1
0
Fork 0
mirror of https://github.com/Eggbertx/gochan.git synced 2025-08-02 02:36:24 -07:00

consolidate path validation, remove platform-specific files from gochan-installer (since that is now handled by the config package)

This commit is contained in:
Eggbertx 2025-07-11 15:54:03 -07:00
parent 564d659e02
commit 062cafc4f2
6 changed files with 310 additions and 332 deletions

View file

@ -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")
}

View file

@ -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
}

View file

@ -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
}

View file

@ -1,9 +0,0 @@
//go:build darwin
package main
import "github.com/gochan-org/gochan/pkg/config"
var (
cfgPaths = config.StandardConfigSearchPaths
)

View file

@ -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
}

View file

@ -1,7 +0,0 @@
//go:build windows
package main
var (
cfgPaths []string = nil
)