diff --git a/cmd/gochan-installer/forms.go b/cmd/gochan-installer/forms.go index 49701af7..d3264571 100644 --- a/cmd/gochan-installer/forms.go +++ b/cmd/gochan-installer/forms.go @@ -1,6 +1,7 @@ package main import ( + "database/sql" "errors" "fmt" "io/fs" @@ -22,35 +23,34 @@ type pathsForm struct { WebRoot string `form:"webroot,required" method:"POST"` } -func (pf *pathsForm) validatePath(targetPath *string, desc string, expectDir bool) error { - p := *targetPath - if p == "" { - return fmt.Errorf("%s is required", desc) - } - p = path.Clean(p) - *targetPath = p - - fi, err := os.Stat(p) +func (pf *pathsForm) validateDirectory(dir string, createIfNotExist bool) error { + fi, err := os.Stat(dir) if errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("%s %s does not exist", desc, p) + if createIfNotExist { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + return nil + } + return fmt.Errorf("directory %s does not exist", dir) } if errors.Is(err, fs.ErrPermission) { - return fmt.Errorf("permission denied to %s", p) + return fmt.Errorf("permission denied to access directory %s", dir) } - if expectDir && !fi.Mode().IsDir() { - return fmt.Errorf("%s exists at %s but is not a directory", desc, p) + if !fi.IsDir() { + return fmt.Errorf("%s exists at %s but is not a directory", dir, dir) } return nil } -func (pf *pathsForm) validate(warnEv, errEv *zerolog.Event) (err error) { +func (pf *pathsForm) validate(warnEv, _ *zerolog.Event) (err error) { + pf.TemplateDir = path.Clean(pf.TemplateDir) pf.DocumentRoot = path.Clean(pf.DocumentRoot) pf.LogDir = path.Clean(pf.LogDir) - - if pf.ConfigPath == "" { - warnEv.Msg("Required config output directory not set") - return errors.New("config output directory is required") + if pf.WebRoot == "" { + pf.WebRoot = "/" } + pf.WebRoot = path.Clean(pf.WebRoot) pf.ConfigPath = path.Clean(pf.ConfigPath) validConfigPaths := cfgPaths @@ -66,41 +66,36 @@ func (pf *pathsForm) validate(warnEv, errEv *zerolog.Event) (err error) { return fmt.Errorf("config output path %s is not allowed. Valid values are %s", strings.Join(cfgPaths, ", "), pf.ConfigPath) } - configDir := path.Dir(pf.ConfigPath) - - if err = pf.validatePath(&configDir, "config output directory", true); err != nil { + if err = pf.validateDirectory(path.Dir(pf.ConfigPath), true); err != nil { warnEv.Err(err). - Msg("Invalid config output directory") + Msg("Invalid config output path") return err } - if err = pf.validatePath(&pf.TemplateDir, "template directory", true); err != nil { + if err = pf.validateDirectory(pf.TemplateDir, true); err != nil { warnEv.Err(err).Str("templateDir", pf.TemplateDir). Msg("Invalid template directory") return err } - if err = pf.validatePath(&pf.DocumentRoot, "document root", true); err != nil { + if err = pf.validateDirectory(pf.DocumentRoot, true); err != nil { warnEv.Err(err).Str("documentRoot", pf.DocumentRoot). Msg("Invalid document root") return err } - if err = pf.validatePath(&pf.LogDir, "log directory", true); err != nil { + if err = pf.validateDirectory(pf.LogDir, true); err != nil { warnEv.Err(err).Str("logDir", pf.LogDir). Msg("Invalid log directory") return err } - if pf.WebRoot == "" { - pf.WebRoot = "/" - } return nil } type dbForm struct { DBtype string `form:"dbtype,required,notempty" method:"POST"` - DBhost string `form:"dbhost,required,notempty" method:"POST"` + DBhost string `form:"dbhost,notempty,default=localhost" method:"POST"` DBname string `form:"dbname,required,notempty" method:"POST"` - DBuser string `form:"dbuser,required,notempty" method:"POST"` + DBuser string `form:"dbuser" method:"POST"` DBpass string `form:"dbpass" method:"POST"` DBprefix string `form:"dbprefix" method:"POST"` @@ -114,6 +109,10 @@ func (dbf *dbForm) validate() (status dbStatus, err error) { if dbf.DBprefix == "" { return dbStatusNoPrefix, nil } + supportedDrivers := sql.Drivers() + if !slices.Contains(supportedDrivers, dbf.DBtype) { + return dbStatusUnknown, fmt.Errorf("unsupported database type %s, supported types are %s", dbf.DBtype, strings.Join(supportedDrivers, ", ")) + } if dbf.TimeoutSeconds <= 0 { return dbStatusUnknown, errors.New("request timeout must be greater than 0") } diff --git a/cmd/gochan-installer/handler.go b/cmd/gochan-installer/handler.go new file mode 100644 index 00000000..3097b138 --- /dev/null +++ b/cmd/gochan-installer/handler.go @@ -0,0 +1,277 @@ +package main + +import ( + "bytes" + _ "embed" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + + "github.com/Eggbertx/go-forms" + "github.com/gochan-org/gochan/pkg/building" + "github.com/gochan-org/gochan/pkg/config" + "github.com/gochan-org/gochan/pkg/gcsql" + "github.com/gochan-org/gochan/pkg/gcutil" + "github.com/gochan-org/gochan/pkg/server" + "github.com/gochan-org/gochan/pkg/server/serverutil" + "github.com/uptrace/bunrouter" +) + +const ( + dbStatusUnknown dbStatus = iota + dbStatusClean + dbStatusNoPrefix + dbStatusTablesExist +) + +var ( + //go:embed license.txt + licenseTxt string + + configPath string + currentDBStatus = dbStatusUnknown + adminUser *gcsql.Staff + cfgPaths = slices.Clone(config.StandardConfigSearchPaths) +) + +type dbStatus int + +func (dbs dbStatus) String() string { + switch dbs { + case dbStatusClean: + return "The database does not appear to contain any Gochan tables. It will be provisioned in the next step." + case dbStatusNoPrefix: + return "Since no prefix was specified, the installer will attempt to provision the database in the next step." + case dbStatusTablesExist: + return fmt.Sprintf("The database appears to contain Gochan tables with the prefix %s. The next step (database provisioning) may return errors", config.GetSystemCriticalConfig().DBprefix) + default: + return "unknown" + } +} + +func installHandler(writer http.ResponseWriter, req bunrouter.Request) (err error) { + infoEv, warnEv, errEv := gcutil.LogRequest(req.Request) + var buf bytes.Buffer + httpStatus := http.StatusOK + page := req.Param("page") + + defer func() { + gcutil.LogDiscard(infoEv, warnEv, errEv) + writer.WriteHeader(httpStatus) + if err == nil { + writer.Write(buf.Bytes()) + } else { + server.ServeError(writer, err, false, nil) + } + if page == "save" { + installServerStopper <- 1 + } + }() + var pageTitle string + data := map[string]any{ + "page": page, + "config": cfg, + "nextButton": "Next", + } + + refererResult, err := serverutil.CheckReferer(req.Request) + if err != nil { + httpStatus = http.StatusBadRequest + warnEv.Err(err).Caller(). + Str("referer", req.Referer()). + Msg("Failed to check referer") + return + } + + if refererResult == serverutil.NoReferer && req.Method == http.MethodPost { + httpStatus = http.StatusBadRequest + warnEv.Caller().Msg("No referer present for POST request") + return + } else if refererResult == serverutil.ExternalReferer { + httpStatus = http.StatusForbidden + warnEv.Caller(). + Str("referer", req.Referer()). + Msg("Request came from an external referer (not allowed during installation)") + return errors.New("your post looks like spam") + } + + switch page { + case "": + pageTitle = "Gochan Installation" + data["nextPage"] = "license" + case "license": + pageTitle = "License" + data["license"] = licenseTxt + data["nextPage"] = "paths" + case "paths": + pageTitle = "Paths" + data["cfgPaths"] = cfgPaths + data["nextPage"] = "database" + case "database": + var pathFormData pathsForm + if err = forms.FillStructFromForm(req.Request, &pathFormData); err != nil { + httpStatus = http.StatusBadRequest + errEv.Err(err).Msg("Failed to fill form data") + return + } + if err = pathFormData.validate(warnEv, errEv); err != nil { + httpStatus = http.StatusBadRequest + return + } + configPath = pathFormData.ConfigPath + cfg.DocumentRoot = pathFormData.DocumentRoot + cfg.LogDir = pathFormData.LogDir + cfg.TemplateDir = pathFormData.TemplateDir + cfg.WebRoot = pathFormData.WebRoot + config.SetSystemCriticalConfig(&cfg.SystemCriticalConfig) + + pageTitle = "Database Setup" + data["nextPage"] = "dbtest" + data["nextButton"] = "Test Connection" + case "dbtest": + pageTitle = "Database Test" + var dbFormData dbForm + if err = forms.FillStructFromForm(req.Request, &dbFormData); err != nil { + httpStatus = http.StatusBadRequest + errEv.Err(err).Msg("Failed to fill form data") + return + } + if currentDBStatus, err = dbFormData.validate(); err != nil { + httpStatus = http.StatusBadRequest + errEv.Err(err).Msg("Database test failed") + return err + } + + data["testResult"] = currentDBStatus.String() + cfg.DBtype = dbFormData.DBtype + cfg.DBhost = dbFormData.DBhost + cfg.DBname = dbFormData.DBname + cfg.DBusername = dbFormData.DBuser + cfg.DBpassword = dbFormData.DBpass + cfg.DBprefix = dbFormData.DBprefix + config.SetSystemCriticalConfig(&cfg.SystemCriticalConfig) + + data["nextPage"] = "staff" + case "staff": + pageTitle = "Create Administrator Account" + + if adminUser != nil { + data["nextButton"] = "Next" + data["alreadyCreated"] = true + break + } + + // staff not created yet, show new admin form + if currentDBStatus == dbStatusUnknown { + httpStatus = http.StatusBadRequest + errEv.Msg("Database status is unknown, cannot proceed with provisioning") + return errors.New("database status is unknown, cannot proceed with provisioning") + } + + err := gcsql.CheckAndInitializeDatabase(cfg.DBtype, false) + if err != nil { + errEv.Err(err).Msg("Failed to initialize database") + httpStatus = http.StatusInternalServerError + return err + } + + if err = gcsql.ResetViews(); err != nil { + errEv.Err(err).Msg("Failed to reset database views") + httpStatus = http.StatusInternalServerError + return err + } + + data["nextPage"] = "pre-save" + case "pre-save": + pageTitle = "Configuration Confirmation" + + var staffFormData staffForm + if err = forms.FillStructFromForm(req.Request, &staffFormData); err != nil { + httpStatus = http.StatusBadRequest + errEv.Err(err).Msg("Failed to fill form data") + return + } + if err = staffFormData.validate(); err != nil { + httpStatus = http.StatusBadRequest + warnEv.Err(err).Msg("Invalid staff form data") + return + } + + adminUser, err = gcsql.NewStaff(staffFormData.Username, staffFormData.Password, 3) + if err != nil { + httpStatus = http.StatusInternalServerError + errEv.Err(err).Msg("Failed to create administrator account") + return err + } + + if configPath == "" { + httpStatus = http.StatusBadRequest + errEv.Msg("Configuration path is not set") + return errors.New("configuration path is not set") + } + + var jsonBuf bytes.Buffer + encoder := json.NewEncoder(&jsonBuf) + encoder.SetIndent("", " ") + if err = encoder.Encode(cfg); err != nil { + httpStatus = http.StatusInternalServerError + errEv.Err(err).Msg("Failed to encode configuration to JSON") + return err + } + data["configJSON"] = jsonBuf.String() + data["configPath"] = configPath + data["nextButton"] = "Save" + data["nextPage"] = "save" + case "save": + pageTitle = "Save Configuration" + if configPath == "" { + httpStatus = http.StatusBadRequest + errEv.Msg("Configuration path is not set") + return errors.New("configuration path is not set") + } + + if err = config.WriteConfig(configPath); err != nil { + httpStatus = http.StatusInternalServerError + errEv.Err(err).Msg("Failed to write configuration") + return err + } + + if err = building.BuildFrontPage(); err != nil { + httpStatus = http.StatusInternalServerError + errEv.Err(err).Msg("Failed to build front page") + return err + } + + if err = building.BuildBoards(true); err != nil { + httpStatus = http.StatusInternalServerError + errEv.Err(err).Msg("Failed to build boards") + return err + } + + infoEv.Str("configPath", configPath).Msg("Configuration written successfully") + data["nextPage"] = "" + default: + httpStatus = http.StatusNotFound + pageTitle = "Page Not Found" + } + + if err = building.BuildPageHeader(&buf, pageTitle, "", data); err != nil { + httpStatus = http.StatusInternalServerError + errEv.Err(err).Msg("Failed to build page header") + return + } + if err = serverutil.MinifyTemplate(installTemplate, data, &buf, "text/html"); err != nil { + httpStatus = http.StatusInternalServerError + errEv.Err(err).Msg("Failed to minify template") + return + } + if err = building.BuildPageFooter(&buf); err != nil { + httpStatus = http.StatusInternalServerError + errEv.Err(err).Msg("Failed to build page footer") + return + } + + return nil +} diff --git a/cmd/gochan-installer/main.go b/cmd/gochan-installer/main.go index 30baa65b..5d780977 100644 --- a/cmd/gochan-installer/main.go +++ b/cmd/gochan-installer/main.go @@ -1,75 +1,38 @@ package main import ( - "bytes" - "encoding/json" - "errors" "flag" - "fmt" "html/template" "net" "net/http" "net/http/fcgi" "os" "path" + "slices" "strconv" "strings" "time" - _ "embed" - - "github.com/Eggbertx/go-forms" - "github.com/gochan-org/gochan/pkg/building" "github.com/gochan-org/gochan/pkg/config" - "github.com/gochan-org/gochan/pkg/gcsql" _ "github.com/gochan-org/gochan/pkg/gcsql/initsql" "github.com/gochan-org/gochan/pkg/gctemplates" "github.com/gochan-org/gochan/pkg/gcutil" _ "github.com/gochan-org/gochan/pkg/posting/uploads/inituploads" "github.com/gochan-org/gochan/pkg/server" - "github.com/gochan-org/gochan/pkg/server/serverutil" - "github.com/uptrace/bunrouter" ) var ( - //go:embed license.txt - licenseTxt string - installTemplate *template.Template installServerStopper chan int - configPath string - - currentDBStatus = dbStatusUnknown - adminUser *gcsql.Staff - cfg *config.GochanConfig = config.GetDefaultConfig() + cfg *config.GochanConfig = config.GetDefaultConfig() ) -const ( - dbStatusUnknown dbStatus = iota - dbStatusClean - dbStatusNoPrefix - dbStatusTablesExist -) - -type dbStatus int - -func (dbs dbStatus) String() string { - switch dbs { - case dbStatusClean: - return "The database does not appear to contain any Gochan tables. It will be provisioned in the next step." - case dbStatusNoPrefix: - return "Since no prefix was specified, the installer will attempt to provision the database in the next step." - case dbStatusTablesExist: - return fmt.Sprintf("The database appears to contain Gochan tables with the prefix %s. The next step (database provisioning) may return errors", config.GetSystemCriticalConfig().DBprefix) - default: - return "unknown" - } -} - func main() { var err error + slices.Reverse(cfgPaths) + fatalEv := gcutil.LogFatal() infoEv := gcutil.LogInfo() defer gcutil.LogDiscard(infoEv, fatalEv) @@ -176,228 +139,3 @@ func initTemplates() error { return nil } - -func installHandler(writer http.ResponseWriter, req bunrouter.Request) (err error) { - infoEv, warnEv, errEv := gcutil.LogRequest(req.Request) - var buf bytes.Buffer - httpStatus := http.StatusOK - page := req.Param("page") - - defer func() { - gcutil.LogDiscard(infoEv, warnEv, errEv) - writer.WriteHeader(httpStatus) - if err == nil { - writer.Write(buf.Bytes()) - } else { - server.ServeError(writer, err, false, nil) - } - if page == "save" { - installServerStopper <- 1 - } - }() - var pageTitle string - data := map[string]any{ - "page": page, - "config": cfg, - "nextButton": "Next", - } - - refererResult, err := serverutil.CheckReferer(req.Request) - if err != nil { - httpStatus = http.StatusBadRequest - warnEv.Err(err).Caller(). - Str("referer", req.Referer()). - Msg("Failed to check referer") - return - } - - if refererResult == serverutil.NoReferer && req.Method == http.MethodPost { - httpStatus = http.StatusBadRequest - warnEv.Caller().Msg("No referer present for POST request") - return - } else if refererResult == serverutil.ExternalReferer { - httpStatus = http.StatusForbidden - warnEv.Caller(). - Str("referer", req.Referer()). - Msg("Request came from an external referer (not allowed during installation)") - return errors.New("your post looks like spam") - } - - switch page { - case "": - pageTitle = "Gochan Installation" - data["nextPage"] = "license" - case "license": - pageTitle = "License" - data["license"] = licenseTxt - data["nextPage"] = "paths" - case "paths": - pageTitle = "Paths" - data["cfgPaths"] = cfgPaths - data["nextPage"] = "database" - case "database": - var pathFormData pathsForm - if err = forms.FillStructFromForm(req.Request, &pathFormData); err != nil { - httpStatus = http.StatusBadRequest - errEv.Err(err).Msg("Failed to fill form data") - return - } - if err = pathFormData.validate(warnEv, errEv); err != nil { - httpStatus = http.StatusBadRequest - return - } - configPath = pathFormData.ConfigPath - cfg.DocumentRoot = pathFormData.DocumentRoot - cfg.LogDir = pathFormData.LogDir - cfg.TemplateDir = pathFormData.TemplateDir - cfg.WebRoot = pathFormData.WebRoot - config.SetSystemCriticalConfig(&cfg.SystemCriticalConfig) - - pageTitle = "Database Setup" - data["nextPage"] = "dbtest" - data["nextButton"] = "Test Connection" - case "dbtest": - pageTitle = "Database Test" - var dbFormData dbForm - if err = forms.FillStructFromForm(req.Request, &dbFormData); err != nil { - httpStatus = http.StatusBadRequest - errEv.Err(err).Msg("Failed to fill form data") - return - } - if currentDBStatus, err = dbFormData.validate(); err != nil { - httpStatus = http.StatusBadRequest - errEv.Err(err).Msg("Database test failed") - return err - } - - data["testResult"] = currentDBStatus.String() - cfg.DBtype = dbFormData.DBtype - cfg.DBhost = dbFormData.DBhost - cfg.DBname = dbFormData.DBname - cfg.DBusername = dbFormData.DBuser - cfg.DBpassword = dbFormData.DBpass - cfg.DBprefix = dbFormData.DBprefix - config.SetSystemCriticalConfig(&cfg.SystemCriticalConfig) - - data["nextPage"] = "staff" - case "staff": - pageTitle = "Create Administrator Account" - - if adminUser != nil { - data["nextButton"] = "Next" - data["alreadyCreated"] = true - break - } - - // staff not created yet, show new admin form - if currentDBStatus == dbStatusUnknown { - httpStatus = http.StatusBadRequest - errEv.Msg("Database status is unknown, cannot proceed with provisioning") - return errors.New("database status is unknown, cannot proceed with provisioning") - } - - err := gcsql.CheckAndInitializeDatabase(cfg.DBtype, false) - if err != nil { - errEv.Err(err).Msg("Failed to initialize database") - httpStatus = http.StatusInternalServerError - return err - } - - if err = gcsql.ResetViews(); err != nil { - errEv.Err(err).Msg("Failed to reset database views") - httpStatus = http.StatusInternalServerError - return err - } - - data["nextPage"] = "pre-save" - case "pre-save": - pageTitle = "Configuration Confirmation" - - var staffFormData staffForm - if err = forms.FillStructFromForm(req.Request, &staffFormData); err != nil { - httpStatus = http.StatusBadRequest - errEv.Err(err).Msg("Failed to fill form data") - return - } - if err = staffFormData.validate(); err != nil { - httpStatus = http.StatusBadRequest - warnEv.Err(err).Msg("Invalid staff form data") - return - } - - adminUser, err = gcsql.NewStaff(staffFormData.Username, staffFormData.Password, 3) - if err != nil { - httpStatus = http.StatusInternalServerError - errEv.Err(err).Msg("Failed to create administrator account") - return err - } - - if configPath == "" { - httpStatus = http.StatusBadRequest - errEv.Msg("Configuration path is not set") - return errors.New("configuration path is not set") - } - - var jsonBuf bytes.Buffer - encoder := json.NewEncoder(&jsonBuf) - encoder.SetIndent("", " ") - if err = encoder.Encode(cfg); err != nil { - httpStatus = http.StatusInternalServerError - errEv.Err(err).Msg("Failed to encode configuration to JSON") - return err - } - data["configJSON"] = jsonBuf.String() - data["configPath"] = configPath - data["nextButton"] = "Save" - data["nextPage"] = "save" - case "save": - pageTitle = "Save Configuration" - if configPath == "" { - httpStatus = http.StatusBadRequest - errEv.Msg("Configuration path is not set") - return errors.New("configuration path is not set") - } - - if err = config.WriteConfig(configPath); err != nil { - httpStatus = http.StatusInternalServerError - errEv.Err(err).Msg("Failed to write configuration") - return err - } - - if err = building.BuildFrontPage(); err != nil { - httpStatus = http.StatusInternalServerError - errEv.Err(err).Msg("Failed to build front page") - return err - } - - if err = building.BuildBoards(true); err != nil { - httpStatus = http.StatusInternalServerError - errEv.Err(err).Msg("Failed to build boards") - return err - } - - infoEv.Str("configPath", configPath).Msg("Configuration written successfully") - data["nextPage"] = "" - default: - httpStatus = http.StatusNotFound - pageTitle = "Page Not Found" - } - - if err = building.BuildPageHeader(&buf, pageTitle, "", data); err != nil { - httpStatus = http.StatusInternalServerError - errEv.Err(err).Msg("Failed to build page header") - return - } - if err = serverutil.MinifyTemplate(installTemplate, data, &buf, "text/html"); err != nil { - httpStatus = http.StatusInternalServerError - errEv.Err(err).Msg("Failed to minify template") - return - } - if err = building.BuildPageFooter(&buf); err != nil { - httpStatus = http.StatusInternalServerError - errEv.Err(err).Msg("Failed to build page footer") - return - } - - return nil -} diff --git a/cmd/gochan-installer/paths_darwin.go b/cmd/gochan-installer/paths_darwin.go deleted file mode 100644 index 70ed567c..00000000 --- a/cmd/gochan-installer/paths_darwin.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build darwin - -package main - -import "github.com/gochan-org/gochan/pkg/config" - -var ( - cfgPaths = config.StandardConfigSearchPaths -) diff --git a/cmd/gochan-installer/paths_unix.go b/cmd/gochan-installer/paths_unix.go deleted file mode 100644 index dada8401..00000000 --- a/cmd/gochan-installer/paths_unix.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build !darwin && !windows - -package main - -import ( - "slices" - - "github.com/gochan-org/gochan/pkg/config" -) - -var ( - cfgPaths = slices.DeleteFunc(config.StandardConfigSearchPaths, func(s string) bool { - return s == "/opt/homebrew/etc/gochan/gochan.json" - }) // Exclude Homebrew path on non-macOS systems - -) - -func init() { - slices.Reverse(cfgPaths) // /etc/gochan/gochan.json should be first on *nix systems -} diff --git a/cmd/gochan-installer/paths_windows.go b/cmd/gochan-installer/paths_windows.go deleted file mode 100644 index 28a4a948..00000000 --- a/cmd/gochan-installer/paths_windows.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build windows - -package main - -var ( - cfgPaths []string = nil -)