diff --git a/app.go b/app.go index 8182f54..e9ff9b1 100644 --- a/app.go +++ b/app.go @@ -1,244 +1,269 @@ package writefreely import ( "database/sql" "flag" "fmt" _ "github.com/go-sql-driver/mysql" "html/template" "net/http" "os" "os/signal" "regexp" "syscall" + "time" "github.com/gorilla/mux" "github.com/gorilla/schema" "github.com/gorilla/sessions" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" ) const ( staticDir = "static/" assumedTitleLen = 80 postsPerPage = 10 serverSoftware = "WriteFreely" softwareURL = "https://writefreely.org" softwareVer = "0.1" ) var ( debugging bool // 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 ) type app struct { router *mux.Router db *datastore cfg *config.Config keys *keychain sessionStore *sessions.CookieStore formDecoder *schema.Decoder } // 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) } 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 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 shttp = http.NewServeMux() var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$") func Serve() { debugPtr := flag.Bool("debug", false, "Enables debug logging.") createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits") doConfig := flag.Bool("config", false, "Run the configuration process") flag.Parse() debugging = *debugPtr + app := &app{} + 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() + d, err := config.Configure() if err != nil { log.Error("Unable to configure: %v", err) os.Exit(1) } + if d != nil { + app.cfg = d.Config + connectToDatabase(app) + defer shutdown(app) + + 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) } 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, - } + app.cfg = cfg hostName = cfg.App.Host isSingleUser = cfg.App.SingleUser app.cfg.Server.Dev = *debugPtr initTemplates() // 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) 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.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} + connectToDatabase(app) defer shutdown(app) - app.db.SetMaxOpenConns(50) 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 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 connectToDatabase(app *app) { + 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("%s", err) + os.Exit(1) + } + app.db = &datastore{db} + app.db.SetMaxOpenConns(50) +} + func shutdown(app *app) { log.Info("Closing database connection...") app.db.Close() } diff --git a/config/data.go b/config/data.go new file mode 100644 index 0000000..d9694f4 --- /dev/null +++ b/config/data.go @@ -0,0 +1,6 @@ +package config + +type UserCreation struct { + Username string + HashedPass []byte +} diff --git a/config/setup.go b/config/setup.go index f1478a9..3729f8b 100644 --- a/config/setup.go +++ b/config/setup.go @@ -1,219 +1,255 @@ package config import ( "fmt" "github.com/fatih/color" "github.com/manifoldco/promptui" "github.com/mitchellh/go-wordwrap" + "github.com/writeas/web-core/auth" "strconv" ) -func Configure() error { - c, err := Load() +type SetupData struct { + User *UserCreation + Config *Config +} + +func Configure() (*SetupData, error) { + data := &SetupData{} + var err error + + data.Config, err = Load() var action string if err != nil { fmt.Println("No configuration yet. Creating new.") - c = New() + data.Config = New() action = "generate" } else { fmt.Println("Configuration loaded.") action = "update" } 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 "+action+" 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() tmpls := &promptui.PromptTemplates{ Success: "{{ . | bold | faint }}: ", } selTmpls := &promptui.SelectTemplates{ Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`), } prompt := promptui.Prompt{ Templates: tmpls, Label: "Local port", Validate: validatePort, - Default: fmt.Sprintf("%d", c.Server.Port), + Default: fmt.Sprintf("%d", data.Config.Server.Port), } port, err := prompt.Run() if err != nil { - return err + return data, err } - c.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number + data.Config.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number fmt.Println() title(" Database setup ") fmt.Println() prompt = promptui.Prompt{ Templates: tmpls, Label: "Username", Validate: validateNonEmpty, - Default: c.Database.User, + Default: data.Config.Database.User, } - c.Database.User, err = prompt.Run() + data.Config.Database.User, err = prompt.Run() if err != nil { - return err + return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Password", Validate: validateNonEmpty, - Default: c.Database.Password, + Default: data.Config.Database.Password, Mask: '*', } - c.Database.Password, err = prompt.Run() + data.Config.Database.Password, err = prompt.Run() if err != nil { - return err + return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Database name", Validate: validateNonEmpty, - Default: c.Database.Database, + Default: data.Config.Database.Database, } - c.Database.Database, err = prompt.Run() + data.Config.Database.Database, err = prompt.Run() if err != nil { - return err + return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Host", Validate: validateNonEmpty, - Default: c.Database.Host, + Default: data.Config.Database.Host, } - c.Database.Host, err = prompt.Run() + data.Config.Database.Host, err = prompt.Run() if err != nil { - return err + return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Port", Validate: validatePort, - Default: fmt.Sprintf("%d", c.Database.Port), + Default: fmt.Sprintf("%d", data.Config.Database.Port), } dbPort, err := prompt.Run() if err != nil { - return err + return data, err } - c.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number + data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number fmt.Println() title(" App setup ") fmt.Println() selPrompt := promptui.Select{ Templates: selTmpls, Label: "Site type", Items: []string{"Single user blog", "Multi-user instance"}, } _, usersType, err := selPrompt.Run() if err != nil { - return err + return data, err + } + data.Config.App.SingleUser = usersType == "Single user blog" + + if data.Config.App.SingleUser { + data.User = &UserCreation{} + + // 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 + } + + // prompt for password + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Admin password", + Validate: validateNonEmpty, + } + newUserPass, err := prompt.Run() + if err != nil { + return data, err + } + + data.User.HashedPass, err = auth.HashPass([]byte(newUserPass)) + if err != nil { + return data, err + } } - c.App.SingleUser = usersType == "Single user" - // TODO: if c.App.SingleUser { - // prompt for username - // prompt for password - // create blog siteNameLabel := "Instance name" - if c.App.SingleUser { + if data.Config.App.SingleUser { siteNameLabel = "Blog name" } prompt = promptui.Prompt{ Templates: tmpls, Label: siteNameLabel, Validate: validateNonEmpty, - Default: c.App.SiteName, + Default: data.Config.App.SiteName, } - c.App.SiteName, err = prompt.Run() + data.Config.App.SiteName, err = prompt.Run() if err != nil { - return err + return data, err } prompt = promptui.Prompt{ Templates: tmpls, Label: "Public URL", Validate: validateDomain, - Default: c.App.Host, + Default: data.Config.App.Host, } - c.App.Host, err = prompt.Run() + data.Config.App.Host, err = prompt.Run() if err != nil { - return err + return data, err } - if !c.App.SingleUser { + if !data.Config.App.SingleUser { selPrompt = promptui.Select{ Templates: selTmpls, Label: "Registration", Items: []string{"Open", "Closed"}, } _, regType, err := selPrompt.Run() if err != nil { - return err + return data, err } - c.App.OpenRegistration = regType == "Open" + data.Config.App.OpenRegistration = regType == "Open" prompt = promptui.Prompt{ Templates: tmpls, Label: "Max blogs per user", - Default: fmt.Sprintf("%d", c.App.MaxBlogs), + Default: fmt.Sprintf("%d", data.Config.App.MaxBlogs), } maxBlogs, err := prompt.Run() if err != nil { - return err + return data, err } - c.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 err + return data, err } - c.App.Federation = fedType == "Enabled" + data.Config.App.Federation = fedType == "Enabled" - if c.App.Federation { + 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 err + return data, err } - c.App.PublicStats = fedStatsType == "Public" + 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 err + return data, err } - c.App.Private = fedStatsType == "Private" + data.Config.App.Private = fedStatsType == "Private" } - return Save(c) + return data, Save(data.Config) }