Now admins can choose a title for their About and Privacy pages; now editable through the instance page editor. This adds `title` and `content_type` fields to the `appcontent` table, requiring a migration by running `writefreely --migrate` The content_type field specifies that items we're currently storing in this table are all "page"s; queries for fetching these have been updated to filter for this type. In the future, this field will be used to indicate when an item is a stylesheet (ref T563) or other supported type. Ref T566tags/v0.9.0
@@ -11,6 +11,7 @@ | |||
package writefreely | |||
import ( | |||
"database/sql" | |||
"fmt" | |||
"github.com/gogits/gogs/pkg/tool" | |||
"github.com/gorilla/mux" | |||
@@ -81,7 +82,7 @@ type inspectedCollection struct { | |||
type instanceContent struct { | |||
ID string | |||
Type string | |||
Title string | |||
Title sql.NullString | |||
Content string | |||
Updated time.Time | |||
} | |||
@@ -249,19 +250,26 @@ func handleViewAdminPages(app *app, u *User, w http.ResponseWriter, r *http.Requ | |||
// Add in default pages | |||
var hasAbout, hasPrivacy bool | |||
for _, c := range p.Pages { | |||
for i, c := range p.Pages { | |||
if hasAbout && hasPrivacy { | |||
break | |||
} | |||
if c.ID == "about" { | |||
hasAbout = true | |||
if !c.Title.Valid { | |||
p.Pages[i].Title = defaultAboutTitle(app.cfg) | |||
} | |||
} else if c.ID == "privacy" { | |||
hasPrivacy = true | |||
if !c.Title.Valid { | |||
p.Pages[i].Title = defaultPrivacyTitle() | |||
} | |||
} | |||
} | |||
if !hasAbout { | |||
p.Pages = append(p.Pages, &instanceContent{ | |||
ID: "about", | |||
Title: defaultAboutTitle(app.cfg), | |||
Content: defaultAboutPage(app.cfg), | |||
Updated: defaultPageUpdatedTime, | |||
}) | |||
@@ -269,6 +277,7 @@ func handleViewAdminPages(app *app, u *User, w http.ResponseWriter, r *http.Requ | |||
if !hasPrivacy { | |||
p.Pages = append(p.Pages, &instanceContent{ | |||
ID: "privacy", | |||
Title: defaultPrivacyTitle(), | |||
Content: defaultPrivacyPolicy(app.cfg), | |||
Updated: defaultPageUpdatedTime, | |||
}) | |||
@@ -308,7 +317,13 @@ func handleViewAdminPage(app *app, u *User, w http.ResponseWriter, r *http.Reque | |||
if err != nil { | |||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)} | |||
} | |||
p.UserPage = NewUserPage(app, r, u, p.Content.ID, nil) | |||
title := "New page" | |||
if p.Content != nil { | |||
title = "Edit " + p.Content.ID | |||
} else { | |||
p.Content = &instanceContent{} | |||
} | |||
p.UserPage = NewUserPage(app, r, u, title, nil) | |||
showUserPage(w, "view-page", p) | |||
return nil | |||
@@ -325,7 +340,7 @@ func handleAdminUpdateSite(app *app, u *User, w http.ResponseWriter, r *http.Req | |||
// Update page | |||
m := "" | |||
err := app.db.UpdateDynamicContent(id, r.FormValue("content")) | |||
err := app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page") | |||
if err != nil { | |||
m = "?m=" + err.Error() | |||
} | |||
@@ -115,6 +115,7 @@ func handleViewHome(app *app, w http.ResponseWriter, r *http.Request) error { | |||
func handleTemplatedPage(app *app, w http.ResponseWriter, r *http.Request, t *template.Template) error { | |||
p := struct { | |||
page.StaticPage | |||
ContentTitle string | |||
Content template.HTML | |||
PlainContent string | |||
Updated string | |||
@@ -141,6 +142,7 @@ func handleTemplatedPage(app *app, w http.ResponseWriter, r *http.Request, t *te | |||
if err != nil { | |||
return err | |||
} | |||
p.ContentTitle = c.Title.String | |||
p.Content = template.HTML(applyMarkdown([]byte(c.Content), "")) | |||
p.PlainContent = shortPostDescription(stripmd.Strip(c.Content)) | |||
if !c.Updated.IsZero() { | |||
@@ -116,7 +116,7 @@ type writestore interface { | |||
CreateInvitedUser(inviteID string, userID int64) error | |||
GetDynamicContent(id string) (*instanceContent, error) | |||
UpdateDynamicContent(id, content string) error | |||
UpdateDynamicContent(id, title, content, contentType string) error | |||
GetAllUsers(page uint) (*[]User, error) | |||
GetAllUsersCount() int64 | |||
GetUserLastPostTime(id int64) (*time.Time, error) | |||
@@ -2263,7 +2263,17 @@ func (db *datastore) CreateInvitedUser(inviteID string, userID int64) error { | |||
} | |||
func (db *datastore) GetInstancePages() ([]*instanceContent, error) { | |||
rows, err := db.Query("SELECT id, content, updated FROM appcontent") | |||
return db.GetAllDynamicContent("page") | |||
} | |||
func (db *datastore) GetAllDynamicContent(t string) ([]*instanceContent, error) { | |||
where := "" | |||
params := []interface{}{} | |||
if t != "" { | |||
where = " WHERE content_type = ?" | |||
params = append(params, t) | |||
} | |||
rows, err := db.Query("SELECT id, title, content, updated, content_type FROM appcontent"+where, params...) | |||
if err != nil { | |||
log.Error("Failed selecting from appcontent: %v", err) | |||
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve instance pages."} | |||
@@ -2273,7 +2283,7 @@ func (db *datastore) GetInstancePages() ([]*instanceContent, error) { | |||
pages := []*instanceContent{} | |||
for rows.Next() { | |||
c := &instanceContent{} | |||
err = rows.Scan(&c.ID, &c.Content, &c.Updated) | |||
err = rows.Scan(&c.ID, &c.Title, &c.Content, &c.Updated, &c.Type) | |||
if err != nil { | |||
log.Error("Failed scanning row: %v", err) | |||
break | |||
@@ -2292,7 +2302,7 @@ func (db *datastore) GetDynamicContent(id string) (*instanceContent, error) { | |||
c := &instanceContent{ | |||
ID: id, | |||
} | |||
err := db.QueryRow("SELECT content, updated FROM appcontent WHERE id = ?", id).Scan(&c.Content, &c.Updated) | |||
err := db.QueryRow("SELECT title, content, updated, content_type FROM appcontent WHERE id = ?", id).Scan(&c.Title, &c.Content, &c.Updated, &c.Type) | |||
switch { | |||
case err == sql.ErrNoRows: | |||
return nil, nil | |||
@@ -2303,12 +2313,12 @@ func (db *datastore) GetDynamicContent(id string) (*instanceContent, error) { | |||
return c, nil | |||
} | |||
func (db *datastore) UpdateDynamicContent(id, content string) error { | |||
func (db *datastore) UpdateDynamicContent(id, title, content, contentType string) error { | |||
var err error | |||
if db.driverName == driverSQLite { | |||
_, err = db.Exec("INSERT OR REPLACE INTO appcontent (id, content, updated) VALUES (?, ?, "+db.now()+")", id, content) | |||
_, err = db.Exec("INSERT OR REPLACE INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?)", id, title, content, contentType) | |||
} else { | |||
_, err = db.Exec("INSERT INTO appcontent (id, content, updated) VALUES (?, ?, "+db.now()+") "+db.upsert("id")+" content = ?, updated = "+db.now(), id, content, content) | |||
_, err = db.Exec("INSERT INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?) "+db.upsert("id")+" title = ?, content = ?, updated = "+db.now(), id, title, content, contentType, title, content) | |||
} | |||
if err != nil { | |||
log.Error("Unable to INSERT appcontent for '%s': %v", id, err) | |||
@@ -47,6 +47,13 @@ func (db *datastore) typeChar(l int) string { | |||
return fmt.Sprintf("CHAR(%d)", l) | |||
} | |||
func (db *datastore) typeVarChar(l int) string { | |||
if db.driverName == driverSQLite { | |||
return "TEXT" | |||
} | |||
return fmt.Sprintf("VARCHAR(%d)", l) | |||
} | |||
func (db *datastore) typeBool() string { | |||
if db.driverName == driverSQLite { | |||
return "INTEGER" | |||
@@ -58,6 +65,13 @@ func (db *datastore) typeDateTime() string { | |||
return "DATETIME" | |||
} | |||
func (db *datastore) collateMultiByte() string { | |||
if db.driverName == driverSQLite { | |||
return "" | |||
} | |||
return " COLLATE utf8_bin" | |||
} | |||
func (db *datastore) engine() string { | |||
if db.driverName == driverSQLite { | |||
return "" | |||
@@ -55,7 +55,8 @@ func (m *migration) Migrate(db *datastore) error { | |||
} | |||
var migrations = []Migration{ | |||
New("support user invites", supportUserInvites), // -> V1 (v0.8.0) | |||
New("support user invites", supportUserInvites), // -> V1 (v0.8.0) | |||
New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0) | |||
} | |||
// CurrentVer returns the current migration version the application is on | |||
@@ -0,0 +1,35 @@ | |||
/* | |||
* Copyright © 2019 A Bunch Tell LLC. | |||
* | |||
* This file is part of WriteFreely. | |||
* | |||
* WriteFreely is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License, included | |||
* in the LICENSE file in this source code package. | |||
*/ | |||
package migrations | |||
func supportInstancePages(db *datastore) error { | |||
t, err := db.Begin() | |||
_, err = t.Exec(`ALTER TABLE appcontent ADD COLUMN title ` + db.typeVarChar(255) + db.collateMultiByte() + ` NULL`) | |||
if err != nil { | |||
t.Rollback() | |||
return err | |||
} | |||
_, err = t.Exec(`ALTER TABLE appcontent ADD COLUMN content_type ` + db.typeVarChar(36) + ` DEFAULT 'page' NOT NULL`) | |||
if err != nil { | |||
t.Rollback() | |||
return err | |||
} | |||
err = t.Commit() | |||
if err != nil { | |||
t.Rollback() | |||
return err | |||
} | |||
return nil | |||
} |
@@ -11,6 +11,7 @@ | |||
package writefreely | |||
import ( | |||
"database/sql" | |||
"github.com/writeas/writefreely/config" | |||
"time" | |||
) | |||
@@ -25,12 +26,20 @@ func getAboutPage(app *app) (*instanceContent, error) { | |||
if c == nil { | |||
c = &instanceContent{ | |||
ID: "about", | |||
Type: "page", | |||
Content: defaultAboutPage(app.cfg), | |||
} | |||
} | |||
if !c.Title.Valid { | |||
c.Title = defaultAboutTitle(app.cfg) | |||
} | |||
return c, nil | |||
} | |||
func defaultAboutTitle(cfg *config.Config) sql.NullString { | |||
return sql.NullString{String: "About " + cfg.App.SiteName, Valid: true} | |||
} | |||
func getPrivacyPage(app *app) (*instanceContent, error) { | |||
c, err := app.db.GetDynamicContent("privacy") | |||
if err != nil { | |||
@@ -39,13 +48,21 @@ func getPrivacyPage(app *app) (*instanceContent, error) { | |||
if c == nil { | |||
c = &instanceContent{ | |||
ID: "privacy", | |||
Type: "page", | |||
Content: defaultPrivacyPolicy(app.cfg), | |||
Updated: defaultPageUpdatedTime, | |||
} | |||
} | |||
if !c.Title.Valid { | |||
c.Title = defaultPrivacyTitle() | |||
} | |||
return c, nil | |||
} | |||
func defaultPrivacyTitle() sql.NullString { | |||
return sql.NullString{String: "Privacy Policy", Valid: true} | |||
} | |||
func defaultAboutPage(cfg *config.Config) string { | |||
if cfg.App.Federation { | |||
return `_` + cfg.App.SiteName + `_ is an interconnected place for you to write and publish, powered by WriteFreely and ActivityPub.` | |||
@@ -1,9 +1,9 @@ | |||
{{define "head"}}<title>About {{.SiteName}}</title> | |||
{{define "head"}}<title>{{.ContentTitle}} — {{.SiteName}}</title> | |||
<meta name="description" content="{{.PlainContent}}"> | |||
{{end}} | |||
{{define "content"}} | |||
<div class="content-container snug"> | |||
<h1>About {{.SiteName}}</h1> | |||
<h1>{{.ContentTitle}}</h1> | |||
{{.Content}} | |||
@@ -1,8 +1,8 @@ | |||
{{define "head"}}<title>{{.SiteName}} Privacy Policy</title> | |||
{{define "head"}}<title>{{.ContentTitle}} — {{.SiteName}}</title> | |||
<meta name="description" content="{{.PlainContent}}"> | |||
{{end}} | |||
{{define "content"}}<div class="content-container snug"> | |||
<h1>Privacy Policy</h1> | |||
<h1>{{.ContentTitle}}</h1> | |||
<p style="font-style:italic">Last updated {{.Updated}}</p> | |||
{{.Content}} | |||
@@ -1,6 +1,12 @@ | |||
{{define "pages"}} | |||
{{template "header" .}} | |||
<style> | |||
table.classy.export .disabled, table.classy.export a { | |||
text-transform: initial; | |||
} | |||
</style> | |||
<div class="snug content-container"> | |||
{{template "admin-header" .}} | |||
@@ -8,12 +14,12 @@ | |||
<table class="classy export" style="width:100%"> | |||
<tr> | |||
<th>Pages</th> | |||
<th>Page</th> | |||
<th>Last Modified</th> | |||
</tr> | |||
{{range .Pages}} | |||
<tr> | |||
<td><a href="/admin/page/{{.ID}}">{{.ID}}</a></td> | |||
<td><a href="/admin/page/{{.ID}}">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.ID}}{{end}}</a></td> | |||
<td style="text-align:right">{{.UpdatedFriendly}}</td> | |||
</tr> | |||
{{end}} | |||
@@ -1,22 +1,53 @@ | |||
{{define "view-page"}} | |||
{{template "header" .}} | |||
<style> | |||
label { | |||
display: block; | |||
margin-top: 1em; | |||
padding: 0 0 1em; | |||
color: #666; | |||
} | |||
.content-desc { | |||
font-size: 0.95em; | |||
} | |||
.page-desc { | |||
margin: 0 0 0.5em; | |||
} | |||
textarea + .content-desc { | |||
margin: 0.5em 0 1em; | |||
font-style: italic; | |||
} | |||
input[type=text] { | |||
/* Match textarea color. TODO: y is it like this thooo */ | |||
border-color: #ccc; | |||
} | |||
</style> | |||
<div class="snug content-container"> | |||
{{template "admin-header" .}} | |||
<h2 id="posts-header">{{.Content.ID}} page</h2> | |||
{{if .Message}}<p>{{.Message}}</p>{{end}} | |||
{{if eq .Content.ID "about"}} | |||
<p>Describe what your instance is <a href="/about" target="page">about</a>. <em>Accepts Markdown</em>.</p> | |||
<p class="page-desc content-desc">Describe what your instance is <a href="/about" target="page">about</a>.</p> | |||
{{else if eq .Content.ID "privacy"}} | |||
<p>Outline your <a href="/privacy" target="page">privacy policy</a>. <em>Accepts Markdown</em>.</p> | |||
{{else}} | |||
<p><em>Accepts Markdown and HTML</em>.</p> | |||
<p class="page-desc content-desc">Outline your <a href="/privacy" target="page">privacy policy</a>.</p> | |||
{{end}} | |||
{{if .Message}}<p>{{.Message}}</p>{{end}} | |||
<form method="post" action="/admin/update/{{.Content.ID}}" onsubmit="savePage(this)"> | |||
<textarea id="about-editor" class="section codable norm edit-page" name="content">{{.Content.Content}}</textarea> | |||
<label for="title"> | |||
Title | |||
</label> | |||
<input type="text" name="title" id="title" value="{{.Content.Title.String}}" /> | |||
<label for="content"> | |||
Content | |||
</label> | |||
<textarea id="content" class="section codable norm edit-page" name="content">{{.Content.Content}}</textarea> | |||
<p class="content-desc">Accepts Markdown and HTML.</p> | |||
<input type="submit" value="Save" /> | |||
</form> | |||