diff --git a/app.go b/app.go index 7b149cc..8418bee 100644 --- a/app.go +++ b/app.go @@ -1,725 +1,736 @@ /* * 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 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("/([^/]*\\.[^/]*)$") // Initialize loads the app configuration and initializes templates, keys, // session, route handlers, and the database connection. func Initialize(apper Apper, debug bool) (*App, error) { debugging = debug apper.LoadConfig() // Load templates err := InitTemplates(apper.App().Config()) if err != nil { return nil, fmt.Errorf("load templates: %s", err) } // Load keys and set up session initKeyPaths(apper.App()) // TODO: find a better way to do this, since it's unneeded in all Apper implementations err = InitKeys(apper) if err != nil { return nil, fmt.Errorf("init keys: %s", err) } apper.App().InitSession() apper.App().InitDecoder() err = ConnectToDatabase(apper.App()) if err != nil { return nil, fmt.Errorf("connect to DB: %s", err) } // Handle local timeline, if enabled if apper.App().cfg.App.LocalTimeline { log.Info("Initializing local timeline...") initLocalTimeline(apper.App()) } return apper.App(), nil } func Serve(app *App, r *mux.Router) { log.Info("Going to serve...") isSingleUser = app.cfg.App.SingleUser app.cfg.Server.Dev = debugging // Handle shutdown c := make(chan os.Signal, 2) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c log.Info("Shutting down...") shutdown(app) log.Info("Done.") os.Exit(0) }() // Start web application server var bindAddress = app.cfg.Server.Bind if bindAddress == "" { bindAddress = "localhost" } var err error if app.cfg.IsSecureStandalone() { 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, r) } else { log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port) log.Info("---") err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), r) } if err != nil { log.Error("Unable to start: %v", err) os.Exit(1) } } func (app *App) InitDecoder() { // TODO: do this at the package level, instead of the App level // Initialize modules app.formDecoder = schema.NewDecoder() app.formDecoder.RegisterConverter(converter.NullJSONString{}, converter.ConvertJSONNullString) app.formDecoder.RegisterConverter(converter.NullJSONBool{}, converter.ConvertJSONNullBool) app.formDecoder.RegisterConverter(sql.NullString{}, converter.ConvertSQLNullString) app.formDecoder.RegisterConverter(sql.NullBool{}, converter.ConvertSQLNullBool) app.formDecoder.RegisterConverter(sql.NullInt64{}, converter.ConvertSQLNullInt64) app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64) } // ConnectToDatabase validates and connects to the configured database, then // tests the connection. func ConnectToDatabase(app *App) error { // Check database configuration if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") { return fmt.Errorf("Database user or password not set.") } if app.cfg.Database.Host == "" { app.cfg.Database.Host = "localhost" } if app.cfg.Database.Database == "" { app.cfg.Database.Database = "writefreely" } // TODO: check err connectToDatabase(app) // Test database connection err := app.db.Ping() if err != nil { return fmt.Errorf("Database ping failed: %s", err) } return nil } // OutputVersion prints out the version of the application. func OutputVersion() { fmt.Println(serverSoftware + " " + softwareVer) } // NewApp creates a new app instance. func NewApp(cfgFile string) *App { return &App{ cfgFile: cfgFile, } } // CreateConfig creates a default configuration and saves it to the app's cfgFile. func CreateConfig(app *App) error { log.Info("Creating configuration...") c := config.New() log.Info("Saving configuration %s...", app.cfgFile) err := config.Save(c, app.cfgFile) if err != nil { return fmt.Errorf("Unable to save configuration: %v", err) } return nil } // DoConfig runs the interactive configuration process. -func DoConfig(app *App) { - d, err := config.Configure(app.cfgFile) +func DoConfig(app *App, configSections string) { + if configSections == "" { + configSections = "server db app" + } + // let's check there aren't any garbage in the list + configSectionsArray := strings.Split(configSections, " ") + for _, element := range configSectionsArray { + if element != "server" && element != "db" && element != "app" { + log.Error("Invalid argument to --sections. Valid arguments are only \"server\", \"db\" and \"app\"") + os.Exit(1) + } + } + d, err := config.Configure(app.cfgFile, configSections) if err != nil { log.Error("Unable to configure: %v", err) os.Exit(1) } app.cfg = d.Config connectToDatabase(app) defer shutdown(app) if !app.db.DatabaseInitialized() { err = adminInitDatabase(app) if err != nil { log.Error(err.Error()) os.Exit(1) } } else { log.Info("Database already initialized.") } if d.User != nil { u := &User{ Username: d.User.Username, HashedPass: d.User.HashedPass, Created: time.Now().Truncate(time.Second).UTC(), } // Create blog log.Info("Creating user %s...\n", u.Username) err = app.db.CreateUser(u, app.cfg.App.SiteName) if err != nil { log.Error("Unable to create user: %s", err) os.Exit(1) } log.Info("Done!") } os.Exit(0) } // GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir. func GenerateKeyFiles(app *App) error { // Read keys path from config app.LoadConfig() // Create keys dir if it doesn't exist yet fullKeysDir := filepath.Join(app.cfg.Server.KeysParentDir, keysDir) if _, err := os.Stat(fullKeysDir); os.IsNotExist(err) { err = os.Mkdir(fullKeysDir, 0700) if err != nil { return err } } // Generate keys initKeyPaths(app) // TODO: use something like https://github.com/hashicorp/go-multierror to return errors var keyErrs error err := generateKey(emailKeyPath) if err != nil { keyErrs = err } err = generateKey(cookieAuthKeyPath) if err != nil { keyErrs = err } err = generateKey(cookieKeyPath) if err != nil { keyErrs = err } return keyErrs } // CreateSchema creates all database tables needed for the application. func CreateSchema(apper Apper) error { apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) err := adminInitDatabase(apper.App()) if err != nil { return err } return nil } // Migrate runs all necessary database migrations. func Migrate(app *App) error { 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 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 connectToDatabase(app *App) { log.Info("Connecting to %s database...", app.cfg.Database.Type) var db *sql.DB var err error if app.cfg.Database.Type == driverMySQL { db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()))) db.SetMaxOpenConns(50) } else if app.cfg.Database.Type == driverSQLite { if !SQLiteEnabled { log.Error("Invalid database type '%s'. Binary wasn't compiled with SQLite3 support.", app.cfg.Database.Type) os.Exit(1) } if app.cfg.Database.FileName == "" { log.Error("SQLite database filename value in config.ini is empty.") os.Exit(1) } db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared") db.SetMaxOpenConns(1) } else { log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type) os.Exit(1) } if err != nil { log.Error("%s", err) os.Exit(1) } app.db = &datastore{db, app.cfg.Database.Type} } func shutdown(app *App) { log.Info("Closing database connection...") app.db.Close() } // CreateUser creates a new admin or normal user from the given credentials. func CreateUser(apper Apper, username, password string, isAdmin bool) error { // Create an admin user with --create-admin apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) // Ensure an admin / first user doesn't already exist firstUser, _ := apper.App().db.GetUserByID(1) if isAdmin { // Abort if trying to create admin user, but one already exists if firstUser != nil { return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username) } } else { // Abort if trying to create regular user, but no admin exists yet if firstUser == nil { return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin") } } // Create the user // Normalize and validate username desiredUsername := username username = getSlug(username, "") usernameDesc := username if username != desiredUsername { usernameDesc += " (originally: " + desiredUsername + ")" } if !author.IsValidUsername(apper.App().cfg, username) { return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, apper.App().cfg.App.MinUsernameLen) } // Hash the password hashedPass, err := auth.HashPass([]byte(password)) if err != nil { return fmt.Errorf("Unable to hash password: %v", err) } u := &User{ Username: username, HashedPass: hashedPass, Created: time.Now().Truncate(time.Second).UTC(), } userType := "user" if isAdmin { userType = "admin" } log.Info("Creating %s %s...", userType, usernameDesc) err = apper.App().db.CreateUser(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 484135d..10cd7d6 100644 --- a/cmd/writefreely/main.go +++ b/cmd/writefreely/main.go @@ -1,142 +1,145 @@ /* * 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") + configSections := flag.String("sections", "server db app", "Which sections of the configuration to go through (requires --config), "+ + "valid values are any combination of 'server', 'db' and 'app' "+ + "example: writefreely --config --sections \"db app\"") 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) + writefreely.DoConfig(app, *configSections) os.Exit(0) } else if *genKeys { 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) } // 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() writefreely.InitRoutes(app, 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/config/setup.go b/config/setup.go index 4950bc3..b1c0c37 100644 --- a/config/setup.go +++ b/config/setup.go @@ -1,362 +1,373 @@ /* * 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 config import ( "fmt" "github.com/fatih/color" "github.com/manifoldco/promptui" "github.com/mitchellh/go-wordwrap" "github.com/writeas/web-core/auth" "strconv" + "strings" ) type SetupData struct { User *UserCreation Config *Config } -func Configure(fname string) (*SetupData, error) { +func Configure(fname string, configSections string) (*SetupData, error) { data := &SetupData{} var err error if fname == "" { fname = FileName } data.Config, err = Load(fname) var action string isNewCfg := false if err != nil { fmt.Printf("No %s configuration yet. Creating new.\n", fname) data.Config = New() action = "generate" isNewCfg = true } else { fmt.Printf("Loaded configuration %s.\n", fname) action = "update" } title := color.New(color.Bold, color.BgGreen).PrintFunc() intro := color.New(color.Bold, color.FgWhite).PrintlnFunc() fmt.Println() intro(" ✍ WriteFreely Configuration ✍") fmt.Println() fmt.Println(wordwrap.WrapString(" This quick configuration process will "+action+" the application's config file, "+fname+".\n\n It validates your input along the way, so you can be sure any future errors aren't caused by a bad configuration. If you'd rather configure your server manually, instead run: writefreely --create-config and edit that file.", 75)) fmt.Println() - title(" Server setup ") - fmt.Println() - tmpls := &promptui.PromptTemplates{ Success: "{{ . | bold | faint }}: ", } selTmpls := &promptui.SelectTemplates{ Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`), } - // Environment selection - selPrompt := promptui.Select{ - Templates: selTmpls, - Label: "Environment", - Items: []string{"Development", "Production, standalone", "Production, behind reverse proxy"}, - } - _, envType, err := selPrompt.Run() - if err != nil { - return data, err - } - isDevEnv := envType == "Development" - isStandalone := envType == "Production, standalone" + var selPrompt promptui.Select + var prompt promptui.Prompt - data.Config.Server.Dev = isDevEnv + if strings.Contains(configSections, "server") { + title(" Server setup ") + fmt.Println() - var prompt promptui.Prompt - if isDevEnv || !isStandalone { - // Running in dev environment or behind reverse proxy; ask for port - prompt = promptui.Prompt{ - Templates: tmpls, - Label: "Local port", - Validate: validatePort, - Default: fmt.Sprintf("%d", data.Config.Server.Port), + // Environment selection + selPrompt = promptui.Select{ + Templates: selTmpls, + Label: "Environment", + Items: []string{"Development", "Production, standalone", "Production, behind reverse proxy"}, } - port, err := prompt.Run() + _, envType, err := selPrompt.Run() if err != nil { return data, err } - data.Config.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number + isDevEnv := envType == "Development" + isStandalone := envType == "Production, standalone" + + data.Config.Server.Dev = isDevEnv + + if isDevEnv || !isStandalone { + // Running in dev environment or behind reverse proxy; ask for port + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Local port", + Validate: validatePort, + Default: fmt.Sprintf("%d", data.Config.Server.Port), + } + port, err := prompt.Run() + if err != nil { + return data, err + } + data.Config.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number + } + + if isStandalone { + selPrompt = promptui.Select{ + Templates: selTmpls, + Label: "Web server mode", + Items: []string{"Insecure (port 80)", "Secure (port 443)"}, + } + sel, _, err := selPrompt.Run() + if err != nil { + return data, err + } + if sel == 0 { + data.Config.Server.Port = 80 + data.Config.Server.TLSCertPath = "" + data.Config.Server.TLSKeyPath = "" + } else if sel == 1 { + data.Config.Server.Port = 443 + + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Certificate path", + Validate: validateNonEmpty, + Default: data.Config.Server.TLSCertPath, + } + data.Config.Server.TLSCertPath, err = prompt.Run() + if err != nil { + return data, err + } + + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Key path", + Validate: validateNonEmpty, + Default: data.Config.Server.TLSKeyPath, + } + data.Config.Server.TLSKeyPath, err = prompt.Run() + if err != nil { + return data, err + } + } + } else { + data.Config.Server.TLSCertPath = "" + data.Config.Server.TLSKeyPath = "" + } + + fmt.Println() } - if isStandalone { + if strings.Contains(configSections, "db") { + title(" Database setup ") + fmt.Println() + selPrompt = promptui.Select{ Templates: selTmpls, - Label: "Web server mode", - Items: []string{"Insecure (port 80)", "Secure (port 443)"}, + Label: "Database driver", + Items: []string{"MySQL", "SQLite"}, } sel, _, err := selPrompt.Run() if err != nil { return data, err } + if sel == 0 { - data.Config.Server.Port = 80 - data.Config.Server.TLSCertPath = "" - data.Config.Server.TLSKeyPath = "" - } else if sel == 1 { - data.Config.Server.Port = 443 + // Configure for MySQL + data.Config.UseMySQL(isNewCfg) prompt = promptui.Prompt{ Templates: tmpls, - Label: "Certificate path", + Label: "Username", Validate: validateNonEmpty, - Default: data.Config.Server.TLSCertPath, + Default: data.Config.Database.User, } - data.Config.Server.TLSCertPath, err = prompt.Run() + data.Config.Database.User, err = prompt.Run() if err != nil { return data, err } prompt = promptui.Prompt{ Templates: tmpls, - Label: "Key path", + Label: "Password", Validate: validateNonEmpty, - Default: data.Config.Server.TLSKeyPath, + Default: data.Config.Database.Password, + Mask: '*', } - data.Config.Server.TLSKeyPath, err = prompt.Run() + data.Config.Database.Password, err = prompt.Run() if err != nil { return data, err } - } - } else { - data.Config.Server.TLSCertPath = "" - data.Config.Server.TLSKeyPath = "" - } - fmt.Println() - title(" Database setup ") - fmt.Println() + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Database name", + Validate: validateNonEmpty, + Default: data.Config.Database.Database, + } + data.Config.Database.Database, err = prompt.Run() + if err != nil { + return data, err + } - selPrompt = promptui.Select{ - Templates: selTmpls, - Label: "Database driver", - Items: []string{"MySQL", "SQLite"}, - } - sel, _, err := selPrompt.Run() - if err != nil { - return data, err - } + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Host", + Validate: validateNonEmpty, + Default: data.Config.Database.Host, + } + data.Config.Database.Host, err = prompt.Run() + if err != nil { + return data, err + } - if sel == 0 { - // Configure for MySQL - data.Config.UseMySQL(isNewCfg) + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Port", + Validate: validatePort, + Default: fmt.Sprintf("%d", data.Config.Database.Port), + } + dbPort, err := prompt.Run() + if err != nil { + return data, err + } + data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number + } else if sel == 1 { + // Configure for SQLite + data.Config.UseSQLite(isNewCfg) - prompt = promptui.Prompt{ - Templates: tmpls, - Label: "Username", - Validate: validateNonEmpty, - Default: data.Config.Database.User, - } - data.Config.Database.User, err = prompt.Run() - if err != nil { - return data, err + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Filename", + Validate: validateNonEmpty, + Default: data.Config.Database.FileName, + } + data.Config.Database.FileName, err = prompt.Run() + if err != nil { + return data, err + } } - prompt = promptui.Prompt{ - Templates: tmpls, - Label: "Password", - Validate: validateNonEmpty, - Default: data.Config.Database.Password, - Mask: '*', - } - data.Config.Database.Password, err = prompt.Run() - if err != nil { - return data, err - } + fmt.Println() + } - prompt = promptui.Prompt{ - Templates: tmpls, - Label: "Database name", - Validate: validateNonEmpty, - Default: data.Config.Database.Database, - } - data.Config.Database.Database, err = prompt.Run() - if err != nil { - return data, err - } + if strings.Contains(configSections, "app") { + title(" App setup ") + fmt.Println() - prompt = promptui.Prompt{ - Templates: tmpls, - Label: "Host", - Validate: validateNonEmpty, - Default: data.Config.Database.Host, - } - data.Config.Database.Host, err = prompt.Run() - if err != nil { - return data, err - } - - prompt = promptui.Prompt{ - Templates: tmpls, - Label: "Port", - Validate: validatePort, - Default: fmt.Sprintf("%d", data.Config.Database.Port), + selPrompt = promptui.Select{ + Templates: selTmpls, + Label: "Site type", + Items: []string{"Single user blog", "Multi-user instance"}, } - dbPort, err := prompt.Run() + _, usersType, err := selPrompt.Run() if err != nil { return data, err } - data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number - } else if sel == 1 { - // Configure for SQLite - data.Config.UseSQLite(isNewCfg) + data.Config.App.SingleUser = usersType == "Single user blog" - prompt = promptui.Prompt{ - Templates: tmpls, - Label: "Filename", - Validate: validateNonEmpty, - Default: data.Config.Database.FileName, - } - data.Config.Database.FileName, err = prompt.Run() - if err != nil { - return data, err - } - } + if data.Config.App.SingleUser { + data.User = &UserCreation{} - fmt.Println() - title(" App setup ") - fmt.Println() + // prompt for username + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Admin username", + Validate: validateNonEmpty, + } + data.User.Username, err = prompt.Run() + if err != nil { + return data, err + } - selPrompt = promptui.Select{ - Templates: selTmpls, - Label: "Site type", - Items: []string{"Single user blog", "Multi-user instance"}, - } - _, usersType, err := selPrompt.Run() - if err != nil { - return data, err - } - data.Config.App.SingleUser = usersType == "Single user blog" + // prompt for password + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Admin password", + Validate: validateNonEmpty, + } + newUserPass, err := prompt.Run() + if err != nil { + return data, err + } - if data.Config.App.SingleUser { - data.User = &UserCreation{} + data.User.HashedPass, err = auth.HashPass([]byte(newUserPass)) + if err != nil { + return data, err + } + } - // prompt for username + siteNameLabel := "Instance name" + if data.Config.App.SingleUser { + siteNameLabel = "Blog name" + } prompt = promptui.Prompt{ Templates: tmpls, - Label: "Admin username", + Label: siteNameLabel, Validate: validateNonEmpty, + Default: data.Config.App.SiteName, } - data.User.Username, err = prompt.Run() + data.Config.App.SiteName, err = prompt.Run() if err != nil { return data, err } - // prompt for password prompt = promptui.Prompt{ Templates: tmpls, - Label: "Admin password", - Validate: validateNonEmpty, - } - newUserPass, err := prompt.Run() - if err != nil { - return data, err + Label: "Public URL", + Validate: validateDomain, + Default: data.Config.App.Host, } - - data.User.HashedPass, err = auth.HashPass([]byte(newUserPass)) + data.Config.App.Host, err = prompt.Run() if err != nil { return data, err } - } - siteNameLabel := "Instance name" - if data.Config.App.SingleUser { - siteNameLabel = "Blog name" - } - prompt = promptui.Prompt{ - Templates: tmpls, - Label: siteNameLabel, - Validate: validateNonEmpty, - Default: data.Config.App.SiteName, - } - data.Config.App.SiteName, err = prompt.Run() - if err != nil { - return data, err - } - - prompt = promptui.Prompt{ - Templates: tmpls, - Label: "Public URL", - Validate: validateDomain, - Default: data.Config.App.Host, - } - data.Config.App.Host, err = prompt.Run() - if err != nil { - return data, err - } - - if !data.Config.App.SingleUser { - selPrompt = promptui.Select{ - Templates: selTmpls, - Label: "Registration", - Items: []string{"Open", "Closed"}, - } - _, regType, err := selPrompt.Run() - if err != nil { - return data, err - } - data.Config.App.OpenRegistration = regType == "Open" + if !data.Config.App.SingleUser { + selPrompt = promptui.Select{ + Templates: selTmpls, + Label: "Registration", + Items: []string{"Open", "Closed"}, + } + _, regType, err := selPrompt.Run() + if err != nil { + return data, err + } + data.Config.App.OpenRegistration = regType == "Open" - prompt = promptui.Prompt{ - Templates: tmpls, - Label: "Max blogs per user", - Default: fmt.Sprintf("%d", data.Config.App.MaxBlogs), - } - maxBlogs, err := prompt.Run() - if err != nil { - return data, err + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Max blogs per user", + Default: fmt.Sprintf("%d", data.Config.App.MaxBlogs), + } + maxBlogs, err := prompt.Run() + if err != nil { + return data, err + } + data.Config.App.MaxBlogs, _ = strconv.Atoi(maxBlogs) // Ignore error, as we've already validated number } - data.Config.App.MaxBlogs, _ = strconv.Atoi(maxBlogs) // Ignore error, as we've already validated number - } - - selPrompt = promptui.Select{ - Templates: selTmpls, - Label: "Federation", - Items: []string{"Enabled", "Disabled"}, - } - _, fedType, err := selPrompt.Run() - if err != nil { - return data, err - } - data.Config.App.Federation = fedType == "Enabled" - if data.Config.App.Federation { selPrompt = promptui.Select{ Templates: selTmpls, - Label: "Federation usage stats", - Items: []string{"Public", "Private"}, + Label: "Federation", + Items: []string{"Enabled", "Disabled"}, } - _, fedStatsType, err := selPrompt.Run() + _, fedType, err := selPrompt.Run() if err != nil { return data, err } - data.Config.App.PublicStats = fedStatsType == "Public" + data.Config.App.Federation = fedType == "Enabled" - selPrompt = promptui.Select{ - Templates: selTmpls, - Label: "Instance metadata privacy", - Items: []string{"Public", "Private"}, - } - _, fedStatsType, err = selPrompt.Run() - if err != nil { - return data, err + if data.Config.App.Federation { + selPrompt = promptui.Select{ + Templates: selTmpls, + Label: "Federation usage stats", + Items: []string{"Public", "Private"}, + } + _, fedStatsType, err := selPrompt.Run() + if err != nil { + return data, err + } + data.Config.App.PublicStats = fedStatsType == "Public" + + selPrompt = promptui.Select{ + Templates: selTmpls, + Label: "Instance metadata privacy", + Items: []string{"Public", "Private"}, + } + _, fedStatsType, err = selPrompt.Run() + if err != nil { + return data, err + } + data.Config.App.Private = fedStatsType == "Private" } - data.Config.App.Private = fedStatsType == "Private" } return data, Save(data.Config, fname) }