Page MenuHomeMusing Studio

No OneTemporary

diff --git a/admin.go b/admin.go
index 99124ae..457b384 100644
--- a/admin.go
+++ b/admin.go
@@ -1,556 +1,637 @@
/*
- * Copyright © 2018-2019 A Bunch Tell LLC.
+ * Copyright © 2018-2020 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 writefreely
import (
"database/sql"
"fmt"
"net/http"
"runtime"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/writeas/impart"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/passgen"
"github.com/writeas/writefreely/appstats"
"github.com/writeas/writefreely/config"
)
var (
appStartTime = time.Now()
sysStatus systemStatus
)
const adminUsersPerPage = 30
type systemStatus struct {
Uptime string
NumGoroutine int
// General statistics.
MemAllocated string // bytes allocated and still in use
MemTotal string // bytes allocated (even if freed)
MemSys string // bytes obtained from system (sum of XxxSys below)
Lookups uint64 // number of pointer lookups
MemMallocs uint64 // number of mallocs
MemFrees uint64 // number of frees
// Main allocation heap statistics.
HeapAlloc string // bytes allocated and still in use
HeapSys string // bytes obtained from system
HeapIdle string // bytes in idle spans
HeapInuse string // bytes in non-idle span
HeapReleased string // bytes released to the OS
HeapObjects uint64 // total number of allocated objects
// Low-level fixed-size structure allocator statistics.
// Inuse is bytes used now.
// Sys is bytes obtained from system.
StackInuse string // bootstrap stacks
StackSys string
MSpanInuse string // mspan structures
MSpanSys string
MCacheInuse string // mcache structures
MCacheSys string
BuckHashSys string // profiling bucket hash table
GCSys string // GC metadata
OtherSys string // other system allocations
// Garbage collector statistics.
NextGC string // next run in HeapAlloc time (bytes)
LastGC string // last run in absolute time (ns)
PauseTotalNs string
PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256]
NumGC uint32
}
type inspectedCollection struct {
CollectionObj
Followers int
LastPost string
}
type instanceContent struct {
ID string
Type string
Title sql.NullString
Content string
Updated time.Time
}
+type AdminPage struct {
+ UpdateAvailable bool
+}
+
+func NewAdminPage(app *App) *AdminPage {
+ ap := &AdminPage{}
+ if app.updates != nil {
+ ap.UpdateAvailable = app.updates.AreAvailableNoCheck()
+ }
+ return ap
+}
+
func (c instanceContent) UpdatedFriendly() string {
/*
// TODO: accept a locale in this method and use that for the format
var loc monday.Locale = monday.LocaleEnUS
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
*/
return c.Updated.Format("January 2, 2006, 3:04 PM")
}
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
+ p := struct {
+ *UserPage
+ *AdminPage
+ Message string
+
+ UsersCount, CollectionsCount, PostsCount int64
+ }{
+ UserPage: NewUserPage(app, r, u, "Admin", nil),
+ AdminPage: NewAdminPage(app),
+ Message: r.FormValue("m"),
+ }
+
+ // Get user stats
+ p.UsersCount = app.db.GetAllUsersCount()
+ var err error
+ p.CollectionsCount, err = app.db.GetTotalCollections()
+ if err != nil {
+ return err
+ }
+ p.PostsCount, err = app.db.GetTotalPosts()
+ if err != nil {
+ return err
+ }
+
+ showUserPage(w, "admin", p)
+ return nil
+}
+
+func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
updateAppStats()
p := struct {
*UserPage
+ *AdminPage
SysStatus systemStatus
Config config.AppCfg
Message, ConfigMessage string
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
+ AdminPage: NewAdminPage(app),
SysStatus: sysStatus,
Config: app.cfg.App,
Message: r.FormValue("m"),
ConfigMessage: r.FormValue("cm"),
}
- showUserPage(w, "admin", p)
+
+ showUserPage(w, "monitor", p)
+ return nil
+}
+
+func handleViewAdminSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
+ p := struct {
+ *UserPage
+ *AdminPage
+ Config config.AppCfg
+
+ Message, ConfigMessage string
+ }{
+ UserPage: NewUserPage(app, r, u, "Admin", nil),
+ AdminPage: NewAdminPage(app),
+ Config: app.cfg.App,
+
+ Message: r.FormValue("m"),
+ ConfigMessage: r.FormValue("cm"),
+ }
+
+ showUserPage(w, "app-settings", p)
return nil
}
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
+ *AdminPage
Config config.AppCfg
Message string
Users *[]User
CurPage int
TotalUsers int64
TotalPages []int
}{
- UserPage: NewUserPage(app, r, u, "Users", nil),
- Config: app.cfg.App,
- Message: r.FormValue("m"),
+ UserPage: NewUserPage(app, r, u, "Users", nil),
+ AdminPage: NewAdminPage(app),
+ Config: app.cfg.App,
+ Message: r.FormValue("m"),
}
p.TotalUsers = app.db.GetAllUsersCount()
ttlPages := p.TotalUsers / adminUsersPerPage
p.TotalPages = []int{}
for i := 1; i <= int(ttlPages); i++ {
p.TotalPages = append(p.TotalPages, i)
}
var err error
p.CurPage, err = strconv.Atoi(r.FormValue("p"))
if err != nil || p.CurPage < 1 {
p.CurPage = 1
} else if p.CurPage > int(ttlPages) {
p.CurPage = int(ttlPages)
}
p.Users, err = app.db.GetAllUsers(uint(p.CurPage))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get users: %v", err)}
}
showUserPage(w, "users", p)
return nil
}
func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
p := struct {
*UserPage
+ *AdminPage
Config config.AppCfg
Message string
User *User
Colls []inspectedCollection
LastPost string
NewPassword string
TotalPosts int64
ClearEmail string
}{
- Config: app.cfg.App,
- Message: r.FormValue("m"),
- Colls: []inspectedCollection{},
+ AdminPage: NewAdminPage(app),
+ Config: app.cfg.App,
+ Message: r.FormValue("m"),
+ Colls: []inspectedCollection{},
}
var err error
p.User, err = app.db.GetUserForAuth(username)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("Could not get user: %v", err)
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
}
flashes, _ := getSessionFlashes(app, w, r, nil)
for _, flash := range flashes {
if strings.HasPrefix(flash, "SUCCESS: ") {
p.NewPassword = strings.TrimPrefix(flash, "SUCCESS: ")
p.ClearEmail = p.User.EmailClear(app.keys)
}
}
p.UserPage = NewUserPage(app, r, u, p.User.Username, nil)
p.TotalPosts = app.db.GetUserPostsCount(p.User.ID)
lp, err := app.db.GetUserLastPostTime(p.User.ID)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's last post time: %v", err)}
}
if lp != nil {
p.LastPost = lp.Format("January 2, 2006, 3:04 PM")
}
colls, err := app.db.GetCollections(p.User, app.cfg.App.Host)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)}
}
for _, c := range *colls {
ic := inspectedCollection{
CollectionObj: CollectionObj{Collection: c},
}
if app.cfg.App.Federation {
folls, err := app.db.GetAPFollowers(&c)
if err == nil {
// TODO: handle error here (at least log it)
ic.Followers = len(*folls)
}
}
app.db.GetPostsCount(&ic.CollectionObj, true)
lp, err := app.db.GetCollectionLastPostTime(c.ID)
if err != nil {
log.Error("Didn't get last post time for collection %d: %v", c.ID, err)
}
if lp != nil {
ic.LastPost = lp.Format("January 2, 2006, 3:04 PM")
}
p.Colls = append(p.Colls, ic)
}
showUserPage(w, "view-user", p)
return nil
}
func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
user, err := app.db.GetUserForAuth(username)
if err != nil {
log.Error("failed to get user: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)}
}
if user.IsSilenced() {
err = app.db.SetUserStatus(user.ID, UserActive)
} else {
err = app.db.SetUserStatus(user.ID, UserSilenced)
}
if err != nil {
log.Error("toggle user silenced: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)}
}
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
}
func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
// Generate new random password since none supplied
pass := passgen.NewWordish()
hashedPass, err := auth.HashPass([]byte(pass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
}
userIDVal := r.FormValue("user")
log.Info("ADMIN: Changing user %s password", userIDVal)
id, err := strconv.Atoi(userIDVal)
if err != nil {
return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid user ID: %v", err)}
}
err = app.db.ChangePassphrase(int64(id), true, "", hashedPass)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
}
log.Info("ADMIN: Successfully changed.")
addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: %s", pass), nil)
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s", username)}
}
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
+ *AdminPage
Config config.AppCfg
Message string
Pages []*instanceContent
}{
- UserPage: NewUserPage(app, r, u, "Pages", nil),
- Config: app.cfg.App,
- Message: r.FormValue("m"),
+ UserPage: NewUserPage(app, r, u, "Pages", nil),
+ AdminPage: NewAdminPage(app),
+ Config: app.cfg.App,
+ Message: r.FormValue("m"),
}
var err error
p.Pages, err = app.db.GetInstancePages()
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get pages: %v", err)}
}
// Add in default pages
var hasAbout, hasPrivacy bool
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,
})
}
if !hasPrivacy {
p.Pages = append(p.Pages, &instanceContent{
ID: "privacy",
Title: defaultPrivacyTitle(),
Content: defaultPrivacyPolicy(app.cfg),
Updated: defaultPageUpdatedTime,
})
}
showUserPage(w, "pages", p)
return nil
}
func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
slug := vars["slug"]
if slug == "" {
return impart.HTTPError{http.StatusFound, "/admin/pages"}
}
p := struct {
*UserPage
+ *AdminPage
Config config.AppCfg
Message string
Banner *instanceContent
Content *instanceContent
}{
- Config: app.cfg.App,
- Message: r.FormValue("m"),
+ AdminPage: NewAdminPage(app),
+ Config: app.cfg.App,
+ Message: r.FormValue("m"),
}
var err error
// Get pre-defined pages, or select slug
if slug == "about" {
p.Content, err = getAboutPage(app)
} else if slug == "privacy" {
p.Content, err = getPrivacyPage(app)
} else if slug == "landing" {
p.Banner, err = getLandingBanner(app)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
}
p.Content, err = getLandingBody(app)
p.Content.ID = "landing"
} else if slug == "reader" {
p.Content, err = getReaderSection(app)
} else {
p.Content, err = app.db.GetDynamicContent(slug)
}
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)}
}
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
}
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" && id != "landing" && id != "reader" {
return impart.HTTPError{http.StatusNotFound, "No such page."}
}
var err error
m := ""
if id == "landing" {
// Handle special landing page
err = app.db.UpdateDynamicContent("landing-banner", "", r.FormValue("banner"), "section")
if err != nil {
m = "?m=" + err.Error()
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
}
err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section")
} else if id == "reader" {
// Update sections with titles
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "section")
} else {
// Update page
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page")
}
if err != nil {
m = "?m=" + err.Error()
}
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
}
func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error {
apper.App().cfg.App.SiteName = r.FormValue("site_name")
apper.App().cfg.App.SiteDesc = r.FormValue("site_desc")
apper.App().cfg.App.Landing = r.FormValue("landing")
apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on"
mul, err := strconv.Atoi(r.FormValue("min_username_len"))
if err == nil {
apper.App().cfg.App.MinUsernameLen = mul
}
mb, err := strconv.Atoi(r.FormValue("max_blogs"))
if err == nil {
apper.App().cfg.App.MaxBlogs = mb
}
apper.App().cfg.App.Federation = r.FormValue("federation") == "on"
apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on"
apper.App().cfg.App.Private = r.FormValue("private") == "on"
apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on"
if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil {
log.Info("Initializing local timeline...")
initLocalTimeline(apper.App())
}
apper.App().cfg.App.UserInvites = r.FormValue("user_invites")
if apper.App().cfg.App.UserInvites == "none" {
apper.App().cfg.App.UserInvites = ""
}
apper.App().cfg.App.DefaultVisibility = r.FormValue("default_visibility")
m := "?cm=Configuration+saved."
err = apper.SaveConfig(apper.App().cfg)
if err != nil {
m = "?cm=" + err.Error()
}
- return impart.HTTPError{http.StatusFound, "/admin" + m + "#config"}
+ return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
}
func updateAppStats() {
sysStatus.Uptime = appstats.TimeSincePro(appStartTime)
m := new(runtime.MemStats)
runtime.ReadMemStats(m)
sysStatus.NumGoroutine = runtime.NumGoroutine()
sysStatus.MemAllocated = appstats.FileSize(int64(m.Alloc))
sysStatus.MemTotal = appstats.FileSize(int64(m.TotalAlloc))
sysStatus.MemSys = appstats.FileSize(int64(m.Sys))
sysStatus.Lookups = m.Lookups
sysStatus.MemMallocs = m.Mallocs
sysStatus.MemFrees = m.Frees
sysStatus.HeapAlloc = appstats.FileSize(int64(m.HeapAlloc))
sysStatus.HeapSys = appstats.FileSize(int64(m.HeapSys))
sysStatus.HeapIdle = appstats.FileSize(int64(m.HeapIdle))
sysStatus.HeapInuse = appstats.FileSize(int64(m.HeapInuse))
sysStatus.HeapReleased = appstats.FileSize(int64(m.HeapReleased))
sysStatus.HeapObjects = m.HeapObjects
sysStatus.StackInuse = appstats.FileSize(int64(m.StackInuse))
sysStatus.StackSys = appstats.FileSize(int64(m.StackSys))
sysStatus.MSpanInuse = appstats.FileSize(int64(m.MSpanInuse))
sysStatus.MSpanSys = appstats.FileSize(int64(m.MSpanSys))
sysStatus.MCacheInuse = appstats.FileSize(int64(m.MCacheInuse))
sysStatus.MCacheSys = appstats.FileSize(int64(m.MCacheSys))
sysStatus.BuckHashSys = appstats.FileSize(int64(m.BuckHashSys))
sysStatus.GCSys = appstats.FileSize(int64(m.GCSys))
sysStatus.OtherSys = appstats.FileSize(int64(m.OtherSys))
sysStatus.NextGC = appstats.FileSize(int64(m.NextGC))
sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000)
sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)
sysStatus.NumGC = m.NumGC
}
func adminResetPassword(app *App, u *User, newPass string) error {
hashedPass, err := auth.HashPass([]byte(newPass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
}
err = app.db.ChangePassphrase(u.ID, true, "", hashedPass)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
}
return nil
}
func handleViewAdminUpdates(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
check := r.URL.Query().Get("check")
if check == "now" && app.cfg.App.UpdateChecks {
app.updates.CheckNow()
}
p := struct {
*UserPage
- LastChecked string
- LatestVersion string
- LatestReleaseURL string
- UpdateAvailable bool
+ *AdminPage
+ CurReleaseNotesURL string
+ LastChecked string
+ LastChecked8601 string
+ LatestVersion string
+ LatestReleaseURL string
+ LatestReleaseNotesURL string
+ CheckFailed bool
}{
- UserPage: NewUserPage(app, r, u, "Updates", nil),
+ UserPage: NewUserPage(app, r, u, "Updates", nil),
+ AdminPage: NewAdminPage(app),
}
+ p.CurReleaseNotesURL = wfReleaseNotesURL(p.Version)
if app.cfg.App.UpdateChecks {
p.LastChecked = app.updates.lastCheck.Format("January 2, 2006, 3:04 PM")
+ p.LastChecked8601 = app.updates.lastCheck.Format("2006-01-02T15:04:05Z")
p.LatestVersion = app.updates.LatestVersion()
p.LatestReleaseURL = app.updates.ReleaseURL()
+ p.LatestReleaseNotesURL = app.updates.ReleaseNotesURL()
p.UpdateAvailable = app.updates.AreAvailable()
+ p.CheckFailed = app.updates.checkError != nil
}
showUserPage(w, "app-updates", p)
return nil
}
diff --git a/config/config.go b/config/config.go
index 31f62f0..78892bf 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,216 +1,217 @@
/*
* Copyright © 2018-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 config holds and assists in the configuration of a writefreely instance.
package config
import (
"strings"
"gopkg.in/ini.v1"
)
const (
// FileName is the default configuration file name
FileName = "config.ini"
UserNormal UserType = "user"
UserAdmin = "admin"
)
type (
UserType string
// ServerCfg holds values that affect how the HTTP server runs
ServerCfg struct {
HiddenHost string `ini:"hidden_host"`
Port int `ini:"port"`
Bind string `ini:"bind"`
TLSCertPath string `ini:"tls_cert_path"`
TLSKeyPath string `ini:"tls_key_path"`
Autocert bool `ini:"autocert"`
TemplatesParentDir string `ini:"templates_parent_dir"`
StaticParentDir string `ini:"static_parent_dir"`
PagesParentDir string `ini:"pages_parent_dir"`
KeysParentDir string `ini:"keys_parent_dir"`
HashSeed string `ini:"hash_seed"`
Dev bool `ini:"-"`
}
// DatabaseCfg holds values that determine how the application connects to a datastore
DatabaseCfg struct {
Type string `ini:"type"`
FileName string `ini:"filename"`
User string `ini:"username"`
Password string `ini:"password"`
Database string `ini:"database"`
Host string `ini:"host"`
Port int `ini:"port"`
}
WriteAsOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
AuthLocation string `ini:"auth_location"`
TokenLocation string `ini:"token_location"`
InspectLocation string `ini:"inspect_location"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
SlackOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
TeamID string `ini:"team_id"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
// AppCfg holds values that affect how the application functions
AppCfg struct {
SiteName string `ini:"site_name"`
SiteDesc string `ini:"site_description"`
Host string `ini:"host"`
// Site appearance
Theme string `ini:"theme"`
Editor string `ini:"editor"`
JSDisabled bool `ini:"disable_js"`
WebFonts bool `ini:"webfonts"`
Landing string `ini:"landing"`
SimpleNav bool `ini:"simple_nav"`
WFModesty bool `ini:"wf_modesty"`
// Site functionality
Chorus bool `ini:"chorus"`
+ Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info.
DisableDrafts bool `ini:"disable_drafts"`
// Users
SingleUser bool `ini:"single_user"`
OpenRegistration bool `ini:"open_registration"`
MinUsernameLen int `ini:"min_username_len"`
MaxBlogs int `ini:"max_blogs"`
// Federation
Federation bool `ini:"federation"`
PublicStats bool `ini:"public_stats"`
// Access
Private bool `ini:"private"`
// Additional functions
LocalTimeline bool `ini:"local_timeline"`
UserInvites string `ini:"user_invites"`
// Defaults
DefaultVisibility string `ini:"default_visibility"`
// Check for Updates
UpdateChecks bool `ini:"update_checks"`
}
// Config holds the complete configuration for running a writefreely instance
Config struct {
Server ServerCfg `ini:"server"`
Database DatabaseCfg `ini:"database"`
App AppCfg `ini:"app"`
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
}
)
// New creates a new Config with sane defaults
func New() *Config {
c := &Config{
Server: ServerCfg{
Port: 8080,
Bind: "localhost", /* IPV6 support when not using localhost? */
},
App: AppCfg{
Host: "http://localhost:8080",
Theme: "write",
WebFonts: true,
SingleUser: true,
MinUsernameLen: 3,
MaxBlogs: 1,
Federation: true,
PublicStats: true,
},
}
c.UseMySQL(true)
return c
}
// UseMySQL resets the Config's Database to use default values for a MySQL setup.
func (cfg *Config) UseMySQL(fresh bool) {
cfg.Database.Type = "mysql"
if fresh {
cfg.Database.Host = "localhost"
cfg.Database.Port = 3306
}
}
// UseSQLite resets the Config's Database to use default values for a SQLite setup.
func (cfg *Config) UseSQLite(fresh bool) {
cfg.Database.Type = "sqlite3"
if fresh {
cfg.Database.FileName = "writefreely.db"
}
}
// IsSecureStandalone returns whether or not the application is running as a
// standalone server with TLS enabled.
func (cfg *Config) IsSecureStandalone() bool {
return cfg.Server.Port == 443 && cfg.Server.TLSCertPath != "" && cfg.Server.TLSKeyPath != ""
}
func (ac *AppCfg) LandingPath() string {
if !strings.HasPrefix(ac.Landing, "/") {
return "/" + ac.Landing
}
return ac.Landing
}
// Load reads the given configuration file, then parses and returns it as a Config.
func Load(fname string) (*Config, error) {
if fname == "" {
fname = FileName
}
cfg, err := ini.Load(fname)
if err != nil {
return nil, err
}
// Parse INI file
uc := &Config{}
err = cfg.MapTo(uc)
if err != nil {
return nil, err
}
return uc, nil
}
// Save writes the given Config to the given file.
func Save(uc *Config, fname string) error {
cfg := ini.Empty()
err := ini.ReflectFrom(cfg, uc)
if err != nil {
return err
}
if fname == "" {
fname = FileName
}
return cfg.SaveTo(fname)
}
diff --git a/less/admin.less b/less/admin.less
index 9c4a7c2..d9d659e 100644
--- a/less/admin.less
+++ b/less/admin.less
@@ -1,44 +1,86 @@
.edit-page {
font-size: 1em;
min-height: 12em;
}
header.admin {
margin: 0;
h1 + a {
margin-left: 1em;
}
}
nav#admin {
display: block;
margin: 0.5em 0;
a {
- color: @primary;
- &:first-child {
- margin-left: 0;
- }
+ margin-left: 0;
+ .rounded(.25em);
+ border: 0;
&.selected {
+ background: #dedede;
font-weight: bold;
+ .blip {
+ color: black;
+ }
}
}
+ .blip {
+ font-weight: bold;
+ }
}
.pager {
display: flex;
justify-content: center;
a {
color: #333;
font-family: @sansFont;
font-size: 0.86em;
padding: 0.5em 1em;
border: 1px solid #ccc;
&:hover {
text-decoration: none;
background: #efefef;
}
&.selected {
cursor: default;
background: #ccc;
}
}
}
+
+.admin-actions {
+ .btn {
+ font-family: @sansFont;
+ font-size: 0.86em;
+ }
+}
+
+.features {
+ margin: 1em 0;
+
+ div {
+ &:first-child {
+ font-weight: bold;
+ }
+ &+div {
+ padding-left: 1em;
+ }
+
+ p {
+ font-weight: normal;
+ margin: 0.5rem 0;
+ font-size: 0.86em;
+ color: #666;
+ }
+ }
+}
+
+@media (max-width: 600px) {
+ div.row.features {
+ align-items: start;
+ }
+ .features div + div {
+ padding-left: 0;
+ }
+}
\ No newline at end of file
diff --git a/less/core.less b/less/core.less
index 931aaa8..f2eaef3 100644
--- a/less/core.less
+++ b/less/core.less
@@ -1,1536 +1,1532 @@
@primary: rgb(114, 120, 191);
@secondary: rgb(114, 191, 133);
@subheaders: #444;
@headerTextColor: black;
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
@dangerCol: #e21d27;
@errUrgentCol: #ecc63c;
@proSelectedCol: #71D571;
@textLinkColor: rgb(0, 0, 238);
body {
font-family: @serifFont;
font-size-adjust: 0.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: white;
color: #111;
h1, header h2 {
a {
color: @headerTextColor;
.transition-duration(0.2s);
&:hover {
color: #303030;
text-decoration: none;
}
}
}
h1, h2, h3 {
line-height: 1.2;
}
&#post article, &#collection article p, &#subpage article p {
display: block;
unicode-bidi: embed;
white-space: pre;
}
&#post {
#wrapper, pre {
max-width: 40em;
margin: 0 auto;
a:hover {
text-decoration: underline;
}
}
blockquote {
p + p {
margin: -2em 0 0.5em;
}
}
article {
margin-bottom: 2em !important;
h1, h2, h3, h4, h5, h6, p, ul, ol, code {
display: inline;
margin: 0;
}
hr + p, ol, ul {
display: block;
margin-top: -1rem;
margin-bottom: -1rem;
}
ol, ul {
margin: 2rem 0 -1rem;
ol, ul {
margin: 1.25rem 0 -0.5rem;
}
}
li {
margin-top: -0.5rem;
margin-bottom: -0.5rem;
}
h2#title {
.article-title;
}
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.17em;
}
}
header {
nav {
span, a {
&.pinned {
&.selected {
font-weight: bold;
}
&+.views {
margin-left: 2em;
}
}
}
}
}
.owner-visible {
display: none;
}
}
&#post, &#collection, &#subpage {
code {
.article-code;
}
img, video, audio {
max-width: 100%;
}
audio {
width: 100%;
white-space: initial;
}
pre {
.code-block;
code {
background: transparent;
border: 0;
padding: 0;
font-size: 1em;
white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
}
blockquote {
.article-blockquote;
}
article {
hr {
margin-top: 0;
margin-bottom: 0;
}
p.badge {
background-color: #aaa;
display: inline-block;
padding: 0.25em 0.5em;
margin: 0;
float: right;
color: white;
.rounded(.25em);
}
}
header {
nav {
span, a {
&.pinned {
&+.pinned {
margin-left: 1.5em;
}
}
}
}
}
footer {
nav {
a {
margin-top: 0;
}
}
}
}
&#collection {
#welcome, .access {
margin: 0 auto;
max-width: 35em;
h2 {
font-weight: normal;
margin-bottom: 1em;
}
p {
font-size: 1.2em;
line-height: 1.6;
}
}
.access {
margin: 8em auto;
text-align: center;
h2, ul.errors {
font-size: 1.2em;
margin-bottom: 1.5em !important;
}
}
header {
padding: 0 1em;
text-align: center;
max-width: 50em;
margin: 3em auto 4em;
.writeas-prefix {
a {
color: #aaa;
}
display: block;
margin-bottom: 0.5em;
}
nav {
display: block;
margin: 1em 0;
a:first-child {
margin: 0;
}
}
}
nav#manage {
position: absolute;
top: 1em;
left: 1.5em;
li a.write {
font-family: @serifFont;
padding-top: 0.2em;
padding-bottom: 0.2em;
}
}
pre {
line-height: 1.5;
}
}
&#subpage {
#wrapper {
h1 {
font-size: 2.5em;
letter-spacing: -2px;
padding: 0 2rem 2rem;
}
}
}
&#post {
pre {
font-size: 0.75em;
}
}
&#collection, &#subpage {
#wrapper {
margin-left: auto;
margin-right: auto;
article {
margin-bottom: 4em;
&:hover {
.hidden {
.opacity(1);
}
}
}
h2 {
margin-top: 0em;
margin-bottom: 0.25em;
&+time {
display: block;
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
time {
font-size: 1.1em;
&+p {
margin-top: 0.25em;
}
}
footer {
text-align: left;
padding: 0;
}
}
#paging {
overflow: visible;
padding: 1em 6em 0;
}
a.read-more {
color: #666;
}
}
&#me #official-writing {
h2 {
font-weight: normal;
a {
font-size: 0.6em;
margin-left: 1em;
}
a[name] {
margin-left: 0;
}
a:link, a:visited {
color: @textLinkColor;
}
a:hover {
text-decoration: underline;
}
}
}
&#promo {
div.heading {
margin: 8em 0;
}
div.heading, div.attention-form {
h1 {
font-size: 3.5em;
}
input {
padding-left: 0.75em;
padding-right: 0.75em;
&[type=email] {
max-width: 16em;
}
&[type=submit] {
padding-left: 1.5em;
padding-right: 1.5em;
}
}
}
h2 {
margin-bottom: 0;
font-size: 1.8em;
font-weight: normal;
span.write-as {
color: black;
}
&.soon {
color: lighten(@subheaders, 50%);
span {
&.write-as {
color: lighten(#000, 50%);
}
&.note {
color: lighten(#333, 50%);
font-variant: small-caps;
margin-left: 0.5em;
}
}
}
}
.half-col a {
margin-left: 1em;
margin-right: 1em;
}
}
nav#top-nav {
display: inline;
position: absolute;
top: 1.5em;
right: 1.5em;
font-size: 0.95rem;
font-family: @sansFont;
text-transform: uppercase;
a {
color: #777;
}
a + a {
margin-left: 1em;
}
}
footer {
nav, ul {
a {
display: inline-block;
margin-top: 0.8em;
.transition-duration(0.1s);
text-decoration: none;
+ a {
margin-left: 0.8em;
}
&:link, &:visited {
color: #999;
}
&:hover {
color: #666;
text-decoration: none;
}
}
}
a.home {
&:link, &:visited {
color: #333;
}
font-weight: bold;
text-decoration: none;
&:hover {
color: #000;
}
}
ul {
list-style: none;
text-align: left;
padding-left: 0 !important;
margin-left: 0 !important;
.icons img {
height: 16px;
width: 16px;
fill: #999;
}
}
}
}
nav#full-nav {
margin: 0;
.left-side {
display: inline-block;
a:first-child {
margin-left: 0;
}
}
.right-side {
float: right;
}
}
nav#full-nav a.simple-btn, .tool button {
font-family: @sansFont;
border: 1px solid #ccc !important;
padding: .5rem 1rem;
margin: 0;
.rounded(.25em);
text-decoration: none;
}
.post-title {
a {
&:link {
color: #333;
}
&:visited {
color: #444;
}
}
time, time a:link, time a:visited, &+.time {
color: #999;
}
}
.hidden {
-moz-transition-property: opacity;
-webkit-transition-property: opacity;
-o-transition-property: opacity;
transition-property: opacity;
.transition-duration(0.4s);
.opacity(0);
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
&.subdued {
color: #999;
&:hover {
border-bottom: 1px solid #999;
text-decoration: none;
}
}
&.danger {
color: @dangerCol;
font-size: 0.86em;
}
&.simple-cta {
text-decoration: none;
border-bottom: 1px solid #ccc;
color: #333;
padding-bottom: 2px;
&:hover {
text-decoration: none;
}
}
&.action-btn {
font-family: @sansFont;
text-transform: uppercase;
.rounded(.25em);
background-color: red;
color: white;
font-weight: bold;
padding: 0.5em 0.75em;
&:hover {
background-color: lighten(#f00, 5%);
text-decoration: none;
}
}
&.hashtag:hover {
text-decoration: none;
span + span {
text-decoration: underline;
}
}
&.hashtag {
span:first-child {
color: #999;
margin-right: 0.1em;
font-size: 0.86em;
text-decoration: none;
}
}
}
abbr {
border-bottom: 1px dotted #999;
text-decoration: none;
cursor: help;
}
body#collection article p, body#subpage article p {
.article-p;
}
pre, body#post article, #post .alert, #subpage .alert, body#collection article, body#subpage article, body#subpage #wrapper h1 {
max-width: 40rem;
margin: 0 auto;
}
#collection header .alert, #post .alert, #subpage .alert {
margin-bottom: 1em;
p {
text-align: left;
line-height: 1.4;
}
}
textarea, pre, body#post article, body#collection article p {
&.norm, &.sans, &.wrap {
line-height: 1.4em;
white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
}
textarea, pre, body#post article, body#collection article, body#subpage article, span, .font {
&.norm {
font-family: @serifFont;
}
&.sans {
font-family: @sansFont;
}
&.mono, &.wrap, &.code {
font-family: @monoFont;
}
&.mono, &.code {
max-width: none !important;
}
}
textarea {
&.section {
border: 1px solid #ccc;
padding: 0.65em 0.75em;
.rounded(.25em);
&.codable {
height: 12em;
resize: vertical;
}
}
}
.ace_editor {
height: 12em;
border: 1px solid #333;
max-width: initial;
width: 100%;
font-size: 0.86em !important;
border: 1px solid #ccc;
padding: 0.65em 0.75em;
margin: 0;
.rounded(.25em);
}
p {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
&.intro {
font-size: 1.25em;
text-align: center;
}
&.upgrade-prompt {
font-size: 0.9em;
color: #444;
}
&.text-cta {
font-size: 1.2em;
text-align: center;
margin-bottom: 0.5em;
&+ p {
text-align: center;
font-size: 0.7em;
margin-top: 0;
color: #666;
}
}
&.error {
font-style: italic;
color: @errUrgentCol;
}
&.headeresque {
font-size: 2em;
}
}
table.classy {
width: 95%;
border-collapse: collapse;
margin-bottom: 2em;
tr + tr {
border-top: 1px solid #ccc;
}
th {
text-transform: uppercase;
font-weight: normal;
font-size: 95%;
font-family: @sansFont;
padding: 1rem 0.75rem;
text-align: center;
}
td {
height: 3.5rem;
}
p {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
&.export {
.disabled {
color: #999;
}
.disabled, a {
text-transform: lowercase;
}
}
}
body#collection article, body#subpage article {
padding-top: 0;
padding-bottom: 0;
.book {
h2 {
font-size: 1.4em;
}
a.hidden.action {
color: #666;
float: right;
font-size: 1em;
margin-left: 1em;
margin-bottom: 1em;
}
}
}
body#post article {
p.badge {
font-size: 0.9em;
}
}
article {
h2.post-title a[rel=nofollow]::after {
content: '\a0 \2934';
}
}
table.downloads {
width: 100%;
td {
text-align: center;
}
img.os {
width: 48px;
vertical-align: middle;
margin-bottom: 6px;
}
}
select.inputform, textarea.inputform {
border: 1px solid #999;
}
input, button, select.inputform, textarea.inputform, a.btn {
padding: 0.5em;
font-family: @serifFont;
font-size: 100%;
.rounded(.25em);
&[type=submit], &.submit, &.cta {
border: 1px solid @primary;
background: @primary;
color: white;
.transition(0.2s);
&:hover {
background-color: lighten(@primary, 3%);
text-decoration: none;
}
&:disabled {
cursor: default;
background-color: desaturate(@primary, 100%) !important;
border-color: desaturate(@primary, 100%) !important;
}
}
&.error[type=text], textarea.error {
-webkit-transition: all 0.30s ease-in-out;
-moz-transition: all 0.30s ease-in-out;
-ms-transition: all 0.30s ease-in-out;
-o-transition: all 0.30s ease-in-out;
outline: none;
}
&.danger {
border: 1px solid @dangerCol;
background: @dangerCol;
color: white;
&:hover {
background-color: lighten(@dangerCol, 3%);
}
}
&.error[type=text]:focus, textarea.error:focus {
box-shadow: 0 0 5px @errUrgentCol;
border: 1px solid @errUrgentCol;
}
}
div.flat-select {
display: inline-block;
position: relative;
select {
border: 0;
background: 0;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0;
}
&.action {
&:hover {
label {
text-decoration: underline;
}
}
label, select {
cursor: pointer;
}
}
}
input {
&.underline{
border: none;
border-bottom: 1px solid #ccc;
padding: 0 .2em .2em;
font-size: 0.9em;
color: #333;
}
&.inline {
padding: 0.2rem 0.2rem;
margin-left: 0;
font-size: 1em;
border: 0 !important;
border-bottom: 1px solid #999 !important;
width: 7em;
.rounded(0);
}
&[type=tel], &[type=text], &[type=email], &[type=password] {
border: 1px solid #999;
}
&.boxy {
border: 1px solid #999 !important;
}
}
#beta, .content-container {
max-width: 50em;
margin: 0 auto 3em;
font-size: 1.2em;
&.tight {
max-width: 30em;
}
&.snug {
max-width: 40em;
}
.app {
+ .app {
margin-top: 1.5em;
}
h2 {
margin-bottom: 0.25em;
}
p {
margin-top: 0.25em;
}
}
h2.intro {
font-weight: normal;
}
p {
line-height: 1.4;
}
li {
margin: 0.3em 0;
}
h2 {
&.light {
font-weight: normal;
}
a {
.transition-duration(0.2s);
-moz-transition-property: color;
-webkit-transition-property: color;
-o-transition-property: color;
transition-property: color;
&:link, &:visited, &:hover {
color: @subheaders;
}
&:hover {
color: lighten(@subheaders, 10%);
text-decoration: none;
}
}
}
}
.content-container {
&#pricing {
button {
cursor: pointer;
color: white;
margin-top: 1em;
margin-bottom: 1em;
padding-left: 1.5em;
padding-right: 1.5em;
border: 0;
background: @primary;
.rounded(.25em);
.transition(0.2s);
&:hover {
background-color: lighten(@primary, 5%);
}
&.unselected {
cursor: pointer;
}
}
h2 span {
font-weight: normal;
}
.half {
margin: 0 0 1em 0;
text-align: center;
}
}
- div.features {
- margin-top: 1.5em;
- text-align: center;
- font-size: 0.86em;
- ul {
- text-align: left;
- max-width: 26em;
- margin-left: auto !important;
- margin-right: auto !important;
- li.soon, span.soon {
- color: lighten(#111, 40%);
- }
- }
- }
div.blurbs {
>h2 {
text-align: center;
color: #333;
font-weight: normal;
}
p.price {
font-size: 1.2em;
margin-bottom: 0;
color: #333;
margin-top: 0.5em;
&+p {
margin-top: 0;
font-size: 0.8em;
}
}
p.text-cta {
font-size: 1em;
}
}
}
footer div.blurbs {
display: flex;
flex-flow: row;
flex-wrap: wrap;
}
div.blurbs {
.half, .third, .fourth {
font-size: 0.86em;
h3 {
font-weight: normal;
}
p, ul {
color: #595959;
}
hr {
margin: 1em 0;
}
}
.half {
padding: 0 1em 0 0;
width: ~"calc(50% - 1em)";
&+.half {
padding: 0 0 0 1em;
}
}
.third {
padding: 0;
width: ~"calc(33% - 1em)";
&+.third {
padding: 0 0 0 1em;
}
}
.fourth {
flex: 1 1 25%;
-webkit-flex: 1 1 25%;
h3 {
margin-bottom: 0.5em;
}
ul {
margin-top: 0.5em;
}
}
}
.contain-me {
text-align: left;
margin: 0 auto 4em;
max-width: 50em;
h2 + p, h2 + p + p, p.describe-me {
margin-left: 1.5em;
margin-right: 1.5em;
color: #333;
}
}
footer.contain-me {
font-size: 1.1em;
}
#official-writing, #wrapper {
h2, h3, h4 {
color: @subheaders;
}
ul {
&.collections {
margin-left: 0;
li {
&.collection {
a.title {
&:link, &:visited {
color: @headerTextColor;
}
}
}
a.create {
color: #444;
}
}
& + p {
margin-top: 2em;
margin-left: 1em;
}
}
}
}
#official-writing, #wrapper {
h2 {
&.major {
color: #222;
}
&.bugfix {
color: #666;
}
+.android-version {
a {
color: #999;
&:hover {
text-decoration: underline;
}
}
}
}
}
li {
line-height: 1.4;
.item-desc, .prog-lang {
font-size: 0.6em;
font-family: 'Open Sans', sans-serif;
font-weight: bold;
margin-left: 0.5em;
margin-right: 0.5em;
text-transform: uppercase;
color: #999;
}
}
.success {
color: darken(@proSelectedCol, 20%);
}
.alert {
padding: 1em;
margin-bottom: 1.25em;
border: 1px solid transparent;
.rounded(.25em);
&.info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
&.success {
color: #3c763d;
background-color: #dff0d8;
border-color: #d6e9c6;
}
p {
margin: 0;
&+p {
margin-top: 0.5em;
}
}
p.dismiss {
font-family: @sansFont;
text-align: right;
font-size: 0.86em;
text-transform: uppercase;
}
}
ul.errors {
padding: 0;
text-indent: 0;
li.urgent {
list-style: none;
font-style: italic;
text-align: center;
color: @errUrgentCol;
a:link, a:visited {
color: purple;
}
}
li.info {
list-style: none;
font-size: 1.1em;
text-align: center;
}
}
body#pad #target a.upgrade-prompt {
padding-left: 1em;
padding-right: 1em;
text-align: center;
font-style: italic;
color: @primary;
}
body#pad-sub #posts, .atoms {
margin-top: 1.5em;
h3 {
margin-bottom: 0.25em;
&+ h4 {
margin-top: 0.25em;
margin-bottom: 0.5em;
&+ p {
margin-top: 0.5em;
}
}
.electron {
font-weight: normal;
margin-left: 0.5em;
}
}
h3, h4 {
a {
.transition-duration(0.2s);
-moz-transition-property: color;
-webkit-transition-property: color;
-o-transition-property: color;
transition-property: color;
}
}
h4 {
font-size: 0.9em;
font-weight: normal;
}
date, .electron {
margin-right: 0.5em;
}
.action {
font-size: 1em;
}
#more-posts p {
text-align: center;
font-size: 1.1em;
}
p {
font-size: 0.86em;
}
.error {
display: inline-block;
font-size: 0.8em;
font-style: italic;
color: @errUrgentCol;
strong {
font-style: normal;
}
}
.error + nav {
display: inline-block;
font-size: 0.8em;
margin-left: 1em;
a + a {
margin-left: 0.75em;
}
}
}
h2 {
a, time {
&+.action {
margin-left: 0.5em;
}
}
}
.action {
font-size: 0.7em;
font-weight: normal;
font-family: @serifFont;
&+ .action {
margin-left: 0.5em;
}
&.new-post {
font-weight: bold;
}
}
article.moved {
p {
font-size: 1.2em;
color: #999;
}
}
span.as {
.opacity(0.2);
font-weight: normal;
}
span.ras {
.opacity(0.6);
font-weight: normal;
}
header {
nav {
.username {
font-size: 2em;
font-weight: normal;
color: #555;
}
&#user-nav {
margin-left: 0;
& > a, .tabs > a {
&.selected {
cursor: default;
font-weight: bold;
&:hover {
text-decoration: none;
}
}
& + a {
margin-left: 2em;
}
}
a {
font-size: 1.2em;
font-family: @sansFont;
span {
font-size: 0.7em;
color: #999;
text-transform: uppercase;
margin-left: 0.5em;
margin-right: 0.5em;
}
&.title {
font-size: 1.6em;
font-family: @serifFont;
font-weight: bold;
}
}
nav > ul > li:first-child {
&> a {
display: inline-block;
}
img {
position: relative;
top: -0.5em;
right: 0.3em;
}
}
ul ul {
font-size: 0.8em;
a {
padding-top: 0.25em;
padding-bottom: 0.25em;
}
}
li {
line-height: 1.5;
}
}
&.tabs {
margin: 0 0 0 1em;
}
&+ nav.tabs {
margin: 0;
}
}
&.singleuser {
margin: 0.5em 0.25em;
nav#user-nav {
nav > ul > li:first-child {
img {
top: -0.75em;
}
}
}
}
.dash-nav {
font-weight: bold;
}
}
li#create-collection {
display: none;
h4 {
margin-top: 0px;
margin-bottom: 0px;
}
input[type=submit] {
margin-left: 0.5em;
}
}
#collection-options {
.option {
textarea {
font-size: 0.86em;
font-family: @monoFont;
}
.section > p.explain {
font-size: 0.8em;
}
}
}
.img-placeholder {
text-align: center;
img {
max-width: 100%;
}
}
dl {
&.admin-dl-horizontal {
dt {
font-weight: bolder;
width: 360px;
}
dd {
line-height: 1.5;
}
}
}
dt {
float: left;
clear: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
form {
dt, dd {
padding: 0.5rem 0;
}
dt {
line-height: 1.8;
}
dd {
font-size: 0.86em;
line-height: 2;
}
&.prominent {
margin: 1em 0;
label {
font-weight: bold;
}
input, select {
width: 100%;
}
select {
font-size: 1em;
padding: 0.5rem;
display: block;
border-radius: 0.25rem;
margin: 0.5rem 0;
}
}
}
div.row {
display: flex;
align-items: center;
> div {
flex: 1;
}
}
+.check, .blip {
+ font-size: 1.125em;
+ color: #71D571;
+}
+
+.ex.failure {
+ font-weight: bold;
+ color: @dangerCol;
+}
+
@media all and (max-width: 450px) {
body#post {
header {
nav {
.xtra-feature {
display: none;
}
}
}
}
}
@media all and (min-width: 1280px) {
body#promo {
div.heading {
margin: 10em 0;
}
}
}
@media all and (min-width: 1600px) {
body#promo {
div.heading {
margin: 14em 0;
}
}
}
@media all and (max-width: 900px) {
.half.big {
padding: 0 !important;
width: 100% !important;
}
.third {
padding: 0 !important;
float: none;
width: 100% !important;
p.introduction {
font-size: 0.86em;
}
}
div.blurbs {
.fourth {
flex: 1 1 15em;
-webkit-flex: 1 1 15em;
}
}
.blurbs .third, .blurbs .half {
p, ul {
text-align: left;
}
}
.half-col, .big {
float: none;
text-align: center;
&+.half-col, &+.big {
margin-top: 4em !important;
margin-left: 0;
}
}
#beta, .content-container {
font-size: 1.15em;
}
}
@media all and (max-width: 600px) {
- div.row {
+ div.row:not(.admin-actions) {
flex-direction: column;
}
.half {
padding: 0 !important;
width: 100% !important;
}
.third {
width: 100% !important;
float: none;
}
body#promo {
div.heading {
margin: 6em 0;
}
h2 {
font-size: 1.6em;
}
.half-col a + a {
margin-left: 1em;
}
.half-col a.channel {
margin-left: auto !important;
margin-right: auto !important;
}
}
ul.add-integrations {
li {
display: list-item;
&+ li {
margin-left: 0;
}
}
}
}
@media all and (max-height: 500px) {
body#promo {
div.heading {
margin: 5em 0;
}
}
}
@media all and (max-height: 400px) {
body#promo {
div.heading {
margin: 0em 0;
}
}
}
/* Smartphones (portrait and landscape) ----------- */
@media only screen and (min-device-width : 320px) and (max-device-width : 480px) {
header {
.opacity(1);
}
}
/* Smartphones (portrait) ----------- */
@media only screen and (max-width : 320px) {
.content-container#pricing {
.half {
float: none;
width: 100%;
}
}
header {
.opacity(1);
}
}
/* iPads (portrait and landscape) ----------- */
@media only screen and (min-device-width : 768px) and (max-device-width : 1024px) {
header {
.opacity(1);
}
}
@media (pointer: coarse) {
body footer nav a:not(.pubd) {
padding: 0.8em 1em;
margin-left: 0;
margin-top: 0;
}
}
@media print {
h1 {
page-break-before: always;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
table, figure {
page-break-inside: avoid;
}
header, footer {
display: none;
}
article#post-body {
margin-top: 2em;
margin-left: 0;
margin-right: 0;
}
hr {
border: 1px solid #ccc;
}
}
.code-block {
padding: 0;
max-width: 100%;
margin: 0;
background: #f8f8f8;
border: 1px solid #ccc;
padding: 0.375em 0.625em;
font-size: 0.86em;
.rounded(.25em);
}
pre.code-block {
overflow-x: auto;
}
diff --git a/routes.go b/routes.go
index 3ab820a..523cc30 100644
--- a/routes.go
+++ b/routes.go
@@ -1,220 +1,222 @@
/*
* Copyright © 2018-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 writefreely
import (
"net/http"
"path/filepath"
"strings"
"github.com/gorilla/mux"
"github.com/writeas/go-webfinger"
"github.com/writeas/web-core/log"
"github.com/writefreely/go-nodeinfo"
)
// InitStaticRoutes adds routes for serving static files.
// TODO: this should just be a func, not method
func (app *App) InitStaticRoutes(r *mux.Router) {
// Handle static files
fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir)))
app.shttp = http.NewServeMux()
app.shttp.Handle("/", fs)
r.PathPrefix("/").Handler(fs)
}
// InitRoutes adds dynamic routes for the given mux.Router.
func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
// Create handler
handler := NewWFHandler(apper)
// Set up routes
hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:]
if apper.App().cfg.App.SingleUser {
hostSubroute = "{domain}"
} else {
if strings.HasPrefix(hostSubroute, "localhost") {
hostSubroute = "localhost"
}
}
if apper.App().cfg.App.SingleUser {
log.Info("Adding %s routes (single user)...", hostSubroute)
} else {
log.Info("Adding %s routes (multi-user)...", hostSubroute)
}
// Primary app routes
write := r.PathPrefix("/").Subrouter()
// Federation endpoint configurations
wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg})
wf.NoTLSHandler = nil
// Federation endpoints
// host-meta
write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader))
// webfinger
write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger)))
// nodeinfo
niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg)
ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db})
write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover)))
write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo)))
// handle mentions
write.HandleFunc("/@/{handle}", handler.Web(handleViewMention, UserLevelReader))
configureSlackOauth(handler, write, apper.App())
configureWriteAsOauth(handler, write, apper.App())
// Set up dyamic page handlers
// Handle auth
auth := write.PathPrefix("/api/auth/").Subrouter()
if apper.App().cfg.App.OpenRegistration {
auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST")
}
auth.HandleFunc("/login", handler.All(login)).Methods("POST")
auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST")
auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE")
// Handle logged in user sections
me := write.PathPrefix("/me").Subrouter()
me.HandleFunc("/", handler.Redirect("/me", UserLevelUser))
me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET")
me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET")
me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET")
me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET")
me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET")
me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
me.HandleFunc("/import", handler.User(viewImport)).Methods("GET")
me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET")
me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET")
me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET")
apiMe := write.PathPrefix("/api/me/").Subrouter()
apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET")
apiMe.HandleFunc("/posts", handler.UserAPI(viewMyPostsAPI)).Methods("GET")
apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET")
apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST")
// Sign up validation
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST")
// Handle collections
write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
apiColls := write.PathPrefix("/api/collections/").Subrouter()
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET")
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE")
apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET")
apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET")
apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST")
apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET")
apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET")
// Handle posts
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
posts := write.PathPrefix("/api/posts/").Subrouter()
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.AllReader(fetchPost)).Methods("GET")
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT")
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE")
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST")
write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
+ write.HandleFunc("/admin/monitor", handler.Admin(handleViewAdminMonitor)).Methods("GET")
+ write.HandleFunc("/admin/settings", handler.Admin(handleViewAdminSettings)).Methods("GET")
write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST")
write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).Methods("POST")
write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET")
write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST")
write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST")
write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET")
// Handle special pages first
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
// TODO: show a reader-specific 404 page if the function is disabled
write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader))
RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter())
draftEditPrefix := ""
if apper.App().cfg.App.SingleUser {
draftEditPrefix = "/d"
write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
} else {
write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
}
// All the existing stuff
write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET")
// Collections
if apper.App().cfg.App.SingleUser {
RouteCollections(handler, write.PathPrefix("/").Subrouter())
} else {
write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader))
write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader))
RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter())
// Posts
}
write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional))
write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional))
return r
}
func RouteCollections(handler *Handler, r *mux.Router) {
r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
r.HandleFunc("/{slug}", handler.CollectionPostOrStatic)
r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET")
}
func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) {
r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm))
r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm))
r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm))
r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm))
}
diff --git a/templates/user/admin.tmpl b/templates/user/admin.tmpl
index 3cb81be..1a0e6b4 100644
--- a/templates/user/admin.tmpl
+++ b/templates/user/admin.tmpl
@@ -1,190 +1,68 @@
{{define "admin"}}
{{template "header" .}}
<style type="text/css">
h2 {font-weight: normal;}
ul.pagenav {list-style: none;}
form {
margin: 0 0 2em;
}
form dt {
line-height: inherit;
}
.ui.divider:not(.vertical):not(.horizontal) {
border-top: 1px solid rgba(34,36,38,.15);
border-bottom: 1px solid rgba(255,255,255,.1);
}
.ui.divider {
margin: 1rem 0;
line-height: 1;
height: 0;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: rgba(0,0,0,.85);
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
font-size: 1rem;
}
.invisible {
display: none;
}
p.docs {
font-size: 0.86em;
}
+.stats {
+ font-size: 1.2em;
+ margin: 1em 0;
+}
+.num {
+ font-weight: bold;
+ font-size: 1.5em;
+}
</style>
<div class="content-container snug">
{{template "admin-header" .}}
{{if .Message}}<p>{{.Message}}</p>{{end}}
- <h2>On this page</h2>
- <ul class="pagenav">
- <li><a href="#config">Configuration</a></li>
- <li><a href="#monitor">Application monitor</a></li>
- </ul>
-
- <h2>Resources</h2>
- <ul class="pagenav">
- <li><a href="https://writefreely.org/docs/{{.OfficialVersion}}/admin">Admin Guide</a></li>
- </ul>
-
- <hr />
-
- <h2><a name="config"></a>App Configuration</h2>
- <p class="docs">Read more in the <a href="https://writefreely.org/docs/{{.OfficialVersion}}/admin/config">configuration docs</a>.</p>
-
- {{if .ConfigMessage}}<p class="success" style="text-align: center">{{.ConfigMessage}}</p>{{end}}
-
- <form action="/admin/update/config" method="post">
- <div class="ui attached table segment">
- <dl class="dl-horizontal admin-dl-horizontal">
- <dt{{if .Config.SingleUser}} class="invisible"{{end}}>Site Name</dt>
- <dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="site_name" id="site_name" class="inline" value="{{.Config.SiteName}}" style="width: 14em;" /></dd>
- <dt{{if .Config.SingleUser}} class="invisible"{{end}}>Site Description</dt>
- <dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="site_desc" id="site_desc" class="inline" value="{{.Config.SiteDesc}}" style="width: 14em;" /></dd>
- <dt>Host</dt>
- <dd>{{.Config.Host}}</dd>
- <dt>User Mode</dt>
- <dd>{{if .Config.SingleUser}}Single user{{else}}Multiple users{{end}}</dd>
- <dt{{if .Config.SingleUser}} class="invisible"{{end}}>Landing Page</dt>
- <dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="landing" id="landing" class="inline" value="{{.Config.Landing}}" style="width: 14em;" /></dd>
- <dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="open_registration">Open Registrations</label></dt>
- <dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="open_registration" id="open_registration" {{if .Config.OpenRegistration}}checked="checked"{{end}} /></dd>
- <dt><label for="min_username_len">Minimum Username Length</label></dt>
- <dd><input type="number" name="min_username_len" id="min_username_len" class="inline" min="1" max="100" value="{{.Config.MinUsernameLen}}" /></dd>
- <dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="max_blogs">Maximum Blogs per User</label></dt>
- <dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="number" name="max_blogs" id="max_blogs" class="inline" min="1" value="{{.Config.MaxBlogs}}" /></dd>
- <dt><label for="federation">Federation</label></dt>
- <dd><input type="checkbox" name="federation" id="federation" {{if .Config.Federation}}checked="checked"{{end}} /></dd>
- <dt><label for="public_stats">Public Stats</label></dt>
- <dd><input type="checkbox" name="public_stats" id="public_stats" {{if .Config.PublicStats}}checked="checked"{{end}} /></dd>
- <dt><label for="private">Private Instance</label></dt>
- <dd><input type="checkbox" name="private" id="private" {{if .Config.Private}}checked="checked"{{end}} /></dd>
- <dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="local_timeline">Local Timeline</label></dt>
- <dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="local_timeline" id="local_timeline" {{if .Config.LocalTimeline}}checked="checked"{{end}} /></dd>
- <dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="user_invites">Allow sending invitations by</label></dt>
- <dd{{if .Config.SingleUser}} class="invisible"{{end}}>
- <select name="user_invites" id="user_invites">
- <option value="none" {{if eq .Config.UserInvites ""}}selected="selected"{{end}}>No one</option>
- <option value="user" {{if eq .Config.UserInvites "user"}}selected="selected"{{end}}>Users</option>
- <option value="admin" {{if eq .Config.UserInvites "admin"}}selected="selected"{{end}}>Admins</option>
- </select>
- </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>
- <input type="submit" value="Save Configuration" />
+ <div class="row stats">
+ <div><span class="num">{{largeNumFmt .UsersCount}}</span> {{pluralize "user" "users" .UsersCount}}</div>
+ <div><span class="num">{{largeNumFmt .CollectionsCount}}</span> {{pluralize "blog" "blogs" .CollectionsCount}}</div>
+ <div><span class="num">{{largeNumFmt .PostsCount}}</span> {{pluralize "post" "posts" .PostsCount}}</div>
</div>
- </form>
-
- <hr />
- <h2><a name="monitor"></a>Application</h2>
-
- <div class="ui attached table segment">
- <dl class="dl-horizontal admin-dl-horizontal">
- <dt>WriteFreely</dt>
- <dd>{{.Version}}</dd>
- <dt>Server Uptime</dt>
- <dd>{{.SysStatus.Uptime}}</dd>
- <dt>Current Goroutines</dt>
- <dd>{{.SysStatus.NumGoroutine}}</dd>
- <div class="ui divider"></div>
- <dt>Current memory usage</dt>
- <dd>{{.SysStatus.MemAllocated}}</dd>
- <dt>Total mem allocated</dt>
- <dd>{{.SysStatus.MemTotal}}</dd>
- <dt>Memory obtained</dt>
- <dd>{{.SysStatus.MemSys}}</dd>
- <dt>Pointer lookup times</dt>
- <dd>{{.SysStatus.Lookups}}</dd>
- <dt>Memory allocate times</dt>
- <dd>{{.SysStatus.MemMallocs}}</dd>
- <dt>Memory free times</dt>
- <dd>{{.SysStatus.MemFrees}}</dd>
- <div class="ui divider"></div>
- <dt>Current heap usage</dt>
- <dd>{{.SysStatus.HeapAlloc}}</dd>
- <dt>Heap memory obtained</dt>
- <dd>{{.SysStatus.HeapSys}}</dd>
- <dt>Heap memory idle</dt>
- <dd>{{.SysStatus.HeapIdle}}</dd>
- <dt>Heap memory in use</dt>
- <dd>{{.SysStatus.HeapInuse}}</dd>
- <dt>Heap memory released</dt>
- <dd>{{.SysStatus.HeapReleased}}</dd>
- <dt>Heap objects</dt>
- <dd>{{.SysStatus.HeapObjects}}</dd>
- <div class="ui divider"></div>
- <dt>Bootstrap stack usage</dt>
- <dd>{{.SysStatus.StackInuse}}</dd>
- <dt>Stack memory obtained</dt>
- <dd>{{.SysStatus.StackSys}}</dd>
- <dt>MSpan structures in use</dt>
- <dd>{{.SysStatus.MSpanInuse}}</dd>
- <dt>MSpan structures obtained</dt>
- <dd>{{.SysStatus.HeapSys}}</dd>
- <dt>MCache structures in use</dt>
- <dd>{{.SysStatus.MCacheInuse}}</dd>
- <dt>MCache structures obtained</dt>
- <dd>{{.SysStatus.MCacheSys}}</dd>
- <dt>Profiling bucket hash table obtained</dt>
- <dd>{{.SysStatus.BuckHashSys}}</dd>
- <dt>GC metadata obtained</dt>
- <dd>{{.SysStatus.GCSys}}</dd>
- <dt>Other system allocation obtained</dt>
- <dd>{{.SysStatus.OtherSys}}</dd>
- <div class="ui divider"></div>
- <dt>Next GC recycle</dt>
- <dd>{{.SysStatus.NextGC}}</dd>
- <dt>Since last GC</dt>
- <dd>{{.SysStatus.LastGC}}</dd>
- <dt>Total GC pause</dt>
- <dd>{{.SysStatus.PauseTotalNs}}</dd>
- <dt>Last GC pause</dt>
- <dd>{{.SysStatus.PauseNs}}</dd>
- <dt>GC times</dt>
- <dd>{{.SysStatus.NumGC}}</dd>
- </dl>
- </div>
</div>
<script>
history.replaceState(null, "", "/admin"+window.location.hash);
</script>
{{template "footer" .}}
{{template "body-end" .}}
{{end}}
diff --git a/templates/user/admin/app-settings.tmpl b/templates/user/admin/app-settings.tmpl
new file mode 100644
index 0000000..3e0fdf7
--- /dev/null
+++ b/templates/user/admin/app-settings.tmpl
@@ -0,0 +1,154 @@
+{{define "app-settings"}}
+{{template "header" .}}
+
+<style type="text/css">
+h2 {font-weight: normal;}
+form {
+ margin: 0 0 2em;
+}
+form dt {
+ line-height: inherit;
+}
+.invisible {
+ display: none;
+}
+p.docs {
+ font-size: 0.86em;
+}
+</style>
+
+<div class="content-container snug">
+ {{template "admin-header" .}}
+
+ {{if .Message}}<p><a name="config"></a>{{.Message}}</p>{{end}}
+
+ {{if .ConfigMessage}}<p class="success" style="text-align: center">{{.ConfigMessage}}</p>{{end}}
+
+ <form action="/admin/update/config" method="post">
+ <div class="features row">
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}>
+ Site Title
+ <p>Your public site name.</p>
+ </div>
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="site_name" id="site_name" class="inline" value="{{.Config.SiteName}}" style="width: 14em;"/></div>
+ </div>
+ <div class="features row">
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}>
+ Site Description
+ <p>Describe your site &mdash; this shows in your site's metadata.</p>
+ </div>
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="site_desc" id="site_desc" class="inline" value="{{.Config.SiteDesc}}" style="width: 14em;"/></div>
+ </div>
+ <div class="features row">
+ <div>
+ Host
+ <p>The address where your site lives.</p>
+ </div>
+ <div>{{.Config.Host}}</div>
+ </div>
+ <div class="features row">
+ <div>
+ Community Mode
+ <p>Whether your site is made for one person or many.</p>
+ </div>
+ <div>{{if .Config.SingleUser}}Single user{{else}}Multiple users{{end}}</div>
+ </div>
+ <div class="features row">
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}>
+ Landing Page
+ <p>The page that logged-out visitors will see first. This should be a path, e.g. <code>/read</code></p>
+ </div>
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="landing" id="landing" class="inline" value="{{.Config.Landing}}" style="width: 14em;"/></div>
+ </div>
+ <div class="features row">
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="open_registration">
+ Open Registrations
+ <p>Whether or not registration is open to anyone who visits the site.</p>
+ </label></div>
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="open_registration" id="open_registration" {{if .Config.OpenRegistration}}checked="checked"{{end}} />
+ </div>
+ </div>
+ <div class="features row">
+ <div><label for="min_username_len">
+ Minimum Username Length
+ <p>The minimum number of characters allowed in a username. (Recommended: 2 or more.)</p>
+ </label></div>
+ <div><input type="number" name="min_username_len" id="min_username_len" class="inline" min="1" max="100" value="{{.Config.MinUsernameLen}}"/></div>
+ </div>
+ <div class="features row">
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="max_blogs">
+ Maximum Blogs per User
+ <p>Keep things simple by setting this to <strong>1</strong>, unlimited by setting to <strong>0</strong>, or pick another amount.</p>
+ </label></div>
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="number" name="max_blogs" id="max_blogs" class="inline" min="0" value="{{.Config.MaxBlogs}}"/></div>
+ </div>
+ <div class="features row">
+ <div><label for="federation">
+ Federation
+ <p>Enable accounts on this site to propagate their posts via the ActivityPub protocol.</p>
+ </label></div>
+ <div><input type="checkbox" name="federation" id="federation" {{if .Config.Federation}}checked="checked"{{end}} /></div>
+ </div>
+ <div class="features row">
+ <div><label for="public_stats">
+ Public Stats
+ <p>Publicly display the number of users and posts on your <strong>About</strong> page.</p>
+ </label></div>
+ <div><input type="checkbox" name="public_stats" id="public_stats" {{if .Config.PublicStats}}checked="checked"{{end}} /></div>
+ </div>
+ <div class="features row">
+ <div><label for="private">
+ Private Instance
+ <p>Make this instance accessible only to those with an account.</p>
+ </label></div>
+ <div><input type="checkbox" name="private" id="private" {{if .Config.Private}}checked="checked"{{end}} /></div>
+ </div>
+ <div class="features row">
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="local_timeline">
+ Reader
+ <p>Show a feed of user posts for anyone who chooses to share there.</p>
+ </label></div>
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="local_timeline" id="local_timeline" {{if .Config.LocalTimeline}}checked="checked"{{end}} /></div>
+ </div>
+ <div class="features row">
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="user_invites">
+ Allow invitations from...
+ <p>Choose who on this instance can invite new people.</p>
+ </label></div>
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}>
+ <select name="user_invites" id="user_invites">
+ <option value="none" {{if eq .Config.UserInvites ""}}selected="selected"{{end}}>No one</option>
+ <option value="admin" {{if eq .Config.UserInvites "admin"}}selected="selected"{{end}}>Only Admins</option>
+ <option value="user" {{if eq .Config.UserInvites "user"}}selected="selected"{{end}}>All Users</option>
+ </select>
+ </div>
+ </div>
+ <div class="features row">
+ <div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="default_visibility">
+ Default blog visibility
+ <p>The default setting for new accounts and blogs.</p>
+ </label></div>
+ <div{{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>
+ </div>
+ </div>
+ <div class="features row">
+ <input type="submit" value="Save Settings" />
+ </div>
+ </form>
+
+ <p class="docs">Still have questions? Read more details in the <a href="https://writefreely.org/docs/{{.OfficialVersion}}/admin/config">configuration docs</a>.</p>
+</div>
+
+<script>
+history.replaceState(null, "", "/admin/settings"+window.location.hash);
+</script>
+
+{{template "footer" .}}
+
+{{template "body-end" .}}
+{{end}}
diff --git a/templates/user/admin/app-updates.tmpl b/templates/user/admin/app-updates.tmpl
index 540fb7c..62fd83d 100644
--- a/templates/user/admin/app-updates.tmpl
+++ b/templates/user/admin/app-updates.tmpl
@@ -1,23 +1,48 @@
{{define "app-updates"}}
{{template "header" .}}
<style type="text/css">
+ p.intro {
+ text-align: left;
+ }
+ p.disabled {
+ font-style: italic;
+ color: #999;
+ }
</style>
<div class="content-container snug">
{{template "admin-header" .}}
- {{if not .UpdateAvailable}}
- <p class="alert info">WriteFreely is up to date.</p>
- {{else}}
- <p class="alert info">WriteFreely {{.LatestVersion}} is available.</p>
- <section class="changelog">
- For details on features, bug fixes or notes on upgrading, <a href="{{.LatestReleaseURL}}">read the release notes</a>.
- </section>
- {{end}}
- <p>Last checked at: {{.LastChecked}}. <a href="/admin/updates?check=now">Check now</a>.</p>
+{{ if .UpdateChecks }}
+ {{if .CheckFailed}}
+ <p class="intro"><span class="ex failure">&times;</span> Automated update check failed.</p>
+ <p>Installed version: <strong>{{.Version}}</strong> (<a href="{{.CurReleaseNotesURL}}" target="changelog-wf">release notes</a>).</p>
+ <p>Learn about latest releases on the <a href="https://blog.writefreely.org/tag:release" target="changelog-wf">WriteFreely blog</a> or <a href="https://discuss.write.as/c/writefreely/updates" target="forum-wf">forum</a>.</p>
+ {{else if not .UpdateAvailable}}
+ <p class="intro"><span class="check">&check;</span> WriteFreely is <strong>up to date</strong>.</p>
+ <p>Installed version: <strong>{{.Version}}</strong> (<a href="{{.LatestReleaseNotesURL}}" target="changelog-wf">release notes</a>).</p>
+ {{else}}
+ <p class="intro">A new version of WriteFreely is available! <a href="{{.LatestReleaseURL}}" target="download-wf" style="font-weight: bold;">Get {{.LatestVersion}}</a></p>
+ <p class="changelog">
+ <a href="{{.LatestReleaseNotesURL}}" target="changelog-wf">Read the release notes</a> for details on features, bug fixes, and notes on upgrading from your current version, <strong>{{.Version}}</strong>.
+ </p>
+ {{end}}
+ <p style="font-size: 0.86em;"><em>Last checked</em>: <time class="dt-published" datetime="{{.LastChecked8601}}">{{.LastChecked}}</time>. <a href="/admin/updates?check=now">Check now</a>.</p>
+
+ <script>
+ // Code modified from /js/localdate.js
+ var displayEl = document.querySelector("time");
+ var d = new Date(displayEl.getAttribute("datetime"));
+ displayEl.textContent = d.toLocaleDateString(navigator.language || "en-US", { dateStyle: 'long', timeStyle: 'short' });
+ </script>
+{{ else }}
+ <p class="intro disabled">Automated update checks are disabled.</p>
+ <p>Installed version: <strong>{{.Version}}</strong> (<a href="{{.CurReleaseNotesURL}}" target="changelog-wf">release notes</a>).</p>
+ <p>Learn about latest releases on the <a href="https://blog.writefreely.org/tag:release" target="changelog-wf">WriteFreely blog</a> or <a href="https://discuss.write.as/c/writefreely/updates" target="forum-wf">forum</a>.</p>
+{{ end }}
{{template "footer" .}}
{{template "body-end" .}}
{{end}}
diff --git a/templates/user/admin/monitor.tmpl b/templates/user/admin/monitor.tmpl
new file mode 100644
index 0000000..e803dd3
--- /dev/null
+++ b/templates/user/admin/monitor.tmpl
@@ -0,0 +1,105 @@
+{{define "monitor"}}
+{{template "header" .}}
+
+<style type="text/css">
+h2 {font-weight: normal;}
+.ui.divider:not(.vertical):not(.horizontal) {
+ border-top: 1px solid rgba(34,36,38,.15);
+ border-bottom: 1px solid rgba(255,255,255,.1);
+}
+.ui.divider {
+ margin: 1rem 0;
+ line-height: 1;
+ height: 0;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ color: rgba(0,0,0,.85);
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ font-size: 1rem;
+}
+</style>
+
+<div class="content-container snug">
+ {{template "admin-header" .}}
+
+ {{if .Message}}<p>{{.Message}}</p>{{end}}
+
+ <h2><a name="monitor"></a>Application Monitor</h2>
+
+ <div class="ui attached table segment">
+ <dl class="dl-horizontal admin-dl-horizontal">
+ <dt>WriteFreely</dt>
+ <dd>{{.Version}}</dd>
+ <dt>Server Uptime</dt>
+ <dd>{{.SysStatus.Uptime}}</dd>
+ <dt>Current Goroutines</dt>
+ <dd>{{.SysStatus.NumGoroutine}}</dd>
+ <div class="ui divider"></div>
+ <dt>Current memory usage</dt>
+ <dd>{{.SysStatus.MemAllocated}}</dd>
+ <dt>Total mem allocated</dt>
+ <dd>{{.SysStatus.MemTotal}}</dd>
+ <dt>Memory obtained</dt>
+ <dd>{{.SysStatus.MemSys}}</dd>
+ <dt>Pointer lookup times</dt>
+ <dd>{{.SysStatus.Lookups}}</dd>
+ <dt>Memory allocate times</dt>
+ <dd>{{.SysStatus.MemMallocs}}</dd>
+ <dt>Memory free times</dt>
+ <dd>{{.SysStatus.MemFrees}}</dd>
+ <div class="ui divider"></div>
+ <dt>Current heap usage</dt>
+ <dd>{{.SysStatus.HeapAlloc}}</dd>
+ <dt>Heap memory obtained</dt>
+ <dd>{{.SysStatus.HeapSys}}</dd>
+ <dt>Heap memory idle</dt>
+ <dd>{{.SysStatus.HeapIdle}}</dd>
+ <dt>Heap memory in use</dt>
+ <dd>{{.SysStatus.HeapInuse}}</dd>
+ <dt>Heap memory released</dt>
+ <dd>{{.SysStatus.HeapReleased}}</dd>
+ <dt>Heap objects</dt>
+ <dd>{{.SysStatus.HeapObjects}}</dd>
+ <div class="ui divider"></div>
+ <dt>Bootstrap stack usage</dt>
+ <dd>{{.SysStatus.StackInuse}}</dd>
+ <dt>Stack memory obtained</dt>
+ <dd>{{.SysStatus.StackSys}}</dd>
+ <dt>MSpan structures in use</dt>
+ <dd>{{.SysStatus.MSpanInuse}}</dd>
+ <dt>MSpan structures obtained</dt>
+ <dd>{{.SysStatus.HeapSys}}</dd>
+ <dt>MCache structures in use</dt>
+ <dd>{{.SysStatus.MCacheInuse}}</dd>
+ <dt>MCache structures obtained</dt>
+ <dd>{{.SysStatus.MCacheSys}}</dd>
+ <dt>Profiling bucket hash table obtained</dt>
+ <dd>{{.SysStatus.BuckHashSys}}</dd>
+ <dt>GC metadata obtained</dt>
+ <dd>{{.SysStatus.GCSys}}</dd>
+ <dt>Other system allocation obtained</dt>
+ <dd>{{.SysStatus.OtherSys}}</dd>
+ <div class="ui divider"></div>
+ <dt>Next GC recycle</dt>
+ <dd>{{.SysStatus.NextGC}}</dd>
+ <dt>Since last GC</dt>
+ <dd>{{.SysStatus.LastGC}}</dd>
+ <dt>Total GC pause</dt>
+ <dd>{{.SysStatus.PauseTotalNs}}</dd>
+ <dt>Last GC pause</dt>
+ <dd>{{.SysStatus.PauseNs}}</dd>
+ <dt>GC times</dt>
+ <dd>{{.SysStatus.NumGC}}</dd>
+ </dl>
+ </div>
+</div>
+
+{{template "footer" .}}
+
+{{template "body-end" .}}
+{{end}}
diff --git a/templates/user/admin/users.tmpl b/templates/user/admin/users.tmpl
index fb69d3a..714fa24 100644
--- a/templates/user/admin/users.tmpl
+++ b/templates/user/admin/users.tmpl
@@ -1,33 +1,36 @@
{{define "users"}}
{{template "header" .}}
<div class="snug content-container">
{{template "admin-header" .}}
- <h2 id="posts-header" style="display: flex; justify-content: space-between;">Users <span style="font-style: italic; font-size: 0.75em;">{{.TotalUsers}} total</strong></h2>
+ <div class="row admin-actions" style="justify-content: space-between;">
+ <span style="font-style: italic; font-size: 1.2em">{{.TotalUsers}} {{pluralize "user" "users" .TotalUsers}}</span>
+ <a class="btn cta" href="/me/invites">+ Invite people</a>
+ </div>
<table class="classy export" style="width:100%">
<tr>
<th>User</th>
<th>Joined</th>
<th>Type</th>
<th>Status</th>
</tr>
{{range .Users}}
<tr>
<td><a href="/admin/user/{{.Username}}">{{.Username}}</a></td>
<td>{{.CreatedFriendly}}</td>
<td style="text-align:center">{{if .IsAdmin}}Admin{{else}}User{{end}}</td>
<td style="text-align:center">{{if .IsSilenced}}Silenced{{else}}Active{{end}}</td>
</tr>
{{end}}
</table>
<nav class="pager">
{{range $n := .TotalPages}}<a href="/admin/users{{if ne $n 1}}?p={{$n}}{{end}}" {{if eq $.CurPage $n}}class="selected"{{end}}>{{$n}}</a>{{end}}
</nav>
</div>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/admin/view-user.tmpl b/templates/user/admin/view-user.tmpl
index 8e5d87b..95bc416 100644
--- a/templates/user/admin/view-user.tmpl
+++ b/templates/user/admin/view-user.tmpl
@@ -1,160 +1,159 @@
{{define "view-user"}}
{{template "header" .}}
<style>
table.classy th {
text-align: left;
}
h3 {
font-weight: normal;
}
td.active-silence {
display: flex;
align-items: center;
}
td.active-silence > input[type="submit"] {
margin-left: auto;
margin-right: 5%;
}
@media only screen and (max-width: 500px) {
td.active-silence {
flex-wrap: wrap;
}
td.active-silence > input[type="submit"] {
margin: auto;
}
}
input.copy-text {
text-align: center;
font-size: 1.2em;
color: #555;
width: 100%;
box-sizing: border-box;
}
</style>
<div class="snug content-container">
{{template "admin-header" .}}
<h2 id="posts-header">{{.User.Username}}</h2>
{{if .NewPassword}}<div class="alert success">
<p>This user's password has been reset to:</p>
<p><input type="text" class="copy-text" value="{{.NewPassword}}" onfocus="if (this.select) this.select(); else this.setSelectionRange(0, this.value.length);" readonly /></p>
<p>They can use this new password to log in to their account. <strong>This will only be shown once</strong>, so be sure to copy it and send it to them now.</p>
{{if .ClearEmail}}<p>Their email address is: <a href="mailto:{{.ClearEmail}}">{{.ClearEmail}}</a></p>{{end}}
</div>
{{end}}
<table class="classy export">
<tr>
<th>No.</th>
<td>{{.User.ID}}</td>
</tr>
<tr>
<th>Type</th>
<td>{{if .User.IsAdmin}}Admin{{else}}User{{end}}</td>
</tr>
<tr>
<th>Username</th>
<td>{{.User.Username}}</td>
</tr>
<tr>
<th>Joined</th>
<td>{{.User.CreatedFriendly}}</td>
</tr>
<tr>
<th>Total Posts</th>
<td>{{.TotalPosts}}</td>
</tr>
<tr>
<th>Last Post</th>
<td>{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}</td>
</tr>
<tr>
<form action="/admin/user/{{.User.Username}}/status" method="POST" {{if not .User.IsSilenced}}onsubmit="return confirmSilence()"{{end}}>
- <a id="status"/>
- <th>Status</th>
+ <th><a id="status"></a>Status</th>
<td class="active-silence">
{{if .User.IsSilenced}}
<p>Silenced</p>
<input type="submit" value="Unsilence"/>
{{else}}
<p>Active</p>
<input class="danger" type="submit" value="Silence" {{if .User.IsAdmin}}disabled{{end}}/>
{{end}}
</td>
</form>
</tr>
<tr>
<th>Password</th>
<td>
{{if ne .Username .User.Username}}
<form id="reset-form" action="/admin/user/{{.User.Username}}/passphrase" method="post" autocomplete="false">
<input type="hidden" name="user" value="{{.User.ID}}"/>
<button type="submit">Reset</button>
</form>
{{else}}
<a href="/me/settings" title="Go to reset password page">Change your password</a>
{{end}}
</td>
</tr>
</table>
<h2>Blogs</h2>
{{range .Colls}}
<h3><a href="/{{.Alias}}/">{{.Title}}</a></h3>
<table class="classy export">
<tr>
<th>Alias</th>
<td>{{.Alias}}</td>
</tr>
<tr>
<th>Title</th>
<td>{{.Title}}</td>
</tr>
<tr>
<th>Description</th>
<td>{{.Description}}</td>
</tr>
<tr>
<th>Visibility</th>
<td>{{.FriendlyVisibility}}</td>
</tr>
<tr>
<th>Views</th>
<td>{{.Views}}</td>
</tr>
<tr>
<th>Posts</th>
<td>{{.TotalPosts}}</td>
</tr>
<tr>
<th>Last Post</th>
<td>{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}</td>
</tr>
{{if $.Config.Federation}}
<tr>
<th>Fediverse Followers</th>
<td>{{.Followers}}</td>
</tr>
{{end}}
</table>
{{end}}
</div>
<script type="text/javascript">
function confirmSilence() {
return confirm("Silence this user? They'll still be able to log in and access their posts, but no one else will be able to see them anymore. You can reverse this decision at any time.");
}
form = document.getElementById("reset-form");
form.addEventListener('submit', function(e) {
e.preventDefault();
agreed = confirm("Reset this user's password? This will generate a new temporary password that you'll need to share with them, and invalidate their old one.");
if (agreed === true) {
form.submit();
}
});
</script>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/include/header.tmpl b/templates/user/include/header.tmpl
index 8b39480..16978ee 100644
--- a/templates/user/include/header.tmpl
+++ b/templates/user/include/header.tmpl
@@ -1,109 +1,113 @@
{{define "user-navigation"}}
<header class="{{if .SingleUser}}singleuser{{else}}multiuser{{end}}">
{{if .SingleUser}}
<nav id="user-nav">
<nav class="dropdown-nav">
<ul><li><a href="/" title="View blog" class="title">{{.SiteName}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" />
<ul>
<li><a href="/me/c/{{.Username}}">Customize</a></li>
<li><a href="/me/c/{{.Username}}/stats">Stats</a></li>
<li class="separator"><hr /></li>
{{if .IsAdmin}}<li><a href="/admin">Admin</a></li>{{end}}
<li><a href="/me/settings">Settings</a></li>
<li><a href="/me/import">Import posts</a></li>
<li><a href="/me/export">Export</a></li>
<li class="separator"><hr /></li>
<li><a href="/me/logout">Log out</a></li>
</ul></li>
</ul>
</nav>
<nav class="tabs">
<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>
<a href="/me/new">New Post</a>
</nav>
</nav>
{{else}}
<nav id="full-nav">
<div class="left-side">
<h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1>
</div>
<nav id="user-nav">
{{if .Username}}
<nav class="dropdown-nav">
<ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
<li><a href="/me/settings">Account settings</a></li>
<li><a href="/me/import">Import posts</a></li>
<li><a href="/me/export">Export</a></li>
{{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
<li class="separator"><hr /></li>
<li><a href="/me/logout">Log out</a></li>
</ul></li>
</ul>
</nav>
{{end}}
<nav class="tabs">
{{if .SimpleNav}}
{{ if not .SingleUser }}
{{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>Home</a>{{end}}
{{ end }}
<a href="/about">About</a>
{{ if not .SingleUser }}
{{ if .Username }}
{{if gt .MaxBlogs 1}}<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>{{end}}
{{if and .Chorus (eq .MaxBlogs 1)}}<a href="/{{.Username}}/"{{if eq .Path (printf "/%s/" .Username)}} class="selected"{{end}}>My Posts</a>{{end}}
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
{{ end }}
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
{{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
{{if .Username}}<a href="/me/logout">Log out</a>{{else}}<a href="/login">Log in</a>{{end}}
{{ end }}
{{else}}
<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
{{end}}
</nav>
</nav>
{{if .Chorus}}{{if .Username}}<div class="right-side">
<a class="simple-btn" href="/new">New Post</a>
</div>{{end}}
</nav>
{{end}}
{{end}}
</header>
{{end}}
{{define "header"}}<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>{{.PageTitle}} {{if .Separator}}{{.Separator}}{{else}}&mdash;{{end}} {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#888888" />
<meta name="apple-mobile-web-app-title" content="{{.SiteName}}">
<link rel="apple-touch-icon" sizes="152x152" href="/img/touch-icon-152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/img/touch-icon-167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/img/touch-icon-180.png">
</head>
<body id="me">
{{template "user-navigation" .}}
<div id="official-writing">
{{end}}
{{define "admin-header"}}
<header class="admin">
<h1>Admin</h1>
- <nav id="admin">
+ <nav id="admin" class="pager">
<a href="/admin" {{if eq .Path "/admin"}}class="selected"{{end}}>Dashboard</a>
+ <a href="/admin/settings" {{if eq .Path "/admin/settings"}}class="selected"{{end}}>Settings</a>
{{if not .SingleUser}}
<a href="/admin/users" {{if eq .Path "/admin/users"}}class="selected"{{end}}>Users</a>
<a href="/admin/pages" {{if eq .Path "/admin/pages"}}class="selected"{{end}}>Pages</a>
- {{if .UpdateChecks}}<a href="/admin/updates" {{if eq .Path "/admin/updates"}}class="selected"{{end}}>Updates</a>{{end}}
+ {{if .UpdateChecks}}<a href="/admin/updates" {{if eq .Path "/admin/updates"}}class="selected"{{end}}>Updates{{if .UpdateAvailable}}<span class="blip">!</span>{{end}}</a>{{end}}
+ {{end}}
+ {{if not .Forest}}
+ <a href="/admin/monitor" {{if eq .Path "/admin/monitor"}}class="selected"{{end}}>Monitor</a>
{{end}}
</nav>
</header>
{{end}}
diff --git a/updates.go b/updates.go
index c33247b..8ecccf6 100644
--- a/updates.go
+++ b/updates.go
@@ -1,106 +1,131 @@
/*
- * Copyright © 2018-2019 A Bunch Tell LLC.
+ * Copyright © 2019-2020 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 writefreely
import (
+ "github.com/writeas/web-core/log"
"io/ioutil"
"net/http"
"strings"
"sync"
"time"
)
// updatesCacheTime is the default interval between cache updates for new
// software versions
const defaultUpdatesCacheTime = 12 * time.Hour
// updatesCache holds data about current and new releases of the writefreely
// software
type updatesCache struct {
mu sync.Mutex
frequency time.Duration
lastCheck time.Time
latestVersion string
currentVersion string
+ checkError error
}
// CheckNow asks for the latest released version of writefreely and updates
// the cache last checked time. If the version postdates the current 'latest'
// the version value is replaced.
func (uc *updatesCache) CheckNow() error {
+ if debugging {
+ log.Info("[update check] Checking for update now.")
+ }
uc.mu.Lock()
defer uc.mu.Unlock()
+ uc.lastCheck = time.Now()
latestRemote, err := newVersionCheck()
if err != nil {
+ log.Error("[update check] Failed: %v", err)
+ uc.checkError = err
return err
}
- uc.lastCheck = time.Now()
if CompareSemver(latestRemote, uc.latestVersion) == 1 {
uc.latestVersion = latestRemote
}
return nil
}
// AreAvailable updates the cache if the frequency duration has passed
// then returns if the latest release is newer than the current running version.
func (uc updatesCache) AreAvailable() bool {
if time.Since(uc.lastCheck) > uc.frequency {
uc.CheckNow()
}
return CompareSemver(uc.latestVersion, uc.currentVersion) == 1
}
+// AreAvailableNoCheck returns if the latest release is newer than the current
+// running version.
+func (uc updatesCache) AreAvailableNoCheck() bool {
+ return CompareSemver(uc.latestVersion, uc.currentVersion) == 1
+}
+
// LatestVersion returns the latest stored version available.
func (uc updatesCache) LatestVersion() string {
return uc.latestVersion
}
-// ReleaseURL returns the full URL to the blog.writefreely.org release notes
-// for the latest version as stored in the cache.
func (uc updatesCache) ReleaseURL() string {
- ver := strings.TrimPrefix(uc.latestVersion, "v")
+ return "https://writefreely.org/releases/" + uc.latestVersion
+}
+
+// ReleaseNotesURL returns the full URL to the blog.writefreely.org release notes
+// for the latest version as stored in the cache.
+func (uc updatesCache) ReleaseNotesURL() string {
+ return wfReleaseNotesURL(uc.latestVersion)
+}
+
+func wfReleaseNotesURL(v string) string {
+ ver := strings.TrimPrefix(v, "v")
ver = strings.TrimSuffix(ver, ".0")
// hack until go 1.12 in build/travis
seg := strings.Split(ver, ".")
return "https://blog.writefreely.org/version-" + strings.Join(seg, "-")
}
// newUpdatesCache returns an initialized updates cache
func newUpdatesCache(expiry time.Duration) *updatesCache {
cache := updatesCache{
frequency: expiry,
currentVersion: "v" + softwareVer,
}
- cache.CheckNow()
+ go cache.CheckNow()
return &cache
}
// InitUpdates initializes the updates cache, if the config value is set
// It uses the defaultUpdatesCacheTime for the cache expiry
func (app *App) InitUpdates() {
if app.cfg.App.UpdateChecks {
app.updates = newUpdatesCache(defaultUpdatesCacheTime)
}
}
func newVersionCheck() (string, error) {
res, err := http.Get("https://version.writefreely.org")
+ if debugging {
+ log.Info("[update check] GET https://version.writefreely.org")
+ }
+ // TODO: return error if statusCode != OK
if err == nil && res.StatusCode == http.StatusOK {
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}
return string(body), nil
}
return "", err
}
diff --git a/updates_test.go b/updates_test.go
index 2cb9f92..1c63f30 100644
--- a/updates_test.go
+++ b/updates_test.go
@@ -1,82 +1,82 @@
package writefreely
import (
"regexp"
"testing"
"time"
)
func TestUpdatesRoundTrip(t *testing.T) {
cache := newUpdatesCache(defaultUpdatesCacheTime)
t.Run("New Updates Cache", func(t *testing.T) {
if cache == nil {
t.Fatal("Returned nil cache")
}
if cache.frequency != defaultUpdatesCacheTime {
t.Fatalf("Got cache expiry frequency: %s but expected: %s", cache.frequency, defaultUpdatesCacheTime)
}
if cache.currentVersion != "v"+softwareVer {
t.Fatalf("Got current version: %s but expected: %s", cache.currentVersion, "v"+softwareVer)
}
})
t.Run("Release URL", func(t *testing.T) {
- url := cache.ReleaseURL()
+ url := cache.ReleaseNotesURL()
reg, err := regexp.Compile(`^https:\/\/blog.writefreely.org\/version(-\d+){1,}$`)
if err != nil {
t.Fatalf("Test Case Error: Failed to compile regex: %v", err)
}
match := reg.MatchString(url)
if !match {
t.Fatalf("Malformed Release URL: %s", url)
}
})
t.Run("Check Now", func(t *testing.T) {
// ensure time between init and next check
time.Sleep(1 * time.Second)
prevLastCheck := cache.lastCheck
// force to known older version for latest and current
prevLatestVer := "v0.8.1"
cache.latestVersion = prevLatestVer
cache.currentVersion = "v0.8.0"
err := cache.CheckNow()
if err != nil {
t.Fatalf("Error should be nil, got: %v", err)
}
if prevLastCheck == cache.lastCheck {
t.Fatal("Expected lastCheck to update")
}
if cache.lastCheck.Before(prevLastCheck) {
t.Fatal("Last check should be newer than previous")
}
if prevLatestVer == cache.latestVersion {
t.Fatal("expected latestVersion to update")
}
})
t.Run("Are Available", func(t *testing.T) {
if !cache.AreAvailable() {
t.Fatalf("Cache reports not updates but Current is %s and Latest is %s", cache.currentVersion, cache.latestVersion)
}
})
t.Run("Latest Version", func(t *testing.T) {
gotLatest := cache.LatestVersion()
if gotLatest != cache.latestVersion {
t.Fatalf("Malformed latest version. Expected: %s but got: %s", cache.latestVersion, gotLatest)
}
})
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Nov 23, 3:55 PM (1 d, 4 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3104642

Event Timeline