Page MenuHomeMusing Studio

No OneTemporary

diff --git a/app.go b/app.go
index 002bd6e..8b630df 100644
--- a/app.go
+++ b/app.go
@@ -1,811 +1,817 @@
/*
* 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 (
"crypto/tls"
"database/sql"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
"github.com/gorilla/sessions"
"github.com/manifoldco/promptui"
"github.com/writeas/go-strip-markdown"
"github.com/writeas/impart"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/converter"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/author"
"github.com/writeas/writefreely/config"
"github.com/writeas/writefreely/key"
"github.com/writeas/writefreely/migrations"
"github.com/writeas/writefreely/page"
"golang.org/x/crypto/acme/autocert"
)
const (
staticDir = "static"
assumedTitleLen = 80
postsPerPage = 10
serverSoftware = "WriteFreely"
softwareURL = "https://writefreely.org"
)
var (
debugging bool
// Software version can be set from git env using -ldflags
softwareVer = "0.10.0"
// DEPRECATED VARS
isSingleUser bool
)
// App holds data and configuration for an individual WriteFreely instance.
type App struct {
router *mux.Router
shttp *http.ServeMux
db *datastore
cfg *config.Config
cfgFile string
keys *key.Keychain
sessionStore *sessions.CookieStore
formDecoder *schema.Decoder
timeline *localTimeline
}
// DB returns the App's datastore
func (app *App) DB() *datastore {
return app.db
}
// Router returns the App's router
func (app *App) Router() *mux.Router {
return app.router
}
// Config returns the App's current configuration.
func (app *App) Config() *config.Config {
return app.cfg
}
// SetConfig updates the App's Config to the given value.
func (app *App) SetConfig(cfg *config.Config) {
app.cfg = cfg
}
// SetKeys updates the App's Keychain to the given value.
func (app *App) SetKeys(k *key.Keychain) {
app.keys = k
}
// Apper is the interface for getting data into and out of a WriteFreely
// instance (or "App").
//
// App returns the App for the current instance.
//
// LoadConfig reads an app configuration into the App, returning any error
// encountered.
//
// SaveConfig persists the current App configuration.
//
// LoadKeys reads the App's encryption keys and loads them into its
// key.Keychain.
type Apper interface {
App() *App
LoadConfig() error
SaveConfig(*config.Config) error
LoadKeys() error
ReqLog(r *http.Request, status int, timeSince time.Duration) string
}
// App returns the App
func (app *App) App() *App {
return app
}
// LoadConfig loads and parses a config file.
func (app *App) LoadConfig() error {
log.Info("Loading %s configuration...", app.cfgFile)
cfg, err := config.Load(app.cfgFile)
if err != nil {
log.Error("Unable to load configuration: %v", err)
os.Exit(1)
return err
}
app.cfg = cfg
return nil
}
// SaveConfig saves the given Config to disk -- namely, to the App's cfgFile.
func (app *App) SaveConfig(c *config.Config) error {
return config.Save(c, app.cfgFile)
}
// LoadKeys reads all needed keys from disk into the App. In order to use the
// configured `Server.KeysParentDir`, you must call initKeyPaths(App) before
// this.
func (app *App) LoadKeys() error {
var err error
app.keys = &key.Keychain{}
if debugging {
log.Info(" %s", emailKeyPath)
}
app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
if err != nil {
return err
}
if debugging {
log.Info(" %s", cookieAuthKeyPath)
}
app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
if err != nil {
return err
}
if debugging {
log.Info(" %s", cookieKeyPath)
}
app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath)
if err != nil {
return err
}
return nil
}
func (app *App) ReqLog(r *http.Request, status int, timeSince time.Duration) string {
return fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, timeSince, r.UserAgent())
}
// handleViewHome shows page at root path. It checks the configuration and
// authentication state to show the correct page.
func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
if app.cfg.App.SingleUser {
// Render blog index
return handleViewCollection(app, w, r)
}
if app.cfg.App.Chorus {
// This instance is focused on reading, so show Reader on home route
return viewLocalTimeline(app, w, r)
}
// Multi-user instance
forceLanding := r.FormValue("landing") == "1"
if !forceLanding {
// Show correct page based on user auth status and configured landing path
u := getUserSession(app, r)
if u != nil {
// User is logged in, so show the Pad
return handleViewPad(app, w, r)
}
if land := app.cfg.App.LandingPath(); land != "/" {
return impart.HTTPError{http.StatusFound, land}
}
}
+ return handleViewLanding(app, w, r)
+}
+
+func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
+ forceLanding := r.FormValue("landing") == "1"
+
p := struct {
page.StaticPage
Flashes []template.HTML
Banner template.HTML
Content template.HTML
ForcedLanding bool
}{
StaticPage: pageForReq(app, r),
ForcedLanding: forceLanding,
}
banner, err := getLandingBanner(app)
if err != nil {
log.Error("unable to get landing banner: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
}
p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), "", app.cfg))
content, err := getLandingBody(app)
if err != nil {
log.Error("unable to get landing content: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)}
}
p.Content = template.HTML(applyMarkdown([]byte(content.Content), "", app.cfg))
// Get error messages
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// Ignore this
log.Error("Unable to get session in handleViewHome; ignoring: %v", err)
}
flashes, _ := getSessionFlashes(app, w, r, session)
for _, flash := range flashes {
p.Flashes = append(p.Flashes, template.HTML(flash))
}
// Show landing page
return renderPage(w, "landing.tmpl", p)
}
func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *template.Template) error {
p := struct {
page.StaticPage
ContentTitle string
Content template.HTML
PlainContent string
Updated string
AboutStats *InstanceStats
}{
StaticPage: pageForReq(app, r),
}
if r.URL.Path == "/about" || r.URL.Path == "/privacy" {
var c *instanceContent
var err error
if r.URL.Path == "/about" {
c, err = getAboutPage(app)
// Fetch stats
p.AboutStats = &InstanceStats{}
p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections()
} else {
c, err = getPrivacyPage(app)
}
if err != nil {
return err
}
p.ContentTitle = c.Title.String
p.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg))
p.PlainContent = shortPostDescription(stripmd.Strip(c.Content))
if !c.Updated.IsZero() {
p.Updated = c.Updated.Format("January 2, 2006")
}
}
// Serve templated page
err := t.ExecuteTemplate(w, "base", p)
if err != nil {
log.Error("Unable to render page: %v", err)
}
return nil
}
func pageForReq(app *App, r *http.Request) page.StaticPage {
p := page.StaticPage{
AppCfg: app.cfg.App,
Path: r.URL.Path,
Version: "v" + softwareVer,
}
// Add user information, if given
var u *User
accessToken := r.FormValue("t")
if accessToken != "" {
userID := app.db.GetUserID(accessToken)
if userID != -1 {
var err error
u, err = app.db.GetUserByID(userID)
if err == nil {
p.Username = u.Username
}
}
} else {
u = getUserSession(app, r)
if u != nil {
p.Username = u.Username
p.IsAdmin = u != nil && u.IsAdmin()
p.CanInvite = canUserInvite(app.cfg, p.IsAdmin)
}
}
p.CanViewReader = !app.cfg.App.Private || u != nil
return p
}
var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$")
// Initialize loads the app configuration and initializes templates, keys,
// session, route handlers, and the database connection.
func Initialize(apper Apper, debug bool) (*App, error) {
debugging = debug
apper.LoadConfig()
// Load templates
err := InitTemplates(apper.App().Config())
if err != nil {
return nil, fmt.Errorf("load templates: %s", err)
}
// Load keys and set up session
initKeyPaths(apper.App()) // TODO: find a better way to do this, since it's unneeded in all Apper implementations
err = InitKeys(apper)
if err != nil {
return nil, fmt.Errorf("init keys: %s", err)
}
apper.App().InitSession()
apper.App().InitDecoder()
err = ConnectToDatabase(apper.App())
if err != nil {
return nil, fmt.Errorf("connect to DB: %s", err)
}
// Handle local timeline, if enabled
if apper.App().cfg.App.LocalTimeline {
log.Info("Initializing local timeline...")
initLocalTimeline(apper.App())
}
return apper.App(), nil
}
func Serve(app *App, r *mux.Router) {
log.Info("Going to serve...")
isSingleUser = app.cfg.App.SingleUser
app.cfg.Server.Dev = debugging
// Handle shutdown
c := make(chan os.Signal, 2)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
log.Info("Shutting down...")
shutdown(app)
log.Info("Done.")
os.Exit(0)
}()
// Start web application server
var bindAddress = app.cfg.Server.Bind
if bindAddress == "" {
bindAddress = "localhost"
}
var err error
if app.cfg.IsSecureStandalone() {
if app.cfg.Server.Autocert {
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(app.cfg.Server.TLSCertPath),
}
host, err := url.Parse(app.cfg.App.Host)
if err != nil {
log.Error("[WARNING] Unable to parse configured host! %s", err)
log.Error(`[WARNING] ALL hosts are allowed, which can open you to an attack where
clients connect to a server by IP address and pretend to be asking for an
incorrect host name, and cause you to reach the CA's rate limit for certificate
requests. We recommend supplying a valid host name.`)
log.Info("Using autocert on ANY host")
} else {
log.Info("Using autocert on host %s", host.Host)
m.HostPolicy = autocert.HostWhitelist(host.Host)
}
s := &http.Server{
Addr: ":https",
Handler: r,
TLSConfig: &tls.Config{
GetCertificate: m.GetCertificate,
},
}
s.SetKeepAlivesEnabled(false)
go func() {
log.Info("Serving redirects on http://%s:80", bindAddress)
err = http.ListenAndServe(":80", m.HTTPHandler(nil))
log.Error("Unable to start redirect server: %v", err)
}()
log.Info("Serving on https://%s:443", bindAddress)
log.Info("---")
err = s.ListenAndServeTLS("", "")
} else {
go func() {
log.Info("Serving redirects on http://%s:80", bindAddress)
err = http.ListenAndServe(fmt.Sprintf("%s:80", bindAddress), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, app.cfg.App.Host, http.StatusMovedPermanently)
}))
log.Error("Unable to start redirect server: %v", err)
}()
log.Info("Serving on https://%s:443", bindAddress)
log.Info("Using manual certificates")
log.Info("---")
err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
}
} else {
log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port)
log.Info("---")
err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), r)
}
if err != nil {
log.Error("Unable to start: %v", err)
os.Exit(1)
}
}
func (app *App) InitDecoder() {
// TODO: do this at the package level, instead of the App level
// Initialize modules
app.formDecoder = schema.NewDecoder()
app.formDecoder.RegisterConverter(converter.NullJSONString{}, converter.ConvertJSONNullString)
app.formDecoder.RegisterConverter(converter.NullJSONBool{}, converter.ConvertJSONNullBool)
app.formDecoder.RegisterConverter(sql.NullString{}, converter.ConvertSQLNullString)
app.formDecoder.RegisterConverter(sql.NullBool{}, converter.ConvertSQLNullBool)
app.formDecoder.RegisterConverter(sql.NullInt64{}, converter.ConvertSQLNullInt64)
app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64)
}
// ConnectToDatabase validates and connects to the configured database, then
// tests the connection.
func ConnectToDatabase(app *App) error {
// Check database configuration
if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
return fmt.Errorf("Database user or password not set.")
}
if app.cfg.Database.Host == "" {
app.cfg.Database.Host = "localhost"
}
if app.cfg.Database.Database == "" {
app.cfg.Database.Database = "writefreely"
}
// TODO: check err
connectToDatabase(app)
// Test database connection
err := app.db.Ping()
if err != nil {
return fmt.Errorf("Database ping failed: %s", err)
}
return nil
}
// OutputVersion prints out the version of the application.
func OutputVersion() {
fmt.Println(serverSoftware + " " + softwareVer)
}
// NewApp creates a new app instance.
func NewApp(cfgFile string) *App {
return &App{
cfgFile: cfgFile,
}
}
// CreateConfig creates a default configuration and saves it to the app's cfgFile.
func CreateConfig(app *App) error {
log.Info("Creating configuration...")
c := config.New()
log.Info("Saving configuration %s...", app.cfgFile)
err := config.Save(c, app.cfgFile)
if err != nil {
return fmt.Errorf("Unable to save configuration: %v", err)
}
return nil
}
// DoConfig runs the interactive configuration process.
func DoConfig(app *App, configSections string) {
if configSections == "" {
configSections = "server db app"
}
// let's check there aren't any garbage in the list
configSectionsArray := strings.Split(configSections, " ")
for _, element := range configSectionsArray {
if element != "server" && element != "db" && element != "app" {
log.Error("Invalid argument to --sections. Valid arguments are only \"server\", \"db\" and \"app\"")
os.Exit(1)
}
}
d, err := config.Configure(app.cfgFile, configSections)
if err != nil {
log.Error("Unable to configure: %v", err)
os.Exit(1)
}
app.cfg = d.Config
connectToDatabase(app)
defer shutdown(app)
if !app.db.DatabaseInitialized() {
err = adminInitDatabase(app)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
} else {
log.Info("Database already initialized.")
}
if d.User != nil {
u := &User{
Username: d.User.Username,
HashedPass: d.User.HashedPass,
Created: time.Now().Truncate(time.Second).UTC(),
}
// Create blog
log.Info("Creating user %s...\n", u.Username)
err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName)
if err != nil {
log.Error("Unable to create user: %s", err)
os.Exit(1)
}
log.Info("Done!")
}
os.Exit(0)
}
// GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir.
func GenerateKeyFiles(app *App) error {
// Read keys path from config
app.LoadConfig()
// Create keys dir if it doesn't exist yet
fullKeysDir := filepath.Join(app.cfg.Server.KeysParentDir, keysDir)
if _, err := os.Stat(fullKeysDir); os.IsNotExist(err) {
err = os.Mkdir(fullKeysDir, 0700)
if err != nil {
return err
}
}
// Generate keys
initKeyPaths(app)
// TODO: use something like https://github.com/hashicorp/go-multierror to return errors
var keyErrs error
err := generateKey(emailKeyPath)
if err != nil {
keyErrs = err
}
err = generateKey(cookieAuthKeyPath)
if err != nil {
keyErrs = err
}
err = generateKey(cookieKeyPath)
if err != nil {
keyErrs = err
}
return keyErrs
}
// CreateSchema creates all database tables needed for the application.
func CreateSchema(apper Apper) error {
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
err := adminInitDatabase(apper.App())
if err != nil {
return err
}
return nil
}
// Migrate runs all necessary database migrations.
func Migrate(apper Apper) error {
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
err := migrations.Migrate(migrations.NewDatastore(apper.App().db.DB, apper.App().db.driverName))
if err != nil {
return fmt.Errorf("migrate: %s", err)
}
return nil
}
// ResetPassword runs the interactive password reset process.
func ResetPassword(apper Apper, username string) error {
// Connect to the database
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
// Fetch user
u, err := apper.App().db.GetUserForAuth(username)
if err != nil {
log.Error("Get user: %s", err)
os.Exit(1)
}
// Prompt for new password
prompt := promptui.Prompt{
Templates: &promptui.PromptTemplates{
Success: "{{ . | bold | faint }}: ",
},
Label: "New password",
Mask: '*',
}
newPass, err := prompt.Run()
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
// Do the update
log.Info("Updating...")
err = adminResetPassword(apper.App(), u, newPass)
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
log.Info("Success.")
return nil
}
func connectToDatabase(app *App) {
log.Info("Connecting to %s database...", app.cfg.Database.Type)
var db *sql.DB
var err error
if app.cfg.Database.Type == driverMySQL {
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String())))
db.SetMaxOpenConns(50)
} else if app.cfg.Database.Type == driverSQLite {
if !SQLiteEnabled {
log.Error("Invalid database type '%s'. Binary wasn't compiled with SQLite3 support.", app.cfg.Database.Type)
os.Exit(1)
}
if app.cfg.Database.FileName == "" {
log.Error("SQLite database filename value in config.ini is empty.")
os.Exit(1)
}
db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared")
db.SetMaxOpenConns(1)
} else {
log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type)
os.Exit(1)
}
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
app.db = &datastore{db, app.cfg.Database.Type}
}
func shutdown(app *App) {
log.Info("Closing database connection...")
app.db.Close()
}
// CreateUser creates a new admin or normal user from the given credentials.
func CreateUser(apper Apper, username, password string, isAdmin bool) error {
// Create an admin user with --create-admin
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
// Ensure an admin / first user doesn't already exist
firstUser, _ := apper.App().db.GetUserByID(1)
if isAdmin {
// Abort if trying to create admin user, but one already exists
if firstUser != nil {
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username)
}
} else {
// Abort if trying to create regular user, but no admin exists yet
if firstUser == nil {
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin")
}
}
// Create the user
// Normalize and validate username
desiredUsername := username
username = getSlug(username, "")
usernameDesc := username
if username != desiredUsername {
usernameDesc += " (originally: " + desiredUsername + ")"
}
if !author.IsValidUsername(apper.App().cfg, username) {
return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, apper.App().cfg.App.MinUsernameLen)
}
// Hash the password
hashedPass, err := auth.HashPass([]byte(password))
if err != nil {
return fmt.Errorf("Unable to hash password: %v", err)
}
u := &User{
Username: username,
HashedPass: hashedPass,
Created: time.Now().Truncate(time.Second).UTC(),
}
userType := "user"
if isAdmin {
userType = "admin"
}
log.Info("Creating %s %s...", userType, usernameDesc)
err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername)
if err != nil {
return fmt.Errorf("Unable to create user: %s", err)
}
log.Info("Done!")
return nil
}
func adminInitDatabase(app *App) error {
schemaFileName := "schema.sql"
if app.cfg.Database.Type == driverSQLite {
schemaFileName = "sqlite.sql"
}
schema, err := Asset(schemaFileName)
if err != nil {
return fmt.Errorf("Unable to load schema file: %v", err)
}
tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`")
queries := strings.Split(string(schema), ";\n")
for _, q := range queries {
if strings.TrimSpace(q) == "" {
continue
}
parts := tblReg.FindStringSubmatch(q)
if len(parts) >= 3 {
log.Info("Creating table %s...", parts[2])
} else {
log.Info("Creating table ??? (Weird query) No match in: %v", parts)
}
_, err = app.db.Exec(q)
if err != nil {
log.Error("%s", err)
} else {
log.Info("Created.")
}
}
// Set up migrations table
log.Info("Initializing appmigrations table...")
err = migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
if err != nil {
return fmt.Errorf("Unable to set initial migrations: %v", err)
}
log.Info("Running migrations...")
err = migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName))
if err != nil {
return fmt.Errorf("migrate: %s", err)
}
log.Info("Done.")
return nil
}
diff --git a/routes.go b/routes.go
index 724c532..777492b 100644
--- a/routes.go
+++ b/routes.go
@@ -1,204 +1,205 @@
/*
* 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 (
"github.com/gorilla/mux"
"github.com/writeas/go-webfinger"
"github.com/writeas/web-core/log"
"github.com/writefreely/go-nodeinfo"
"net/http"
"path/filepath"
"strings"
)
// 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)))
// 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("/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")
// Sign up validation
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).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/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
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")
// Handle special pages first
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
+ write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
write.HandleFunc("/invite/{code}", handler.Web(handleViewInvite, UserLevelNoneRequired)).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, UserLevelOptional)).Methods("GET")
} else {
write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
}
// All the existing stuff
write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).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/base.tmpl b/templates/base.tmpl
index 41b0f2a..34fcf22 100644
--- a/templates/base.tmpl
+++ b/templates/base.tmpl
@@ -1,81 +1,82 @@
{{define "base"}}<!DOCTYPE HTML>
<html>
<head>
{{ template "head" . }}
<link rel="stylesheet" type="text/css" href="{{.Host}}/css/{{.Theme}}.css" />
<link rel="shortcut icon" href="{{.Host}}/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="application-name" content="{{.SiteName}}">
<meta name="application-url" content="{{.Host}}">
<meta property="og:site_name" content="{{.SiteName}}" />
</head>
<body {{template "body-attrs" .}}>
<div id="overlay"></div>
<header>
{{ if .SimpleNav }}<nav id="full-nav">
<div class="left-side">
<h2><a href="/">{{.SiteName}}</a></h2>
</div>
{{ else }}
<h2><a href="/">{{.SiteName}}</a></h2>
{{ end }}
{{if not .SingleUser}}
<nav id="user-nav">
{{if and .Chorus .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/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">
<a href="/about"{{if eq .Path "/about"}} class="selected"{{end}}>About</a>
{{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>Reader</a>{{end}}
+ {{if and (and (and (not .SingleUser) .Chorus) .OpenRegistration) (not .Username)}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
{{if and (not .SingleUser) (not .Username)}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>Log in</a>{{else if .SimpleNav}}<a href="/me/logout">Log out</a>{{end}}
</nav>
{{if .SimpleNav}}{{if .Username}}<div class="right-side" style="font-size: 0.86em;">
<a class="simple-btn" href="/new">New Post</a>
</div>{{end}}
</nav>
{{end}}
</nav>
{{end}}
</header>
<div id="official-writing">
{{ template "content" . }}
</div>
{{ template "footer" . }}
{{if not .JSDisabled}}
<script type="text/javascript">
{{if .WebFonts}}
try { // Google Fonts
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
{{end}}
</script>
{{else}}
{{if .WebFonts}}<link href="{{.Host}}/css/fonts.css" rel="stylesheet" type="text/css" />{{end}}
{{end}}
</body>
</html>{{end}}
{{define "body-attrs"}}{{end}}
diff --git a/templates/user/include/header.tmpl b/templates/user/include/header.tmpl
index 1ebc6ac..2b522a8 100644
--- a/templates/user/include/header.tmpl
+++ b/templates/user/include/header.tmpl
@@ -1,97 +1,98 @@
{{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/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}}
{{ if .SimpleNav }}<nav id="full-nav">
<div class="left-side">
<h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1>
</div>
{{ else }}
<h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1>
{{ end }}
<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/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}}
<a href="/about">About</a>
{{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}<a href="/read">Reader</a>{{end}}
+ {{if and (and (and (not .SingleUser) .Chorus) .OpenRegistration) (not .Username)}}<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}}
{{else}}
<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>
<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>
{{end}}
</nav>
</nav>
{{if .SimpleNav}}{{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">
<a href="/admin" {{if eq .Path "/admin"}}class="selected"{{end}}>Dashboard</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>
{{end}}
</nav>
</header>
{{end}}

File Metadata

Mime Type
text/x-diff
Expires
Sun, Apr 27, 2:29 AM (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3205851

Event Timeline