This allows admin to edit these pages from the web, using Markdown. It also dynamically loads information on those pages now, and makes loading `pages` templates a little easier to find in the code / more explicit. It requires this new schema change: CREATE TABLE IF NOT EXISTS `appcontent` ( `id` varchar(36) NOT NULL, `content` mediumtext CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, `updated` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1; This closes T533tags/v0.3.0^0
@@ -3,6 +3,7 @@ package writefreely | |||||
import ( | import ( | ||||
"fmt" | "fmt" | ||||
"github.com/gogits/gogs/pkg/tool" | "github.com/gogits/gogs/pkg/tool" | ||||
"github.com/gorilla/mux" | |||||
"github.com/writeas/impart" | "github.com/writeas/impart" | ||||
"github.com/writeas/web-core/auth" | "github.com/writeas/web-core/auth" | ||||
"net/http" | "net/http" | ||||
@@ -62,16 +63,47 @@ func handleViewAdminDash(app *app, u *User, w http.ResponseWriter, r *http.Reque | |||||
*UserPage | *UserPage | ||||
Message string | Message string | ||||
SysStatus systemStatus | SysStatus systemStatus | ||||
AboutPage, PrivacyPage string | |||||
}{ | }{ | ||||
NewUserPage(app, r, u, "Admin", nil), | |||||
r.FormValue("m"), | |||||
sysStatus, | |||||
UserPage: NewUserPage(app, r, u, "Admin", nil), | |||||
Message: r.FormValue("m"), | |||||
SysStatus: sysStatus, | |||||
} | |||||
var err error | |||||
p.AboutPage, err = getAboutPage(app) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
p.PrivacyPage, _, err = getPrivacyPage(app) | |||||
if err != nil { | |||||
return err | |||||
} | } | ||||
showUserPage(w, "admin", p) | showUserPage(w, "admin", p) | ||||
return nil | return nil | ||||
} | } | ||||
func handleAdminUpdateSite(app *app, u *User, w http.ResponseWriter, r *http.Request) error { | |||||
vars := mux.Vars(r) | |||||
id := vars["page"] | |||||
// Validate | |||||
if id != "about" && id != "privacy" { | |||||
return impart.HTTPError{http.StatusNotFound, "No such page."} | |||||
} | |||||
// Update page | |||||
m := "" | |||||
err := app.db.UpdateDynamicContent(id, r.FormValue("content")) | |||||
if err != nil { | |||||
m = "?m=" + err.Error() | |||||
} | |||||
return impart.HTTPError{http.StatusFound, "/admin" + m + "#page-" + id} | |||||
} | |||||
func updateAppStats() { | func updateAppStats() { | ||||
sysStatus.Uptime = tool.TimeSincePro(appStartTime) | sysStatus.Uptime = tool.TimeSincePro(appStartTime) | ||||
@@ -93,6 +93,42 @@ func handleViewHome(app *app, w http.ResponseWriter, r *http.Request) error { | |||||
return renderPage(w, "landing.tmpl", p) | return renderPage(w, "landing.tmpl", p) | ||||
} | } | ||||
func handleTemplatedPage(app *app, w http.ResponseWriter, r *http.Request, t *template.Template) error { | |||||
p := struct { | |||||
page.StaticPage | |||||
Content template.HTML | |||||
Updated string | |||||
}{ | |||||
StaticPage: pageForReq(app, r), | |||||
} | |||||
if r.URL.Path == "/about" || r.URL.Path == "/privacy" { | |||||
var c string | |||||
var updated *time.Time | |||||
var err error | |||||
if r.URL.Path == "/about" { | |||||
c, err = getAboutPage(app) | |||||
} else { | |||||
c, updated, err = getPrivacyPage(app) | |||||
} | |||||
if err != nil { | |||||
return err | |||||
} | |||||
p.Content = template.HTML(applyMarkdown([]byte(c))) | |||||
if updated != nil { | |||||
p.Updated = updated.Format("January 2, 2006") | |||||
} | |||||
} | |||||
// Serve templated page | |||||
err := t.ExecuteTemplate(w, "base", p) | |||||
if err != nil { | |||||
log.Error("Unable to render page: %v", err) | |||||
} | |||||
return nil | |||||
} | |||||
func pageForReq(app *app, r *http.Request) page.StaticPage { | func pageForReq(app *app, r *http.Request) page.StaticPage { | ||||
p := page.StaticPage{ | p := page.StaticPage{ | ||||
AppCfg: app.cfg.App, | AppCfg: app.cfg.App, | ||||
@@ -91,6 +91,9 @@ type writestore interface { | |||||
GetAPFollowers(c *Collection) (*[]RemoteUser, error) | GetAPFollowers(c *Collection) (*[]RemoteUser, error) | ||||
GetAPActorKeys(collectionID int64) ([]byte, []byte) | GetAPActorKeys(collectionID int64) ([]byte, []byte) | ||||
GetDynamicContent(id string) (string, *time.Time, error) | |||||
UpdateDynamicContent(id, content string) error | |||||
} | } | ||||
type datastore struct { | type datastore struct { | ||||
@@ -2105,6 +2108,28 @@ func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) { | |||||
return pub, priv | return pub, priv | ||||
} | } | ||||
func (db *datastore) GetDynamicContent(id string) (string, *time.Time, error) { | |||||
var c string | |||||
var u *time.Time | |||||
err := db.QueryRow("SELECT content, updated FROM appcontent WHERE id = ?", id).Scan(&c, &u) | |||||
switch { | |||||
case err == sql.ErrNoRows: | |||||
return "", nil, nil | |||||
case err != nil: | |||||
log.Error("Couldn't SELECT FROM appcontent for id '%s': %v", id, err) | |||||
return "", nil, err | |||||
} | |||||
return c, u, nil | |||||
} | |||||
func (db *datastore) UpdateDynamicContent(id, content string) error { | |||||
_, err := db.Exec("INSERT INTO appcontent (id, content, updated) VALUES (?, ?, NOW()) ON DUPLICATE KEY UPDATE content = ?, updated = NOW()", id, content, content) | |||||
if err != nil { | |||||
log.Error("Unable to INSERT appcontent for '%s': %v", id, err) | |||||
} | |||||
return err | |||||
} | |||||
func stringLogln(log *string, s string, v ...interface{}) { | func stringLogln(log *string, s string, v ...interface{}) { | ||||
*log += fmt.Sprintf(s+"\n", v...) | *log += fmt.Sprintf(s+"\n", v...) | ||||
} | } | ||||
@@ -0,0 +1,4 @@ | |||||
.edit-page { | |||||
font-size: 1em; | |||||
min-height: 12em; | |||||
} |
@@ -4,6 +4,7 @@ | |||||
@import "pad-theme"; | @import "pad-theme"; | ||||
@import "post-temp"; | @import "post-temp"; | ||||
@import "effects"; | @import "effects"; | ||||
@import "admin"; | |||||
@import "pages/error"; | @import "pages/error"; | ||||
@import "lib/elements"; | @import "lib/elements"; | ||||
@import "lib/material"; | @import "lib/material"; |
@@ -0,0 +1,39 @@ | |||||
package writefreely | |||||
import ( | |||||
"time" | |||||
) | |||||
func getAboutPage(app *app) (string, error) { | |||||
c, _, err := app.db.GetDynamicContent("about") | |||||
if err != nil { | |||||
return "", err | |||||
} | |||||
if c == "" { | |||||
if app.cfg.App.Federation { | |||||
c = `_` + app.cfg.App.SiteName + `_ is an interconnected place for you to write and publish, powered by WriteFreely and ActivityPub.` | |||||
} else { | |||||
c = `_` + app.cfg.App.SiteName + `_ is a place for you to write and publish, powered by WriteFreely.` | |||||
} | |||||
} | |||||
return c, nil | |||||
} | |||||
func getPrivacyPage(app *app) (string, *time.Time, error) { | |||||
c, updated, err := app.db.GetDynamicContent("privacy") | |||||
if err != nil { | |||||
return "", nil, err | |||||
} | |||||
if c == "" { | |||||
c = `[Write Freely](https://writefreely.org), the software that powers this site, is built to enforce your right to privacy by default. | |||||
It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database. We salt and hash your account's password. | |||||
We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account. | |||||
Beyond this, it's important that you trust whoever runs **` + app.cfg.App.SiteName + `**. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service.` | |||||
defaultTime := time.Date(2018, 11, 8, 12, 0, 0, 0, time.Local) | |||||
updated = &defaultTime | |||||
} | |||||
return c, updated, nil | |||||
} |
@@ -4,18 +4,7 @@ | |||||
<div class="content-container snug"> | <div class="content-container snug"> | ||||
<h1>About {{.SiteName}}</h1> | <h1>About {{.SiteName}}</h1> | ||||
<!-- | |||||
Feel free to edit this section. Describe what your instance is about! | |||||
--> | |||||
<p> | |||||
{{ if .Federation }} | |||||
<em>{{.SiteName}}</em> is an interconnected place for you to write and publish, powered by WriteFreely and ActivityPub. | |||||
{{ else }} | |||||
<em>{{.SiteName}}</em> is a place for you to write and publish, powered by WriteFreely. | |||||
{{ end }} | |||||
</p> | |||||
{{.Content}} | |||||
<h2 style="margin-top:2em">About WriteFreely</h2> | <h2 style="margin-top:2em">About WriteFreely</h2> | ||||
<p><a href="https://writefreely.org">WriteFreely</a> is a self-hosted, decentralized blogging platform for publishing beautiful, simple blogs.</p> | <p><a href="https://writefreely.org">WriteFreely</a> is a self-hosted, decentralized blogging platform for publishing beautiful, simple blogs.</p> | ||||
@@ -2,10 +2,8 @@ | |||||
{{end}} | {{end}} | ||||
{{define "content"}}<div class="content-container snug"> | {{define "content"}}<div class="content-container snug"> | ||||
<h1>Privacy Policy</h1> | <h1>Privacy Policy</h1> | ||||
<p style="font-style:italic">Last updated November 8, 2018</p> | |||||
<p class="statement"><a href="https://writefreely.org">Write Freely</a>, the software that powers this site, is built to enforce your right to privacy by default.</p> | |||||
<p>It retains as little data about you as possible, not even requiring an email address to sign up. However, if you <em>do</em> give us your email address, it is stored encrypted in our database. We salt and hash your account's password.</p> | |||||
<p>We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.</p> | |||||
<p>Beyond this, it's important that you trust whoever runs <strong>{{.SiteName}}</strong>. Software can only do so much to protect you — your level of privacy protections will ultimately fall on the humans that run this particular service.</p> | |||||
<p style="font-style:italic">Last updated {{.Updated}}</p> | |||||
{{.Content}} | |||||
</div> | </div> | ||||
{{end}} | {{end}} |
@@ -258,12 +258,7 @@ func handleViewPost(app *app, w http.ResponseWriter, r *http.Request) error { | |||||
// Display reserved page if that is requested resource | // Display reserved page if that is requested resource | ||||
if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok { | if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok { | ||||
// Serve templated page | |||||
err := t.ExecuteTemplate(w, "base", pageForReq(app, r)) | |||||
if err != nil { | |||||
log.Error("Unable to render page: %v", err) | |||||
} | |||||
return nil | |||||
return handleTemplatedPage(app, w, r, t) | |||||
} else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" { | } else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" { | ||||
// Serve static file | // Serve static file | ||||
shttp.ServeHTTP(w, r) | shttp.ServeHTTP(w, r) | ||||
@@ -116,6 +116,7 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto | |||||
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") | write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") | ||||
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") | write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") | ||||
write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST") | |||||
// Handle special pages first | // Handle special pages first | ||||
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) | write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) | ||||
@@ -22,6 +22,19 @@ CREATE TABLE IF NOT EXISTS `accesstokens` ( | |||||
-- -------------------------------------------------------- | -- -------------------------------------------------------- | ||||
-- | -- | ||||
-- Table structure for table `appcontent` | |||||
-- | |||||
CREATE TABLE IF NOT EXISTS `appcontent` ( | |||||
`id` varchar(36) NOT NULL, | |||||
`content` mediumtext CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, | |||||
`updated` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
PRIMARY KEY (`id`) | |||||
) ENGINE=InnoDB DEFAULT CHARSET=latin1; | |||||
-- -------------------------------------------------------- | |||||
-- | |||||
-- Table structure for table `collectionattributes` | -- Table structure for table `collectionattributes` | ||||
-- | -- | ||||
@@ -4,7 +4,9 @@ | |||||
<style type="text/css"> | <style type="text/css"> | ||||
h2 {font-weight: normal;} | h2 {font-weight: normal;} | ||||
ul.pagenav {list-style: none;} | ul.pagenav {list-style: none;} | ||||
form {margin: 2em 0;} | |||||
form { | |||||
margin: 0 0 2em; | |||||
} | |||||
.ui.divider:not(.vertical):not(.horizontal) { | .ui.divider:not(.vertical):not(.horizontal) { | ||||
border-top: 1px solid rgba(34,36,38,.15); | border-top: 1px solid rgba(34,36,38,.15); | ||||
border-bottom: 1px solid rgba(255,255,255,.1); | border-bottom: 1px solid rgba(255,255,255,.1); | ||||
@@ -26,18 +28,59 @@ form {margin: 2em 0;} | |||||
} | } | ||||
</style> | </style> | ||||
<div class="content-container tight"> | |||||
<h2>Admin Dashboard</h2> | |||||
<script> | |||||
function savePage(el) { | |||||
var $btn = el.querySelector('input[type=submit]'); | |||||
$btn.value = 'Saving...'; | |||||
$btn.disabled = true; | |||||
} | |||||
</script> | |||||
<div class="content-container snug"> | |||||
<h1>Admin Dashboard</h1> | |||||
{{if .Message}}<p>{{.Message}}</p>{{end}} | {{if .Message}}<p>{{.Message}}</p>{{end}} | ||||
<ul class="pagenav"> | <ul class="pagenav"> | ||||
{{if not .SingleUser}} | |||||
<li><a href="#page-about">Edit About page</a></li> | |||||
<li><a href="#page-privacy">Edit Privacy page</a></li> | |||||
{{end}} | |||||
<li><a href="#reset-pass">Reset user password</a></li> | |||||
<li><a href="#monitor">Application monitor</a></li> | <li><a href="#monitor">Application monitor</a></li> | ||||
</ul> | </ul> | ||||
<hr /> | <hr /> | ||||
<h3><a name="monitor"></a>application monitor</h3> | |||||
{{if not .SingleUser}} | |||||
<h2>Site</h2> | |||||
<h3 id="page-about">About page</h3> | |||||
<p>Describe what your instance is <a href="/privacy">about</a>. <em>Accepts Markdown</em>.</p> | |||||
<form method="post" action="/admin/update/about" onsubmit="savePage(this)"> | |||||
<textarea id="about-editor" class="section codable norm edit-page" name="content">{{.AboutPage}}</textarea> | |||||
<input type="submit" value="Save" /> | |||||
</form> | |||||
<h3 id="page-privacy">Privacy page</h3> | |||||
<p>Outline your <a href="/privacy">privacy policy</a>. <em>Accepts Markdown</em>.</p> | |||||
<form method="post" action="/admin/update/privacy" onsubmit="savePage(this)"> | |||||
<textarea id="privacy-editor" class="section codable norm edit-page" name="content">{{.PrivacyPage}}</textarea> | |||||
<input type="submit" value="Save" /> | |||||
</form> | |||||
<hr /> | |||||
{{end}} | |||||
<h2>Users</h2> | |||||
<h3><a name="reset-pass"></a>reset password</h3> | |||||
<pre><code>writefreely --reset-pass <username></code></pre> | |||||
<hr /> | |||||
<h2><a name="monitor"></a>Application</h2> | |||||
<div class="ui attached table segment"> | <div class="ui attached table segment"> | ||||
<dl class="dl-horizontal admin-dl-horizontal"> | <dl class="dl-horizontal admin-dl-horizontal"> | ||||
<dt>Server Uptime</dt> | <dt>Server Uptime</dt> | ||||
@@ -103,6 +146,8 @@ form {margin: 2em 0;} | |||||
</dl> | </dl> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
{{template "footer" .}} | {{template "footer" .}} | ||||
{{template "body-end" .}} | {{template "body-end" .}} | ||||
{{end}} | {{end}} |