Merge branch 'develop' into chorus

This commit is contained in:
Matt Baer 2019-08-08 07:55:49 -04:00
commit deec914ccb
17 changed files with 90 additions and 44 deletions

View File

@ -168,7 +168,7 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
} }
// Create actual user // Create actual user
if err := app.db.CreateUser(u, desiredUsername); err != nil { if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
return nil, err return nil, err
} }

View File

@ -391,6 +391,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
if apper.App().cfg.App.UserInvites == "none" { if apper.App().cfg.App.UserInvites == "none" {
apper.App().cfg.App.UserInvites = "" apper.App().cfg.App.UserInvites = ""
} }
apper.App().cfg.App.DefaultVisibility = r.FormValue("default_visibility")
m := "?cm=Configuration+saved." m := "?cm=Configuration+saved."
err = apper.SaveConfig(apper.App().cfg) err = apper.SaveConfig(apper.App().cfg)

4
app.go
View File

@ -558,7 +558,7 @@ func DoConfig(app *App, configSections string) {
// Create blog // Create blog
log.Info("Creating user %s...\n", u.Username) log.Info("Creating user %s...\n", u.Username)
err = app.db.CreateUser(u, app.cfg.App.SiteName) err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName)
if err != nil { if err != nil {
log.Error("Unable to create user: %s", err) log.Error("Unable to create user: %s", err)
os.Exit(1) os.Exit(1)
@ -753,7 +753,7 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
userType = "admin" userType = "admin"
} }
log.Info("Creating %s %s...", userType, usernameDesc) log.Info("Creating %s %s...", userType, usernameDesc)
err = apper.App().db.CreateUser(u, desiredUsername) err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername)
if err != nil { if err != nil {
return fmt.Errorf("Unable to create user: %s", err) return fmt.Errorf("Unable to create user: %s", err)
} }

View File

@ -31,6 +31,7 @@ import (
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
waposts "github.com/writeas/web-core/posts" waposts "github.com/writeas/web-core/posts"
"github.com/writeas/writefreely/author" "github.com/writeas/writefreely/author"
"github.com/writeas/writefreely/config"
"github.com/writeas/writefreely/page" "github.com/writeas/writefreely/page"
) )
@ -126,6 +127,21 @@ const (
CollProtected CollProtected
) )
var collVisibilityStrings = map[string]collVisibility{
"unlisted": CollUnlisted,
"public": CollPublic,
"private": CollPrivate,
"protected": CollProtected,
}
func defaultVisibility(cfg *config.Config) collVisibility {
vis, ok := collVisibilityStrings[cfg.App.DefaultVisibility]
if !ok {
vis = CollUnlisted
}
return vis
}
func (cf *CollectionFormat) Ascending() bool { func (cf *CollectionFormat) Ascending() bool {
return cf.Format == "novel" return cf.Format == "novel"
} }
@ -362,37 +378,33 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)} return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)}
} }
var userID int64
if reqJSON && !c.Web { if reqJSON && !c.Web {
accessToken = r.Header.Get("Authorization") accessToken = r.Header.Get("Authorization")
if accessToken == "" { if accessToken == "" {
return ErrNoAccessToken return ErrNoAccessToken
} }
userID = app.db.GetUserID(accessToken)
if userID == -1 {
return ErrBadAccessToken
}
} else { } else {
u = getUserSession(app, r) u = getUserSession(app, r)
if u == nil { if u == nil {
return ErrNotLoggedIn return ErrNotLoggedIn
} }
userID = u.ID
} }
if !author.IsValidUsername(app.cfg, c.Alias) { if !author.IsValidUsername(app.cfg, c.Alias) {
return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."} return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."}
} }
var coll *Collection coll, err := app.db.CreateCollection(app.cfg, c.Alias, c.Title, userID)
var err error
if accessToken != "" {
coll, err = app.db.CreateCollectionFromToken(c.Alias, c.Title, accessToken)
if err != nil { if err != nil {
// TODO: handle this // TODO: handle this
return err return err
} }
} else {
coll, err = app.db.CreateCollection(c.Alias, c.Title, u.ID)
if err != nil {
// TODO: handle this
return err
}
}
res := &CollectionObj{Collection: *coll} res := &CollectionObj{Collection: *coll}

View File

@ -69,6 +69,7 @@ type (
WebFonts bool `ini:"webfonts"` WebFonts bool `ini:"webfonts"`
Landing string `ini:"landing"` Landing string `ini:"landing"`
SimpleNav bool `ini:"simple_nav"` SimpleNav bool `ini:"simple_nav"`
WFModesty bool `ini:"wf_modesty"`
Chorus bool `ini:"chorus"` Chorus bool `ini:"chorus"`
// Users // Users
@ -87,6 +88,9 @@ type (
// Additional functions // Additional functions
LocalTimeline bool `ini:"local_timeline"` LocalTimeline bool `ini:"local_timeline"`
UserInvites string `ini:"user_invites"` UserInvites string `ini:"user_invites"`
// Defaults
DefaultVisibility string `ini:"default_visibility"`
} }
// Config holds the complete configuration for running a writefreely instance // Config holds the complete configuration for running a writefreely instance

View File

@ -45,7 +45,7 @@ var (
) )
type writestore interface { type writestore interface {
CreateUser(*User, string) error CreateUser(*config.Config, *User, string) error
UpdateUserEmail(keys *key.Keychain, userID int64, email string) error UpdateUserEmail(keys *key.Keychain, userID int64, email string) error
UpdateEncryptedUserEmail(int64, []byte) error UpdateEncryptedUserEmail(int64, []byte) error
GetUserByID(int64) (*User, error) GetUserByID(int64) (*User, error)
@ -83,8 +83,8 @@ type writestore interface {
GetOwnedPost(id string, ownerID int64) (*PublicPost, error) GetOwnedPost(id string, ownerID int64) (*PublicPost, error)
GetPostProperty(id string, collectionID int64, property string) (interface{}, error) GetPostProperty(id string, collectionID int64, property string) (interface{}, error)
CreateCollectionFromToken(string, string, string) (*Collection, error) CreateCollectionFromToken(*config.Config, string, string, string) (*Collection, error)
CreateCollection(string, string, int64) (*Collection, error) CreateCollection(*config.Config, string, string, int64) (*Collection, error)
GetCollectionBy(condition string, value interface{}) (*Collection, error) GetCollectionBy(condition string, value interface{}) (*Collection, error)
GetCollection(alias string) (*Collection, error) GetCollection(alias string) (*Collection, error)
GetCollectionForPad(alias string) (*Collection, error) GetCollectionForPad(alias string) (*Collection, error)
@ -103,7 +103,7 @@ type writestore interface {
CanCollect(cpr *ClaimPostRequest, userID int64) bool CanCollect(cpr *ClaimPostRequest, userID int64) bool
AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error) AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error)
DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error) DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error)
ClaimPosts(userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
GetPostsCount(c *CollectionObj, includeFuture bool) GetPostsCount(c *CollectionObj, includeFuture bool)
GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error)
@ -163,7 +163,7 @@ func (db *datastore) dateSub(l int, unit string) string {
return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit) return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit)
} }
func (db *datastore) CreateUser(u *User, collectionTitle string) error { func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string) error {
if db.PostIDExists(u.Username) { if db.PostIDExists(u.Username) {
return impart.HTTPError{http.StatusConflict, "Invalid collection name."} return impart.HTTPError{http.StatusConflict, "Invalid collection name."}
} }
@ -197,7 +197,7 @@ func (db *datastore) CreateUser(u *User, collectionTitle string) error {
if collectionTitle == "" { if collectionTitle == "" {
collectionTitle = u.Username collectionTitle = u.Username
} }
res, err = t.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", u.Username, collectionTitle, "", CollUnlisted, u.ID, 0) res, err = t.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", u.Username, collectionTitle, "", defaultVisibility(cfg), u.ID, 0)
if err != nil { if err != nil {
t.Rollback() t.Rollback()
if db.isDuplicateKeyErr(err) { if db.isDuplicateKeyErr(err) {
@ -239,13 +239,13 @@ func (db *datastore) UpdateEncryptedUserEmail(userID int64, encEmail []byte) err
return nil return nil
} }
func (db *datastore) CreateCollectionFromToken(alias, title, accessToken string) (*Collection, error) { func (db *datastore) CreateCollectionFromToken(cfg *config.Config, alias, title, accessToken string) (*Collection, error) {
userID := db.GetUserID(accessToken) userID := db.GetUserID(accessToken)
if userID == -1 { if userID == -1 {
return nil, ErrBadAccessToken return nil, ErrBadAccessToken
} }
return db.CreateCollection(alias, title, userID) return db.CreateCollection(cfg, alias, title, userID)
} }
func (db *datastore) GetUserCollectionCount(userID int64) (uint64, error) { func (db *datastore) GetUserCollectionCount(userID int64) (uint64, error) {
@ -262,13 +262,13 @@ func (db *datastore) GetUserCollectionCount(userID int64) (uint64, error) {
return collCount, nil return collCount, nil
} }
func (db *datastore) CreateCollection(alias, title string, userID int64) (*Collection, error) { func (db *datastore) CreateCollection(cfg *config.Config, alias, title string, userID int64) (*Collection, error) {
if db.PostIDExists(alias) { if db.PostIDExists(alias) {
return nil, impart.HTTPError{http.StatusConflict, "Invalid collection name."} return nil, impart.HTTPError{http.StatusConflict, "Invalid collection name."}
} }
// All good, so create new collection // All good, so create new collection
res, err := db.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", alias, title, "", CollUnlisted, userID, 0) res, err := db.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", alias, title, "", defaultVisibility(cfg), userID, 0)
if err != nil { if err != nil {
if db.isDuplicateKeyErr(err) { if db.isDuplicateKeyErr(err) {
return nil, impart.HTTPError{http.StatusConflict, "Collection already exists."} return nil, impart.HTTPError{http.StatusConflict, "Collection already exists."}
@ -282,6 +282,7 @@ func (db *datastore) CreateCollection(alias, title string, userID int64) (*Colle
Title: title, Title: title,
OwnerID: userID, OwnerID: userID,
PublicOwner: false, PublicOwner: false,
Public: defaultVisibility(cfg) == CollPublic,
} }
c.ID, err = res.LastInsertId() c.ID, err = res.LastInsertId()
@ -1326,7 +1327,7 @@ func (db *datastore) DispersePosts(userID int64, postIDs []string) (*[]ClaimPost
return &res, nil return &res, nil
} }
func (db *datastore) ClaimPosts(userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) { func (db *datastore) ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) {
postClaimReqs := map[string]bool{} postClaimReqs := map[string]bool{}
res := []ClaimPostResult{} res := []ClaimPostResult{}
postCollAlias := collAlias postCollAlias := collAlias
@ -1383,7 +1384,7 @@ func (db *datastore) ClaimPosts(userID int64, collAlias string, posts *[]ClaimPo
// This is a new collection // This is a new collection
// TODO: consider removing this. This seriously complicates this // TODO: consider removing this. This seriously complicates this
// method and adds another (unnecessary?) logic path. // method and adds another (unnecessary?) logic path.
coll, err = db.CreateCollection(postCollAlias, "", userID) coll, err = db.CreateCollection(cfg, postCollAlias, "", userID)
if err != nil { if err != nil {
if err, ok := err.(impart.HTTPError); ok { if err, ok := err.(impart.HTTPError); ok {
r.Code = err.Status r.Code = err.Status

4
pad.go
View File

@ -60,6 +60,10 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
if action == "" && slug == "" { if action == "" && slug == "" {
// Not editing any post; simply render the Pad // Not editing any post; simply render the Pad
if templates[padTmpl] == nil {
log.Info("No template '%s' found. Falling back to default 'pad' template.", padTmpl)
padTmpl = "pad"
}
if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil { if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil {
log.Error("Unable to execute template: %v", err) log.Error("Unable to execute template: %v", err)
} }

View File

@ -65,9 +65,9 @@ func defaultPrivacyTitle() sql.NullString {
func defaultAboutPage(cfg *config.Config) string { func defaultAboutPage(cfg *config.Config) string {
if cfg.App.Federation { if cfg.App.Federation {
return `_` + cfg.App.SiteName + `_ is an interconnected place for you to write and publish, powered by WriteFreely and ActivityPub.` return `_` + cfg.App.SiteName + `_ is an interconnected place for you to write and publish, powered by [WriteFreely](https://writefreely.org) and ActivityPub.`
} }
return `_` + cfg.App.SiteName + `_ is a place for you to write and publish, powered by WriteFreely.` return `_` + cfg.App.SiteName + `_ is a place for you to write and publish, powered by [WriteFreely](https://writefreely.org).`
} }
func defaultPrivacyPolicy(cfg *config.Config) string { func defaultPrivacyPolicy(cfg *config.Config) string {

View File

@ -12,6 +12,7 @@
<p><em>{{.SiteName}}</em> is home to <strong>{{largeNumFmt .AboutStats.NumPosts}}</strong> {{pluralize "article" "articles" .AboutStats.NumPosts}} across <strong>{{largeNumFmt .AboutStats.NumBlogs}}</strong> {{pluralize "blog" "blogs" .AboutStats.NumBlogs}}.</p> <p><em>{{.SiteName}}</em> is home to <strong>{{largeNumFmt .AboutStats.NumPosts}}</strong> {{pluralize "article" "articles" .AboutStats.NumPosts}} across <strong>{{largeNumFmt .AboutStats.NumBlogs}}</strong> {{pluralize "blog" "blogs" .AboutStats.NumBlogs}}.</p>
{{end}} {{end}}
{{if not .WFModesty}}
<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>
<p>It lets you publish a single blog, or host a community of writers who can create multiple blogs under one account. You can also enable federation, which allows people in the fediverse to follow your blog, bookmark your posts, and share them with others.</p> <p>It lets you publish a single blog, or host a community of writers who can create multiple blogs under one account. You can also enable federation, which allows people in the fediverse to follow your blog, bookmark your posts, and share them with others.</p>
@ -23,5 +24,6 @@
<p><a href="https://writefreely.org">WriteFreely</a></p> <p><a href="https://writefreely.org">WriteFreely</a></p>
</div> </div>
</div> </div>
{{end}}
</div> </div>
{{end}} {{end}}

View File

@ -870,7 +870,7 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
collAlias := vars["alias"] collAlias := vars["alias"]
// Update all given posts // Update all given posts
res, err := app.db.ClaimPosts(ownerID, collAlias, claims) res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims)
if err != nil { if err != nil {
return err return err
} }

View File

@ -141,7 +141,7 @@ function pinPost(e, postID, slug, title) {
var $header = document.getElementsByTagName('header')[0]; var $header = document.getElementsByTagName('header')[0];
var $pinnedNavs = $header.getElementsByTagName('nav'); var $pinnedNavs = $header.getElementsByTagName('nav');
// Add link to nav // Add link to nav
var link = '<a class="pinned" href="/{{.Alias}}/'+slug+'">'+title+'</a>'; var link = '<a class="pinned" href="{{if not .SingleUser}}/{{.Alias}}/{{end}}'+slug+'">'+title+'</a>';
if ($pinnedNavs.length == 0) { if ($pinnedNavs.length == 0) {
$header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>'); $header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>');
} else { } else {

View File

@ -177,7 +177,7 @@ function pinPost(e, postID, slug, title) {
var $header = document.getElementsByTagName('header')[0]; var $header = document.getElementsByTagName('header')[0];
var $pinnedNavs = $header.getElementsByTagName('nav'); var $pinnedNavs = $header.getElementsByTagName('nav');
// Add link to nav // Add link to nav
var link = '<a class="pinned" href="/{{.Alias}}/'+slug+'">'+title+'</a>'; var link = '<a class="pinned" href="{{if not .SingleUser}}/{{.Alias}}/{{end}}'+slug+'">'+title+'</a>';
if ($pinnedNavs.length == 0) { if ($pinnedNavs.length == 0) {
$header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>'); $header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>');
} else { } else {

View File

@ -1,13 +1,21 @@
{{define "footer"}} {{define "footer"}}
<footer{{if not .SingleUser}} class="contain-me"{{end}}> <footer{{if not (or .SingleUser .WFModesty)}} class="contain-me"{{end}}>
<hr /> <hr />
{{if .SingleUser}} {{if or .SingleUser .WFModesty}}
<nav> <nav>
<a class="home" href="/">{{.SiteName}}</a> <a class="home" href="/">{{.SiteName}}</a>
{{if not .SingleUser}}
<a href="/about">about</a>
{{if .LocalTimeline}}<a href="/read">reader</a>{{end}}
{{if .Username}}<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>{{end}}
<a href="/privacy">privacy</a>
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
{{else}}
<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a> <a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>
<a href="https://developers.write.as/" title="Build on WriteFreely with our open developer API.">developers</a> <a href="https://developers.write.as/" title="Build on WriteFreely with our open developer API.">developers</a>
<a href="https://github.com/writeas/writefreely">source code</a> <a href="https://github.com/writeas/writefreely">source code</a>
<a href="https://writefreely.org">writefreely {{.Version}}</a> <a href="https://writefreely.org">writefreely {{.Version}}</a>
{{end}}
</nav> </nav>
{{else}} {{else}}
<div class="marketing-section"> <div class="marketing-section">

View File

@ -95,6 +95,14 @@ p.docs {
<option value="admin" {{if eq .Config.UserInvites "admin"}}selected="selected"{{end}}>Admins</option> <option value="admin" {{if eq .Config.UserInvites "admin"}}selected="selected"{{end}}>Admins</option>
</select> </select>
</dd> </dd>
<dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="default_visibility">Default blog visibility</label></dt>
<dd{{if .Config.SingleUser}} class="invisible"{{end}}>
<select name="default_visibility" id="default_visibility">
<option value="unlisted" {{if eq .Config.DefaultVisibility "unlisted"}}selected="selected"{{end}}>Unlisted</option>
<option value="public" {{if eq .Config.DefaultVisibility "public"}}selected="selected"{{end}}>Public</option>
<option value="private" {{if eq .Config.DefaultVisibility "private"}}selected="selected"{{end}}>Private</option>
</select>
</dd>
</dl> </dl>
<input type="submit" value="Save Configuration" /> <input type="submit" value="Save Configuration" />
</div> </div>

View File

@ -34,8 +34,8 @@
{{if .Summary}}<p>{{.Summary}}</p>{{end}} {{if .Summary}}<p>{{.Summary}}</p>{{end}}
</div>{{end}} </div>{{end}}
</div>{{ else }}<div id="no-posts-published"><p>You haven't saved any drafts yet.</p> </div>{{ else }}<div id="no-posts-published"><p>You haven't saved any drafts yet.</p>
<p>They'll show up here once you do. Find your blog posts from the <a href="/me/c/">Blogs</a> page.</p> <p>They'll show up here once you do. {{if not .SingleUser}}Find your blog posts from the <a href="/me/c/">Blogs</a> page.{{end}}</p>
<p class="text-cta"><a href="/">Start writing</a></p></div>{{ end }} <p class="text-cta"><a href="{{if .SingleUser}}/me/new{{else}}/{{end}}">Start writing</a></p></div>{{ end }}
<div id="moving"></div> <div id="moving"></div>

View File

@ -58,6 +58,7 @@
</label> </label>
<p>A password is required to read this blog.</p> <p>A password is required to read this blog.</p>
</li> </li>
{{if not .SingleUser}}
<li> <li>
<label class="option-text{{if not .LocalTimeline}} disabled{{end}}"><input type="radio" name="visibility" id="visibility-public" value="1" {{if .IsPublic}}checked="checked"{{end}} {{if not .LocalTimeline}}disabled="disabled"{{end}} /> <label class="option-text{{if not .LocalTimeline}} disabled{{end}}"><input type="radio" name="visibility" id="visibility-public" value="1" {{if .IsPublic}}checked="checked"{{end}} {{if not .LocalTimeline}}disabled="disabled"{{end}} />
Public Public
@ -65,6 +66,7 @@
{{if .LocalTimeline}}<p>This blog is displayed on the public <a href="/read">reader</a>, and is visible to {{if .Private}}any registered user on this instance{{else}}anyone with its link{{end}}.</p> {{if .LocalTimeline}}<p>This blog is displayed on the public <a href="/read">reader</a>, and is visible to {{if .Private}}any registered user on this instance{{else}}anyone with its link{{end}}.</p>
{{else}}<p>The public reader is currently turned off for this community.</p>{{end}} {{else}}<p>The public reader is currently turned off for this community.</p>{{end}}
</li> </li>
{{end}}
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -8,11 +8,15 @@
<hr /> <hr />
<nav> <nav>
<a class="home" href="/">{{.SiteName}}</a> <a class="home" href="/">{{.SiteName}}</a>
<a href="/about">about</a> {{if not .SingleUser}}<a href="/about">about</a>{{end}}
{{if and (not .SingleUser) .LocalTimeline}}<a href="/read">reader</a>{{end}} {{if and (not .SingleUser) .LocalTimeline}}<a href="/read">reader</a>{{end}}
<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a> <a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>
<a href="/privacy">privacy</a> {{if not .SingleUser}}<a href="/privacy">privacy</a>{{end}}
{{if .WFModesty}}
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
{{else}}
<a href="https://writefreely.org">writefreely {{.Version}}</a> <a href="https://writefreely.org">writefreely {{.Version}}</a>
{{end}}
</nav> </nav>
</footer> </footer>