From 034db22f8c29c8cf94898e3f1b1dfab2338ff000 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Thu, 13 Jun 2019 18:50:23 -0400 Subject: [PATCH] Break functionality out of Serve() func - Adds a new interface, Apper, that enables loading and persisting instance-level data in new ways - Converts some initialization funcs to methods - Exports funcs and methods needed for intialization - In general, moves a ton of stuff around Overall, this should maintain all existing functionality, but with the ability to now better manage a WF instance. Ref T613 --- app.go | 274 +++++++++++++++++++++++++++------------- cmd/writefreely/main.go | 19 ++- handle.go | 13 ++ keys.go | 40 ++---- routes.go | 27 ++-- session.go | 6 +- templates.go | 3 +- 7 files changed, 245 insertions(+), 137 deletions(-) diff --git a/app.go b/app.go index abc34a3..e970a77 100644 --- a/app.go +++ b/app.go @@ -14,6 +14,7 @@ import ( "database/sql" "fmt" "html/template" + "io/ioutil" "net/http" "net/url" "os" @@ -76,10 +77,109 @@ type App struct { 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 { @@ -198,80 +298,49 @@ func pageForReq(app *App, r *http.Request) page.StaticPage { 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) @@ -284,13 +353,12 @@ func Serve(app *App, debug bool) { 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() { @@ -304,11 +372,11 @@ func Serve(app *App, debug bool) { 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) @@ -316,6 +384,44 @@ func Serve(app *App, debug bool) { } } +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) @@ -378,10 +484,10 @@ func DoConfig(app *App) { 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) @@ -412,11 +518,11 @@ func GenerateKeys(app *App) error { } // 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 } @@ -425,7 +531,7 @@ func CreateSchema(app *App) error { // Migrate runs all necessary database migrations. func Migrate(app *App) error { - loadConfig(app) + app.LoadConfig() connectToDatabase(app) defer shutdown(app) @@ -439,7 +545,7 @@ func Migrate(app *App) error { // 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) @@ -475,16 +581,6 @@ func ResetPassword(app *App, username string) error { 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) @@ -521,14 +617,14 @@ func shutdown(app *App) { } // 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 { @@ -551,8 +647,8 @@ func CreateUser(app *App, username, password string, isAdmin bool) error { 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 @@ -572,7 +668,7 @@ func CreateUser(app *App, username, password string, isAdmin bool) error { 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) } 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 @@ -13,6 +13,7 @@ package main import ( "flag" "fmt" + "github.com/gorilla/mux" "github.com/writeas/web-core/log" "github.com/writeas/writefreely" "os" @@ -54,7 +55,7 @@ func main() { 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) @@ -107,7 +108,21 @@ func main() { 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) { diff --git a/handle.go b/handle.go index 946487f..acde1a1 100644 --- a/handle.go +++ b/handle.go @@ -74,6 +74,19 @@ func NewHandler(app *App) *Handler { 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) { diff --git a/keys.go b/keys.go index 067908e..5cc63a3 100644 --- a/keys.go +++ b/keys.go @@ -28,6 +28,15 @@ var ( 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) @@ -35,37 +44,6 @@ func initKeyPaths(app *App) { 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. diff --git a/routes.go b/routes.go index f7d2451..a136970 100644 --- a/routes.go +++ b/routes.go @@ -14,7 +14,6 @@ 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" @@ -31,9 +30,14 @@ func (app *App) InitStaticRoutes(r *mux.Router) { 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") { @@ -41,7 +45,7 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto } } - 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) @@ -51,7 +55,7 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto 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 @@ -60,15 +64,15 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto // 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") @@ -155,7 +159,7 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto 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 { @@ -166,7 +170,7 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto 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)) @@ -176,6 +180,7 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto } write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional)) write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional)) + return r } func RouteCollections(handler *Handler, r *mux.Router) { diff --git a/session.go b/session.go index 95bfb18..e379496 100644 --- a/session.go +++ b/session.go @@ -27,9 +27,9 @@ const ( 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{}) @@ -41,7 +41,7 @@ func initSession(app *App) *sessions.CookieStore { 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) { diff --git a/templates.go b/templates.go index 0f93cb9..7a45c45 100644 --- a/templates.go +++ b/templates.go @@ -98,7 +98,8 @@ func initUserPage(parentDir, path, key string) { )) } -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 {