Page MenuHomeMusing Studio

No OneTemporary

diff --git a/app.go b/app.go
index abc34a3..e970a77 100644
--- a/app.go
+++ b/app.go
@@ -1,630 +1,726 @@
/*
* 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 (
"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"
)
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.9.0"
// DEPRECATED VARS
// TODO: pass app.cfg into GetCollection* calls so we can get these values
// from Collection methods and we no longer need these.
hostName string
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
+}
+
+// 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
+}
+
// handleViewHome shows page at root path. Will be the Pad if logged in and the
// catch-all landing page otherwise.
func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
if app.cfg.App.SingleUser {
// Render blog index
return handleViewCollection(app, w, r)
}
// Multi-user instance
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}
}
p := struct {
page.StaticPage
Flashes []template.HTML
}{
StaticPage: pageForReq(app, r),
}
// 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), ""))
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
}
}
return p
}
var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$")
-func Serve(app *App, debug bool) {
+// 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
- log.Info("Initializing...")
-
- loadConfig(app)
-
- hostName = app.cfg.App.Host
- isSingleUser = app.cfg.App.SingleUser
- app.cfg.Server.Dev = debugging
+ apper.LoadConfig()
- err := initTemplates(app.cfg)
+ // Load templates
+ err := InitTemplates(apper.App().Config())
if err != nil {
- log.Error("load templates: %s", err)
- os.Exit(1)
+ return nil, fmt.Errorf("load templates: %s", err)
}
- // Load keys
- log.Info("Loading encryption keys...")
- initKeyPaths(app)
- err = initKeys(app)
+ // 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 {
- log.Error("\n%s\n", err)
- }
-
- // Initialize modules
- app.sessionStore = initSession(app)
- 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)
-
- // Check database configuration
- if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
- log.Error("Database user or password not set.")
- os.Exit(1)
- }
- if app.cfg.Database.Host == "" {
- app.cfg.Database.Host = "localhost"
- }
- if app.cfg.Database.Database == "" {
- app.cfg.Database.Database = "writefreely"
+ return nil, fmt.Errorf("init keys: %s", err)
}
+ apper.App().InitSession()
- connectToDatabase(app)
- defer shutdown(app)
+ apper.App().InitDecoder()
- // Test database connection
- err = app.db.Ping()
+ err = ConnectToDatabase(apper.App())
if err != nil {
- log.Error("Database ping failed: %s", err)
+ return nil, fmt.Errorf("connect to DB: %s", err)
}
- r := mux.NewRouter()
- handler := NewHandler(app)
- handler.SetErrorPages(&ErrorPages{
- NotFound: pages["404-general.tmpl"],
- Gone: pages["410.tmpl"],
- InternalServerError: pages["500.tmpl"],
- Blank: pages["blank.tmpl"],
- })
-
- // Handle app routes
- initRoutes(handler, r, app.cfg, app.db)
-
// Handle local timeline, if enabled
- if app.cfg.App.LocalTimeline {
+ if apper.App().cfg.App.LocalTimeline {
log.Info("Initializing local timeline...")
- initLocalTimeline(app)
+ initLocalTimeline(apper.App())
}
+ return apper.App(), nil
+}
+
+func Serve(app *App, r *mux.Router) {
+ log.Info("Going to serve...")
+
+ hostName = app.cfg.App.Host
+ 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)
}()
- http.Handle("/", r)
-
// Start web application server
var bindAddress = app.cfg.Server.Bind
if bindAddress == "" {
bindAddress = "localhost"
}
+ var err error
if app.cfg.IsSecureStandalone() {
log.Info("Serving redirects on http://%s:80", bindAddress)
go func() {
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("---")
err = http.ListenAndServeTLS(
- fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, nil)
+ 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), nil)
+ 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) {
d, err := config.Configure(app.cfgFile)
if err != nil {
log.Error("Unable to configure: %v", err)
os.Exit(1)
}
if d.User != nil {
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)
}
}
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(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)
}
-// GenerateKeys creates app encryption keys and saves them into the configured KeysParentDir.
-func GenerateKeys(app *App) error {
+// GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir.
+func GenerateKeyFiles(app *App) error {
// Read keys path from config
- loadConfig(app)
+ 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)
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(app *App) error {
- loadConfig(app)
- connectToDatabase(app)
- defer shutdown(app)
- err := adminInitDatabase(app)
+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(app *App) error {
- loadConfig(app)
+ app.LoadConfig()
connectToDatabase(app)
defer shutdown(app)
err := migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName))
if err != nil {
return fmt.Errorf("migrate: %s", err)
}
return nil
}
// ResetPassword runs the interactive password reset process.
func ResetPassword(app *App, username string) error {
// Connect to the database
- loadConfig(app)
+ app.LoadConfig()
connectToDatabase(app)
defer shutdown(app)
// Fetch user
u, err := 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(app, u, newPass)
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
log.Info("Success.")
return nil
}
-func loadConfig(app *App) {
- 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)
- }
- app.cfg = cfg
-}
-
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(app *App, username, password string, isAdmin bool) error {
+func CreateUser(apper Apper, username, password string, isAdmin bool) error {
// Create an admin user with --create-admin
- loadConfig(app)
- connectToDatabase(app)
- defer shutdown(app)
+ apper.LoadConfig()
+ connectToDatabase(apper.App())
+ defer shutdown(apper.App())
// Ensure an admin / first user doesn't already exist
- firstUser, _ := app.db.GetUserByID(1)
+ 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(app.cfg, username) {
- return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, app.cfg.App.MinUsernameLen)
+ 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 = app.db.CreateUser(u, desiredUsername)
+ err = apper.App().db.CreateUser(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/cmd/writefreely/main.go b/cmd/writefreely/main.go
index 6a32ad9..1ddb3da 100644
--- a/cmd/writefreely/main.go
+++ b/cmd/writefreely/main.go
@@ -1,127 +1,142 @@
/*
* 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 main
import (
"flag"
"fmt"
+ "github.com/gorilla/mux"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely"
"os"
"strings"
)
func main() {
// General options usable with other commands
debugPtr := flag.Bool("debug", false, "Enables debug logging.")
configFile := flag.String("c", "config.ini", "The configuration file to use")
// Setup actions
createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits")
doConfig := flag.Bool("config", false, "Run the configuration process")
genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys")
createSchema := flag.Bool("init-db", false, "Initialize app database")
migrate := flag.Bool("migrate", false, "Migrate the database")
// Admin actions
createAdmin := flag.String("create-admin", "", "Create an admin with the given username:password")
createUser := flag.String("create-user", "", "Create a regular user with the given username:password")
resetPassUser := flag.String("reset-pass", "", "Reset the given user's password")
outputVersion := flag.Bool("v", false, "Output the current version")
flag.Parse()
app := writefreely.NewApp(*configFile)
if *outputVersion {
writefreely.OutputVersion()
os.Exit(0)
} else if *createConfig {
err := writefreely.CreateConfig(app)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
os.Exit(0)
} else if *doConfig {
writefreely.DoConfig(app)
os.Exit(0)
} else if *genKeys {
- err := writefreely.GenerateKeys(app)
+ err := writefreely.GenerateKeyFiles(app)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
os.Exit(0)
} else if *createSchema {
err := writefreely.CreateSchema(app)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
os.Exit(0)
} else if *createAdmin != "" {
username, password, err := userPass(*createAdmin, true)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
err = writefreely.CreateUser(app, username, password, true)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
os.Exit(0)
} else if *createUser != "" {
username, password, err := userPass(*createUser, false)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
err = writefreely.CreateUser(app, username, password, false)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
os.Exit(0)
} else if *resetPassUser != "" {
err := writefreely.ResetPassword(app, *resetPassUser)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
os.Exit(0)
} else if *migrate {
err := writefreely.Migrate(app)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
os.Exit(0)
}
- writefreely.Serve(app, *debugPtr)
+ // Initialize the application
+ var err error
+ app, err = writefreely.Initialize(app, *debugPtr)
+ if err != nil {
+ log.Error("%s", err)
+ os.Exit(1)
+ }
+
+ // Set app routes
+ r := mux.NewRouter()
+ app.InitRoutes(r)
+ app.InitStaticRoutes(r)
+
+ // Serve the application
+ writefreely.Serve(app, r)
}
func userPass(credStr string, isAdmin bool) (user string, pass string, err error) {
creds := strings.Split(credStr, ":")
if len(creds) != 2 {
c := "user"
if isAdmin {
c = "admin"
}
err = fmt.Errorf("usage: writefreely --create-%s username:password", c)
return
}
user = creds[0]
pass = creds[1]
return
}
diff --git a/handle.go b/handle.go
index 946487f..acde1a1 100644
--- a/handle.go
+++ b/handle.go
@@ -1,635 +1,648 @@
/*
* Copyright © 2018 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 (
"fmt"
"html/template"
"net/http"
"net/url"
"runtime/debug"
"strconv"
"strings"
"time"
"github.com/gorilla/sessions"
"github.com/writeas/impart"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/page"
)
type UserLevel int
const (
UserLevelNone UserLevel = iota // user or not -- ignored
UserLevelOptional // user or not -- object fetched if user
UserLevelNoneRequired // non-user (required)
UserLevelUser // user (required)
)
type (
handlerFunc func(app *App, w http.ResponseWriter, r *http.Request) error
userHandlerFunc func(app *App, u *User, w http.ResponseWriter, r *http.Request) error
dataHandlerFunc func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
authFunc func(app *App, r *http.Request) (*User, error)
)
type Handler struct {
errors *ErrorPages
sessionStore *sessions.CookieStore
app *App
}
// ErrorPages hold template HTML error pages for displaying errors to the user.
// In each, there should be a defined template named "base".
type ErrorPages struct {
NotFound *template.Template
Gone *template.Template
InternalServerError *template.Template
Blank *template.Template
}
// NewHandler returns a new Handler instance, using the given StaticPage data,
// and saving alias to the application's CookieStore.
func NewHandler(app *App) *Handler {
h := &Handler{
errors: &ErrorPages{
NotFound: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>404</title></head><body><p>Not found.</p></body></html>{{end}}")),
Gone: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>410</title></head><body><p>Gone.</p></body></html>{{end}}")),
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")),
Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")),
},
sessionStore: app.sessionStore,
app: app,
}
return h
}
+// NewWFHandler returns a new Handler instance, using WriteFreely template files.
+// You MUST call writefreely.InitTemplates() before this.
+func NewWFHandler(app *App) *Handler {
+ h := NewHandler(app)
+ h.SetErrorPages(&ErrorPages{
+ NotFound: pages["404-general.tmpl"],
+ Gone: pages["410.tmpl"],
+ InternalServerError: pages["500.tmpl"],
+ Blank: pages["blank.tmpl"],
+ })
+ return h
+}
+
// SetErrorPages sets the given set of ErrorPages as templates for any errors
// that come up.
func (h *Handler) SetErrorPages(e *ErrorPages) {
h.errors = e
}
// User handles requests made in the web application by the authenticated user.
// This provides user-friendly HTML pages and actions that work in the browser.
func (h *Handler) User(f userHandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = http.StatusInternalServerError
}
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
u := getUserSession(h.app, r)
if u == nil {
err := ErrNotLoggedIn
status = err.Status
return err
}
err := f(h.app, u, w, r)
if err == nil {
status = http.StatusOK
} else if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = http.StatusInternalServerError
}
return err
}())
}
}
// Admin handles requests on /admin routes
func (h *Handler) Admin(f userHandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = http.StatusInternalServerError
}
log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent()))
}()
u := getUserSession(h.app, r)
if u == nil || !u.IsAdmin() {
err := impart.HTTPError{http.StatusNotFound, ""}
status = err.Status
return err
}
err := f(h.app, u, w, r)
if err == nil {
status = http.StatusOK
} else if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = http.StatusInternalServerError
}
return err
}())
}
}
// UserAPI handles requests made in the API by the authenticated user.
// This provides user-friendly HTML pages and actions that work in the browser.
func (h *Handler) UserAPI(f userHandlerFunc) http.HandlerFunc {
return h.UserAll(false, f, func(app *App, r *http.Request) (*User, error) {
// Authorize user from Authorization header
t := r.Header.Get("Authorization")
if t == "" {
return nil, ErrNoAccessToken
}
u := &User{ID: app.db.GetUserID(t)}
if u.ID == -1 {
return nil, ErrBadAccessToken
}
return u, nil
})
}
func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
handleFunc := func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
status = 500
}
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
u, err := a(h.app, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
return err
}
err = f(h.app, u, w, r)
if err == nil {
status = 200
} else if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
return err
}
if web {
h.handleHTTPError(w, r, handleFunc())
} else {
h.handleError(w, r, handleFunc())
}
}
}
func (h *Handler) RedirectOnErr(f handlerFunc, loc string) handlerFunc {
return func(app *App, w http.ResponseWriter, r *http.Request) error {
err := f(app, w, r)
if err != nil {
if ie, ok := err.(impart.HTTPError); ok {
// Override default redirect with returned error's, if it's a
// redirect error.
if ie.Status == http.StatusFound {
return ie
}
}
return impart.HTTPError{http.StatusFound, loc}
}
return nil
}
}
func (h *Handler) Page(n string) http.HandlerFunc {
return h.Web(func(app *App, w http.ResponseWriter, r *http.Request) error {
t, ok := pages[n]
if !ok {
return impart.HTTPError{http.StatusNotFound, "Page not found."}
}
sp := pageForReq(app, r)
err := t.ExecuteTemplate(w, "base", sp)
if err != nil {
log.Error("Unable to render page: %v", err)
}
return err
}, UserLevelOptional)
}
func (h *Handler) WebErrors(f handlerFunc, ul UserLevel) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: factor out this logic shared with Web()
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
u := getUserSession(h.app, r)
username := "None"
if u != nil {
username = u.Username
}
log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
var session *sessions.Session
var err error
if ul != UserLevelNone {
session, err = h.sessionStore.Get(r, cookieName)
if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
// Cookie is required, but we can ignore this error
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
}
_, gotUser := session.Values[cookieUserVal].(*User)
if ul == UserLevelNoneRequired && gotUser {
to := correctPageFromLoginAttempt(r)
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
err := impart.HTTPError{http.StatusFound, to}
status = err.Status
return err
} else if ul == UserLevelUser && !gotUser {
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
err := ErrNotLoggedIn
status = err.Status
return err
}
}
// TODO: pass User object to function
err = f(h.app, w, r)
if err == nil {
status = 200
} else if httpErr, ok := err.(impart.HTTPError); ok {
status = httpErr.Status
if status < 300 || status > 399 {
addSessionFlash(h.app, w, r, httpErr.Message, session)
return impart.HTTPError{http.StatusFound, r.Referer()}
}
} else {
e := fmt.Sprintf("[Web handler] 500: %v", err)
if !strings.HasSuffix(e, "write: broken pipe") {
log.Error(e)
} else {
log.Error(e)
}
log.Info("Web handler internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
return err
}())
}
}
// Web handles requests made in the web application. This provides user-
// friendly HTML pages and actions that work in the browser.
func (h *Handler) Web(f handlerFunc, ul UserLevel) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
u := getUserSession(h.app, r)
username := "None"
if u != nil {
username = u.Username
}
log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
log.Info("Web deferred internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
if ul != UserLevelNone {
session, err := h.sessionStore.Get(r, cookieName)
if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
// Cookie is required, but we can ignore this error
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
}
_, gotUser := session.Values[cookieUserVal].(*User)
if ul == UserLevelNoneRequired && gotUser {
to := correctPageFromLoginAttempt(r)
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
err := impart.HTTPError{http.StatusFound, to}
status = err.Status
return err
} else if ul == UserLevelUser && !gotUser {
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
err := ErrNotLoggedIn
status = err.Status
return err
}
}
// TODO: pass User object to function
err := f(h.app, w, r)
if err == nil {
status = 200
} else if httpErr, ok := err.(impart.HTTPError); ok {
status = httpErr.Status
} else {
e := fmt.Sprintf("[Web handler] 500: %v", err)
log.Error(e)
log.Info("Web internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
return err
}())
}
}
func (h *Handler) All(f handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleError(w, r, func() error {
// TODO: return correct "success" status
status := 200
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s:\n%s", e, debug.Stack())
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
status = 500
}
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
// TODO: do any needed authentication
err := f(h.app, w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
}
return err
}())
}
}
func (h *Handler) Download(f dataHandlerFunc, ul UserLevel) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
data, filename, err := f(h.app, w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
return err
}
ext := ".json"
ct := "application/json"
if strings.HasSuffix(r.URL.Path, ".csv") {
ext = ".csv"
ct = "text/csv"
} else if strings.HasSuffix(r.URL.Path, ".zip") {
ext = ".zip"
ct = "application/zip"
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s%s", filename, ext))
w.Header().Set("Content-Type", ct)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
fmt.Fprint(w, string(data))
status = 200
return nil
}())
}
}
func (h *Handler) Redirect(url string, ul UserLevel) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
start := time.Now()
var status int
if ul != UserLevelNone {
session, err := h.sessionStore.Get(r, cookieName)
if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
// Cookie is required, but we can ignore this error
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
}
_, gotUser := session.Values[cookieUserVal].(*User)
if ul == UserLevelNoneRequired && gotUser {
to := correctPageFromLoginAttempt(r)
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
err := impart.HTTPError{http.StatusFound, to}
status = err.Status
return err
} else if ul == UserLevelUser && !gotUser {
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
err := ErrNotLoggedIn
status = err.Status
return err
}
}
status = sendRedirect(w, http.StatusFound, url)
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
return nil
}())
}
}
func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err error) {
if err == nil {
return
}
if err, ok := err.(impart.HTTPError); ok {
if err.Status >= 300 && err.Status < 400 {
sendRedirect(w, err.Status, err.Message)
return
} else if err.Status == http.StatusUnauthorized {
q := ""
if r.URL.RawQuery != "" {
q = url.QueryEscape("?" + r.URL.RawQuery)
}
sendRedirect(w, http.StatusFound, "/login?to="+r.URL.Path+q)
return
} else if err.Status == http.StatusGone {
w.WriteHeader(err.Status)
p := &struct {
page.StaticPage
Content *template.HTML
}{
StaticPage: pageForReq(h.app, r),
}
if err.Message != "" {
co := template.HTML(err.Message)
p.Content = &co
}
h.errors.Gone.ExecuteTemplate(w, "base", p)
return
} else if err.Status == http.StatusNotFound {
w.WriteHeader(err.Status)
h.errors.NotFound.ExecuteTemplate(w, "base", pageForReq(h.app, r))
return
} else if err.Status == http.StatusInternalServerError {
w.WriteHeader(err.Status)
log.Info("handleHTTPErorr internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
return
} else if err.Status == http.StatusAccepted {
impart.WriteSuccess(w, "", err.Status)
return
} else {
p := &struct {
page.StaticPage
Title string
Content template.HTML
}{
pageForReq(h.app, r),
fmt.Sprintf("Uh oh (%d)", err.Status),
template.HTML(fmt.Sprintf("<p style=\"text-align: center\" class=\"introduction\">%s</p>", err.Message)),
}
h.errors.Blank.ExecuteTemplate(w, "base", p)
return
}
impart.WriteError(w, err)
return
}
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
}
func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error) {
if err == nil {
return
}
if err, ok := err.(impart.HTTPError); ok {
if err.Status >= 300 && err.Status < 400 {
sendRedirect(w, err.Status, err.Message)
return
}
// if strings.Contains(r.Header.Get("Accept"), "text/html") {
impart.WriteError(w, err)
// }
return
}
if IsJSON(r.Header.Get("Content-Type")) {
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
return
}
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
}
func correctPageFromLoginAttempt(r *http.Request) string {
to := r.FormValue("to")
if to == "" {
to = "/"
} else if !strings.HasPrefix(to, "/") {
to = "/" + to
}
return to
}
func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
status := 200
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("Handler.LogHandlerFunc\n\n%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
// TODO: log actual status code returned
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
f(w, r)
return nil
}())
}
}
func sendRedirect(w http.ResponseWriter, code int, location string) int {
w.Header().Set("Location", location)
w.WriteHeader(code)
return code
}
diff --git a/keys.go b/keys.go
index 067908e..5cc63a3 100644
--- a/keys.go
+++ b/keys.go
@@ -1,95 +1,73 @@
/*
* 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/writeas/web-core/log"
"github.com/writeas/writefreely/key"
"io/ioutil"
"os"
"path/filepath"
)
const (
keysDir = "keys"
)
var (
emailKeyPath = filepath.Join(keysDir, "email.aes256")
cookieAuthKeyPath = filepath.Join(keysDir, "cookies_auth.aes256")
cookieKeyPath = filepath.Join(keysDir, "cookies_enc.aes256")
)
+// InitKeys loads encryption keys into memory via the given Apper interface
+func InitKeys(apper Apper) error {
+ log.Info("Loading encryption keys...")
+ err := apper.LoadKeys()
+ if err != nil {
+ return err
+ }
+ return nil
+}
func initKeyPaths(app *App) {
emailKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, emailKeyPath)
cookieAuthKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieAuthKeyPath)
cookieKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieKeyPath)
}
-func initKeys(app *App) 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
-}
-
// generateKey generates a key at the given path used for the encryption of
// certain user data. Because user data becomes unrecoverable without these
// keys, this won't overwrite any existing key, and instead outputs a message.
func generateKey(path string) error {
// Check if key file exists
if _, err := os.Stat(path); err == nil {
log.Info("%s already exists. rm the file if you understand the consquences.", path)
return nil
} else if !os.IsNotExist(err) {
log.Error("%s", err)
return err
}
log.Info("Generating %s.", path)
b, err := key.GenerateBytes(key.EncKeysBytes)
if err != nil {
log.Error("FAILED. %s. Run writefreely --gen-keys again.", err)
return err
}
err = ioutil.WriteFile(path, b, 0600)
if err != nil {
log.Error("FAILED writing file: %s", err)
return err
}
log.Info("Success.")
return nil
}
diff --git a/routes.go b/routes.go
index f7d2451..a136970 100644
--- a/routes.go
+++ b/routes.go
@@ -1,202 +1,207 @@
/*
* 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/writeas/writefreely/config"
"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)
}
-func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datastore) {
- hostSubroute := cfg.App.Host[strings.Index(cfg.App.Host, "://")+3:]
- if cfg.App.SingleUser {
+// InitRoutes adds dynamic routes for the given mux.Router.
+func (app *App) InitRoutes(r *mux.Router) *mux.Router {
+ // Create handler
+ handler := NewWFHandler(app)
+
+ // Set up routes
+ hostSubroute := app.cfg.App.Host[strings.Index(app.cfg.App.Host, "://")+3:]
+ if app.cfg.App.SingleUser {
hostSubroute = "{domain}"
} else {
if strings.HasPrefix(hostSubroute, "localhost") {
hostSubroute = "localhost"
}
}
- if cfg.App.SingleUser {
+ if 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{db, cfg})
+ wf := webfinger.Default(wfResolver{app.db, app.cfg})
wf.NoTLSHandler = nil
// Federation endpoints
// host-meta
write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelOptional))
// webfinger
write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger)))
// nodeinfo
- niCfg := nodeInfoConfig(db, cfg)
- ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{cfg, db})
+ niCfg := nodeInfoConfig(app.db, app.cfg)
+ ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{app.cfg, 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 cfg.App.OpenRegistration {
+ if 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.All(fetchCollection)).Methods("GET")
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE")
apiColls.HandleFunc("/{alias}/posts", handler.All(fetchCollectionPosts)).Methods("GET")
apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/posts/{post}", handler.All(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.All(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.All(handleFetchCollectionOutbox)).Methods("GET")
apiColls.HandleFunc("/{alias}/following", handler.All(handleFetchCollectionFollowing)).Methods("GET")
apiColls.HandleFunc("/{alias}/followers", handler.All(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.All(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.All(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.Admin(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("/invite/{code}", handler.Web(handleViewInvite, UserLevelNoneRequired)).Methods("GET")
// TODO: show a reader-specific 404 page if the function is disabled
// TODO: change this based on configuration for either public or private-to-this-instance
readPerm := UserLevelOptional
write.HandleFunc("/read", handler.Web(viewLocalTimeline, readPerm))
RouteRead(handler, readPerm, write.PathPrefix("/read").Subrouter())
draftEditPrefix := ""
- if cfg.App.SingleUser {
+ if 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 cfg.App.SingleUser {
+ if app.cfg.App.SingleUser {
RouteCollections(handler, write.PathPrefix("/").Subrouter())
} else {
write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelOptional))
write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelOptional))
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, UserLevelOptional))
r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelOptional))
r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelOptional))
r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelOptional))
r.HandleFunc("/sitemap.xml", handler.All(handleViewSitemap))
r.HandleFunc("/feed/", handler.All(ViewFeed))
r.HandleFunc("/{slug}", handler.Web(viewCollectionPost, UserLevelOptional))
r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelOptional)).Methods("GET")
}
func RouteRead(handler *Handler, readPerm UserLevel, 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/session.go b/session.go
index 95bfb18..e379496 100644
--- a/session.go
+++ b/session.go
@@ -1,138 +1,138 @@
/*
* 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 (
"encoding/gob"
"github.com/gorilla/sessions"
"github.com/writeas/web-core/log"
"net/http"
"strings"
)
const (
day = 86400
sessionLength = 180 * day
cookieName = "wfu"
cookieUserVal = "u"
blogPassCookieName = "ub"
)
-// initSession creates the cookie store. It depends on the keychain already
+// InitSession creates the cookie store. It depends on the keychain already
// being loaded.
-func initSession(app *App) *sessions.CookieStore {
+func (app *App) InitSession() {
// Register complex data types we'll be storing in cookies
gob.Register(&User{})
// Create the cookie store
store := sessions.NewCookieStore(app.keys.CookieAuthKey, app.keys.CookieKey)
store.Options = &sessions.Options{
Path: "/",
MaxAge: sessionLength,
HttpOnly: true,
Secure: strings.HasPrefix(app.cfg.App.Host, "https://"),
}
- return store
+ app.sessionStore = store
}
func getSessionFlashes(app *App, w http.ResponseWriter, r *http.Request, session *sessions.Session) ([]string, error) {
var err error
if session == nil {
session, err = app.sessionStore.Get(r, cookieName)
if err != nil {
return nil, err
}
}
f := []string{}
if flashes := session.Flashes(); len(flashes) > 0 {
for _, flash := range flashes {
if str, ok := flash.(string); ok {
f = append(f, str)
}
}
}
saveUserSession(app, r, w)
return f, nil
}
func addSessionFlash(app *App, w http.ResponseWriter, r *http.Request, m string, session *sessions.Session) error {
var err error
if session == nil {
session, err = app.sessionStore.Get(r, cookieName)
}
if err != nil {
log.Error("Unable to add flash '%s': %v", m, err)
return err
}
session.AddFlash(m)
saveUserSession(app, r, w)
return nil
}
func getUserAndSession(app *App, r *http.Request) (*User, *sessions.Session) {
session, err := app.sessionStore.Get(r, cookieName)
if err == nil {
// Got the currently logged-in user
val := session.Values[cookieUserVal]
var u = &User{}
var ok bool
if u, ok = val.(*User); ok {
return u, session
}
}
return nil, nil
}
func getUserSession(app *App, r *http.Request) *User {
u, _ := getUserAndSession(app, r)
return u
}
func saveUserSession(app *App, r *http.Request, w http.ResponseWriter) error {
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
return ErrInternalCookieSession
}
// Extend the session
session.Options.MaxAge = int(sessionLength)
// Remove any information that accidentally got added
// FIXME: find where Plan information is getting saved to cookie.
val := session.Values[cookieUserVal]
var u = &User{}
var ok bool
if u, ok = val.(*User); ok {
session.Values[cookieUserVal] = u.Cookie()
}
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't saveUserSession: %v", err)
}
return err
}
func getFullUserSession(app *App, r *http.Request) *User {
u := getUserSession(app, r)
if u == nil {
return nil
}
u, _ = app.db.GetUserByID(u.ID)
return u
}
diff --git a/templates.go b/templates.go
index 0f93cb9..7a45c45 100644
--- a/templates.go
+++ b/templates.go
@@ -1,193 +1,194 @@
/*
* Copyright © 2018 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/dustin/go-humanize"
"github.com/writeas/web-core/l10n"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config"
"html/template"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
)
var (
templates = map[string]*template.Template{}
pages = map[string]*template.Template{}
userPages = map[string]*template.Template{}
funcMap = template.FuncMap{
"largeNumFmt": largeNumFmt,
"pluralize": pluralize,
"isRTL": isRTL,
"isLTR": isLTR,
"localstr": localStr,
"localhtml": localHTML,
"tolower": strings.ToLower,
}
)
const (
templatesDir = "templates"
pagesDir = "pages"
)
func showUserPage(w http.ResponseWriter, name string, obj interface{}) {
if obj == nil {
log.Error("showUserPage: data is nil!")
return
}
if err := userPages[filepath.Join("user", name+".tmpl")].ExecuteTemplate(w, name, obj); err != nil {
log.Error("Error parsing %s: %v", name, err)
}
}
func initTemplate(parentDir, name string) {
if debugging {
log.Info(" " + filepath.Join(parentDir, templatesDir, name+".tmpl"))
}
files := []string{
filepath.Join(parentDir, templatesDir, name+".tmpl"),
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "base.tmpl"),
}
if name == "collection" || name == "collection-tags" {
// These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl"
files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl"))
}
if name == "collection" || name == "collection-tags" || name == "collection-post" || name == "post" {
files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl"))
}
templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
}
func initPage(parentDir, path, key string) {
if debugging {
log.Info(" [%s] %s", key, path)
}
pages[key] = template.Must(template.New("").Funcs(funcMap).ParseFiles(
path,
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "base.tmpl"),
))
}
func initUserPage(parentDir, path, key string) {
if debugging {
log.Info(" [%s] %s", key, path)
}
userPages[key] = template.Must(template.New(key).Funcs(funcMap).ParseFiles(
path,
filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"),
))
}
-func initTemplates(cfg *config.Config) error {
+// InitTemplates loads all template files from the configured parent dir.
+func InitTemplates(cfg *config.Config) error {
log.Info("Loading templates...")
tmplFiles, err := ioutil.ReadDir(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir))
if err != nil {
return err
}
for _, f := range tmplFiles {
if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
parts := strings.Split(f.Name(), ".")
key := parts[0]
initTemplate(cfg.Server.TemplatesParentDir, key)
}
}
log.Info("Loading pages...")
// Initialize all static pages that use the base template
filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, pagesDir), func(path string, i os.FileInfo, err error) error {
if !i.IsDir() && !strings.HasPrefix(i.Name(), ".") {
key := i.Name()
initPage(cfg.Server.PagesParentDir, path, key)
}
return nil
})
log.Info("Loading user pages...")
// Initialize all user pages that use base templates
filepath.Walk(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir, "user"), func(path string, f os.FileInfo, err error) error {
if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
corePath := path
if cfg.Server.TemplatesParentDir != "" {
corePath = corePath[len(cfg.Server.TemplatesParentDir)+1:]
}
parts := strings.Split(corePath, string(filepath.Separator))
key := f.Name()
if len(parts) > 2 {
key = filepath.Join(parts[1], f.Name())
}
initUserPage(cfg.Server.TemplatesParentDir, path, key)
}
return nil
})
return nil
}
// renderPage retrieves the given template and renders it to the given io.Writer.
// If something goes wrong, the error is logged and returned.
func renderPage(w io.Writer, tmpl string, data interface{}) error {
err := pages[tmpl].ExecuteTemplate(w, "base", data)
if err != nil {
log.Error("%v", err)
}
return err
}
func largeNumFmt(n int64) string {
return humanize.Comma(n)
}
func pluralize(singular, plural string, n int64) string {
if n == 1 {
return singular
}
return plural
}
func isRTL(d string) bool {
return d == "rtl"
}
func isLTR(d string) bool {
return d == "ltr" || d == "auto"
}
func localStr(term, lang string) string {
s := l10n.Strings(lang)[term]
if s == "" {
s = l10n.Strings("")[term]
}
return s
}
func localHTML(term, lang string) template.HTML {
s := l10n.Strings(lang)[term]
if s == "" {
s = l10n.Strings("")[term]
}
s = strings.Replace(s, "write.as", "<a href=\"https://writefreely.org\">writefreely</a>", 1)
return template.HTML(s)
}

File Metadata

Mime Type
text/x-diff
Expires
Sun, Apr 27, 6:01 AM (12 h, 48 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3216926

Event Timeline