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\"}}404

Not found.

{{end}}")), Gone: template.Must(template.New("").Parse("{{define \"base\"}}410

Gone.

{{end}}")), InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}500

Internal server error.

{{end}}")), Blank: template.Must(template.New("").Parse("{{define \"base\"}}{{.Title}}

{{.Content}}

{{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("

%s

", 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", "writefreely", 1) return template.HTML(s) }