diff --git a/app.go b/app.go index 86351ed..7312e83 100644 --- a/app.go +++ b/app.go @@ -1,129 +1,137 @@ package writefreely import ( "database/sql" "flag" "fmt" _ "github.com/go-sql-driver/mysql" "net/http" "os" "os/signal" "syscall" "github.com/gorilla/mux" "github.com/gorilla/sessions" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" ) const ( staticDir = "static/" serverSoftware = "Write Freely" softwareURL = "https://writefreely.org" softwareVer = "0.1" ) type app struct { router *mux.Router db *datastore cfg *config.Config keys *keychain sessionStore *sessions.CookieStore } var shttp = http.NewServeMux() func Serve() { createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits") + doConfig := flag.Bool("config", false, "Run the configuration process") flag.Parse() if *createConfig { log.Info("Creating configuration...") c := config.New() log.Info("Saving configuration...") err := config.Save(c) if err != nil { log.Error("Unable to save configuration: %v", err) os.Exit(1) } os.Exit(0) + } else if *doConfig { + err := config.Configure() + if err != nil { + log.Error("Unable to configure: %v", err) + os.Exit(1) + } + os.Exit(0) } log.Info("Initializing...") log.Info("Loading configuration...") cfg, err := config.Load() if err != nil { log.Error("Unable to load configuration: %v", err) os.Exit(1) } app := &app{ cfg: cfg, } // Load keys log.Info("Loading encryption keys...") err = initKeys(app) if err != nil { log.Error("\n%s\n", err) } // Initialize modules app.sessionStore = initSession(app) // Check database configuration if 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 = "writeas" } log.Info("Connecting to database...") db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database)) if err != nil { log.Error("\n%s\n", err) os.Exit(1) } app.db = &datastore{db} defer shutdown(app) app.db.SetMaxOpenConns(50) r := mux.NewRouter() handler := NewHandler(app.sessionStore) // Handle app routes initRoutes(handler, r, app.cfg, app.db) // Handle static files fs := http.FileServer(http.Dir(staticDir)) shttp.Handle("/", fs) r.PathPrefix("/").Handler(fs) // 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 http.Handle("/", r) log.Info("Serving on http://localhost:%d\n", app.cfg.Server.Port) log.Info("---") http.ListenAndServe(fmt.Sprintf(":%d", app.cfg.Server.Port), nil) } func shutdown(app *app) { log.Info("Closing database connection...") app.db.Close() } diff --git a/config/config.go b/config/config.go index 77a1f58..5ca1358 100644 --- a/config/config.go +++ b/config/config.go @@ -1,90 +1,97 @@ package config import ( "gopkg.in/ini.v1" ) const ( - configFile = "config.ini" + FileName = "config.ini" ) type ( ServerCfg struct { Host string `ini:"host"` Port int `ini:"port"` } DatabaseCfg struct { Type string `ini:"type"` User string `ini:"username"` Password string `ini:"password"` Database string `ini:"database"` Host string `ini:"host"` Port int `ini:"port"` } AppCfg struct { - MultiUser bool `ini:"multiuser"` - OpenSignups bool `ini:"open_signups"` - Federation bool `ini:"federation"` - PublicStats bool `ini:"public_stats"` - Private bool `ini:"private"` + SiteName string `ini:"site_name"` - Name string `ini:"site_name"` + // Site appearance + Theme string `ini:"theme"` + JSDisabled bool `ini:"disable_js"` + WebFonts bool `ini:"webfonts"` - JSDisabled bool `ini:"disable_js"` + // Users + SingleUser bool `ini:"single_user"` + OpenRegistration bool `ini:"open_registration"` + MinUsernameLen int `ini:"min_username_len"` - // User registration - MinUsernameLen int `ini:"min_username_len"` + // Federation + Federation bool `ini:"federation"` + PublicStats bool `ini:"public_stats"` + Private bool `ini:"private"` } Config struct { Server ServerCfg `ini:"server"` Database DatabaseCfg `ini:"database"` App AppCfg `ini:"app"` } ) func New() *Config { return &Config{ Server: ServerCfg{ Host: "http://localhost:8080", Port: 8080, }, Database: DatabaseCfg{ Type: "mysql", Host: "localhost", Port: 3306, }, App: AppCfg{ + Theme: "write", + WebFonts: true, + SingleUser: true, + MinUsernameLen: 3, Federation: true, PublicStats: true, - MinUsernameLen: 3, }, } } func Load() (*Config, error) { - cfg, err := ini.Load(configFile) + cfg, err := ini.Load(FileName) if err != nil { return nil, err } // Parse INI file uc := &Config{} err = cfg.MapTo(uc) if err != nil { return nil, err } return uc, nil } func Save(uc *Config) error { cfg := ini.Empty() err := ini.ReflectFrom(cfg, uc) if err != nil { return err } - return cfg.SaveTo(configFile) + return cfg.SaveTo(FileName) } diff --git a/config/setup.go b/config/setup.go new file mode 100644 index 0000000..3eabce2 --- /dev/null +++ b/config/setup.go @@ -0,0 +1,181 @@ +package config + +import ( + "fmt" + "github.com/fatih/color" + "github.com/manifoldco/promptui" + "github.com/mitchellh/go-wordwrap" + "strconv" +) + +func Configure() error { + c, err := Load() + if err != nil { + fmt.Println("No configuration yet. Creating new.") + c = New() + } else { + fmt.Println("Configuration loaded.") + } + title := color.New(color.Bold, color.BgGreen).PrintlnFunc() + + intro := color.New(color.Bold, color.FgWhite).PrintlnFunc() + fmt.Println() + intro(" ✍ Write Freely Configuration ✍") + fmt.Println() + fmt.Println(wordwrap.WrapString(" This quick configuration process will generate the application's config file, "+FileName+".\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() + + prompt := promptui.Prompt{ + Label: "Local port", + Validate: validatePort, + Default: fmt.Sprintf("%d", c.Server.Port), + } + port, err := prompt.Run() + if err != nil { + return err + } + c.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number + + prompt = promptui.Prompt{ + Label: "Public-facing host", + Validate: validateDomain, + Default: c.Server.Host, + } + c.Server.Host, err = prompt.Run() + if err != nil { + return err + } + + fmt.Println() + title(" Database setup ") + fmt.Println() + + prompt = promptui.Prompt{ + Label: "Username", + Validate: validateNonEmpty, + Default: c.Database.User, + } + c.Database.User, err = prompt.Run() + if err != nil { + return err + } + + prompt = promptui.Prompt{ + Label: "Password", + Validate: validateNonEmpty, + Default: c.Database.Password, + Mask: '*', + } + c.Database.Password, err = prompt.Run() + if err != nil { + return err + } + + prompt = promptui.Prompt{ + Label: "Database name", + Validate: validateNonEmpty, + Default: c.Database.Database, + } + c.Database.Database, err = prompt.Run() + if err != nil { + return err + } + + prompt = promptui.Prompt{ + Label: "Host", + Validate: validateNonEmpty, + Default: c.Database.Host, + } + c.Database.Host, err = prompt.Run() + if err != nil { + return err + } + + prompt = promptui.Prompt{ + Label: "Port", + Validate: validatePort, + Default: fmt.Sprintf("%d", c.Database.Port), + } + dbPort, err := prompt.Run() + if err != nil { + return err + } + c.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number + + fmt.Println() + title(" App setup ") + fmt.Println() + + selPrompt := promptui.Select{ + Label: "Site type", + Items: []string{"Single user", "Multiple users"}, + } + _, usersType, err := selPrompt.Run() + if err != nil { + return err + } + c.App.SingleUser = usersType == "Single user" + + siteNameLabel := "Instance name" + if c.App.SingleUser { + siteNameLabel = "Blog name" + } + prompt = promptui.Prompt{ + Label: siteNameLabel, + Validate: validateNonEmpty, + Default: c.App.SiteName, + } + c.App.SiteName, err = prompt.Run() + if err != nil { + return err + } + + if !c.App.SingleUser { + selPrompt = promptui.Select{ + Label: "Registration", + Items: []string{"Open", "Closed"}, + } + _, regType, err := selPrompt.Run() + if err != nil { + return err + } + c.App.OpenRegistration = regType == "Open" + } + + selPrompt = promptui.Select{ + Label: "Federation", + Items: []string{"Enabled", "Disabled"}, + } + _, fedType, err := selPrompt.Run() + if err != nil { + return err + } + c.App.Federation = fedType == "Enabled" + + if c.App.Federation { + selPrompt = promptui.Select{ + Label: "Federation usage stats", + Items: []string{"Public", "Private"}, + } + _, fedStatsType, err := selPrompt.Run() + if err != nil { + return err + } + c.App.PublicStats = fedStatsType == "Public" + + selPrompt = promptui.Select{ + Label: "Instance metadata privacy", + Items: []string{"Public", "Private"}, + } + _, fedStatsType, err = selPrompt.Run() + if err != nil { + return err + } + c.App.Private = fedStatsType == "Private" + } + + return Save(c) +} diff --git a/config/validation.go b/config/validation.go new file mode 100644 index 0000000..cdbe101 --- /dev/null +++ b/config/validation.go @@ -0,0 +1,41 @@ +package config + +import ( + "fmt" + "regexp" + "strconv" +) + +var ( + domainReg = regexp.MustCompile("^https?://") +) + +const ( + minPort = 80 + maxPort = 1<<16 - 1 +) + +func validateDomain(i string) error { + if !domainReg.MatchString(i) { + return fmt.Errorf("Domain must start with http:// or https://") + } + return nil +} + +func validatePort(i string) error { + p, err := strconv.Atoi(i) + if err != nil { + return err + } + if p < minPort || p > maxPort { + return fmt.Errorf("Port must be a number %d - %d", minPort, maxPort) + } + return nil +} + +func validateNonEmpty(i string) error { + if i == "" { + return fmt.Errorf("Must not be empty") + } + return nil +}