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

Start setting up gochan-installer for providing a web interface for setting up configuration

This commit is contained in:
Eggbertx 2025-05-15 14:13:15 -07:00
parent fbee82edee
commit 772bd265f9
10 changed files with 302 additions and 68 deletions

1
.gitignore vendored
View file

@ -8,6 +8,7 @@
.vagrant/ .vagrant/
*.log *.log
/templates/override /templates/override
/cmd/*/license.txt
# Go output # Go output
/gochan* /gochan*

View file

@ -52,10 +52,12 @@ gochan_version = "unknown"
gcos = "" gcos = ""
gcos_name = "" # used for release, since macOS GOOS is "darwin" gcos_name = "" # used for release, since macOS GOOS is "darwin"
exe = "" exe = ""
gochan_bin = "" gochan_bin = "gochan"
gochan_exe = "" gochan_exe = gochan_bin
migration_bin = "" installer_bin = "gochan-installer"
migration_exe = "" installer_exe = installer_bin
migration_bin = "gochan-migration"
migration_exe = migration_bin
def path_info(loc): def path_info(loc):
i = PATH_UNKNOWN i = PATH_UNKNOWN
@ -186,9 +188,8 @@ def set_vars(goos=""):
global gcos global gcos
global gcos_name # used for release, since macOS GOOS is "darwin" global gcos_name # used for release, since macOS GOOS is "darwin"
global exe global exe
global gochan_bin
global gochan_exe global gochan_exe
global migration_bin global installer_exe
global migration_exe global migration_exe
if goos != "": if goos != "":
@ -203,9 +204,8 @@ def set_vars(goos=""):
if gcos_name == "darwin": if gcos_name == "darwin":
gcos_name = "macos" gcos_name = "macos"
gochan_bin = "gochan"
gochan_exe = gochan_bin + exe gochan_exe = gochan_bin + exe
migration_bin = "gochan-migration" installer_exe = installer_bin + exe
migration_exe = migration_bin + exe migration_exe = migration_bin + exe
@ -255,6 +255,14 @@ def build(debugging=False, plugin_path="", static_templates=False):
sys.exit(1) sys.exit(1)
print("Built gochan successfully") print("Built gochan successfully")
copy("LICENSE", "cmd/gochan-installer/license.txt")
gochan_installer_build_cmd = build_cmd_base + ["-o", installer_exe, "./cmd/gochan-installer"]
status = run_cmd(gochan_installer_build_cmd, realtime=True, print_command=True)[1]
if status != 0:
print("Failed building gochan-installer, see command output for details")
sys.exit(1)
print("Built gochan-installer successfully")
gochan_migrate_build_cmd = build_cmd_base + ["-o", migration_exe, "./cmd/gochan-migration"] gochan_migrate_build_cmd = build_cmd_base + ["-o", migration_exe, "./cmd/gochan-migration"]
status = run_cmd(gochan_migrate_build_cmd, realtime=True, print_command=True)[1] status = run_cmd(gochan_migrate_build_cmd, realtime=True, print_command=True)[1]
if status != 0: if status != 0:
@ -265,10 +273,24 @@ def build(debugging=False, plugin_path="", static_templates=False):
def clean(): def clean():
print("Cleaning up") print("Cleaning up")
del_files = ("gochan", "gochan.exe", "gochan-migration", "gochan-migration.exe", "releases/") del_files = ("gochan", "gochan.exe", "gochan-installer", "gochan-installer.exe", "gochan-migration", "gochan-migration.exe", "releases/", "cmd/gochan/license.txt")
for del_file in del_files: for del_file in del_files:
delete(del_file) delete(del_file)
def install_executable(src_file, dest_dir, symlinks=False):
if not path.exists(src_file):
build()
dest_file = path.join(dest_dir, src_file)
print(f"Installing {src_file}, to {dest_file}")
try:
if symlinks:
symlink(src_file, dest_file)
else:
copy(src_file, dest_file)
except shutil.SameFileError:
print(f"{src_file} and {dest_file} are the same file, skipping")
def install(prefix="/usr", document_root="/srv/gochan", symlinks=False, js_only=False, css_only=False, templates_only=False): def install(prefix="/usr", document_root="/srv/gochan", symlinks=False, js_only=False, css_only=False, templates_only=False):
if gcos == "windows": if gcos == "windows":
@ -339,40 +361,21 @@ def install(prefix="/usr", document_root="/srv/gochan", symlinks=False, js_only=
sys.exit(1) sys.exit(1)
if path.exists(gochan_exe) is False: bin_dest_dir = path.join(prefix, "bin")
build() install_executable(gochan_exe, bin_dest_dir, symlinks)
print("Installing", gochan_exe, "to", path.join(prefix, "bin", gochan_exe)) install_executable(installer_exe, bin_dest_dir, symlinks)
try: install_executable(migration_exe, bin_dest_dir, symlinks)
if symlinks:
symlink(gochan_exe, path.join(prefix, "bin", gochan_exe))
else:
copy(gochan_exe, path.join(prefix, "bin", gochan_exe))
except shutil.SameFileError:
print(gochan_exe, "and", path.join(prefix, "bin", gochan_exe), "are the same file, skipping")
if path.exists(migration_exe) is False:
build()
print("Installing ", migration_exe, "to", path.join(prefix, "bin", migration_exe))
try:
if symlinks:
symlink(migration_exe, path.join(prefix, "bin", migration_exe))
else:
copy(migration_exe, path.join(prefix, "bin", migration_exe))
except shutil.SameFileError:
print(migration_exe, "and", path.join(prefix, "bin", migration_exe), "are the same file, skipping")
print( print(
"gochan was successfully installed. If you haven't already, you should copy\n", "gochan was successfully installed. If you haven't already, you should copy\n",
"examples/configs/gochan.example.json to /etc/gochan/gochan.json (modify as needed)\n", "examples/configs/gochan.example.json to /etc/gochan/gochan.json (modify as needed)\n")
"You may also need to go to https://yourgochansite/manage/rebuildall to rebuild the javascript config")
if gcos == "linux": if gcos == "linux":
print( print(
"If your Linux distribution has systemd, you will also need to run the following commands:\n", "If your Linux distribution has systemd, you will also need to run the following commands:\n",
"cp examples/configs/gochan-[mysql|postgresql|sqlite3].service /lib/systemd/system/gochan.service\n", "cp examples/configs/gochan-[mysql|postgresql|sqlite3].service /lib/systemd/system/gochan.service\n",
"systemctl daemon-reload\n", "systemctl daemon-reload\n",
"systemctl enable gochan.service\n", "systemctl enable gochan.service\n",
"systemctl start gochan.service") "systemctl start gochan.service\n")
print("")
def js(watch=False): def js(watch=False):

View file

@ -0,0 +1,193 @@
package main
import (
"bytes"
"flag"
"html/template"
"net"
"net/http"
"net/http/fcgi"
"os"
"path"
"strconv"
"strings"
"time"
_ "embed"
"github.com/gochan-org/gochan/pkg/building"
"github.com/gochan-org/gochan/pkg/config"
_ "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
// workingConfig *config.GochanConfig = config.GetDefaultConfig()
)
func main() {
var err error
fatalEv := gcutil.LogFatal()
infoEv := gcutil.LogInfo()
defer gcutil.LogDiscard(infoEv, fatalEv)
workingConfig := config.GetDefaultConfig()
flag.StringVar(&workingConfig.SiteHost, "host", "127.0.0.1", "Host to listen on")
flag.IntVar(&workingConfig.Port, "port", 8080, "Port to bind to")
flag.BoolVar(&workingConfig.UseFastCGI, "fastcgi", false, "Use FastCGI instead of HTTP")
flag.StringVar(&workingConfig.WebRoot, "webroot", "/", "Web root path")
flag.StringVar(&workingConfig.TemplateDir, "template-dir", "", "Template directory")
flag.StringVar(&workingConfig.DocumentRoot, "document-root", "", "Document root directory")
flag.Parse()
if jsonPath := config.GetGochanJSONPath(); jsonPath != "" {
infoEv.Str("jsonPath", jsonPath).
Msg("Gochan already installed (found gochan.json)")
os.Exit(0)
}
config.SetSiteConfig(&workingConfig.SiteConfig)
config.SetSystemCriticalConfig(&workingConfig.SystemCriticalConfig)
if err = initTemplates(); err != nil {
os.Exit(1)
}
listenAddr := net.JoinHostPort(workingConfig.SiteHost, strconv.Itoa(workingConfig.Port))
router := server.GetRouter()
router.GET(path.Join(workingConfig.WebRoot, "/install"), installHandler)
router.POST(path.Join(workingConfig.WebRoot, "/install/:page"), installHandler)
// router.GET(path.Join(workingConfig.WebRoot, "/install/:page"), installHandler)
if workingConfig.DocumentRoot == "" {
fatalEv.Msg("-document-root command line argument is required")
os.Exit(1)
}
var listener net.Listener
installServerStopper = make(chan int)
go func() {
<-installServerStopper
if listener != nil {
if err = listener.Close(); err != nil {
fatalEv.Err(err).Caller().Msg("Failed to close listener")
}
}
}()
if workingConfig.UseFastCGI {
listener, err = net.Listen("tcp", listenAddr)
if err != nil {
fatalEv.Err(err).Caller().Msg("Failed listening on address/port")
}
if err = fcgi.Serve(listener, router); err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
fatalEv.Err(err).Caller().Msg("Failed to serve FastCGI")
}
} else {
httpServer := &http.Server{
Addr: listenAddr,
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
}
if err = httpServer.ListenAndServe(); err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
fatalEv.Err(err).Caller().Msg("Failed to serve HTTP")
}
}
if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
fatalEv.Err(err).Caller().Msg("Error initializing server")
}
}
func initTemplates() error {
var err error
fatalEv := gcutil.LogFatal()
defer fatalEv.Discard()
systemCriticalConfig := config.GetSystemCriticalConfig()
if systemCriticalConfig.TemplateDir == "" {
fatalEv.Msg("-template-dir command line argument is required")
return nil
}
if err = gctemplates.InitTemplates(); err != nil {
fatalEv.Err(err).Caller().Msg("Failed to initialize templates")
return err
}
installTemplateBytes, err := os.ReadFile(path.Join(systemCriticalConfig.TemplateDir, "install.html"))
if err != nil {
fatalEv.Err(err).Caller().Msg("Failed to read install template")
}
if installTemplate, err = gctemplates.ParseTemplate("install.html", string(installTemplateBytes)); err != nil {
fatalEv.Err(err).Caller().Msg("Failed to parse install template")
return err
}
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
defer func() {
gcutil.LogDiscard(infoEv, warnEv, errEv)
writer.WriteHeader(httpStatus)
if err == nil {
writer.Write(buf.Bytes())
} else {
server.ServeError(writer, err, false, nil)
}
}()
var pageTitle string
page := req.Param("page")
data := map[string]any{
"page": page,
}
switch page {
case "":
pageTitle = "Gochan Installation"
case "license":
pageTitle = "License"
data["license"] = licenseTxt
case "database":
pageTitle = "Database Setup"
case "stop":
writer.Write([]byte("Stopping server..."))
installServerStopper <- 1 // Stop the server
return nil
}
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

@ -24,6 +24,11 @@ func initServer() {
var err error var err error
systemCritical := config.GetSystemCriticalConfig() systemCritical := config.GetSystemCriticalConfig()
listenAddr := net.JoinHostPort(systemCritical.ListenAddress, strconv.Itoa(systemCritical.Port)) listenAddr := net.JoinHostPort(systemCritical.ListenAddress, strconv.Itoa(systemCritical.Port))
fatalEv := gcutil.LogFatal().
Str("listenAddress", systemCritical.ListenAddress).
Bool("useFastCGI", systemCritical.UseFastCGI).
Int("port", systemCritical.Port)
defer fatalEv.Discard()
router := server.GetRouter() router := server.GetRouter()
router.GET(config.WebPath("/captcha"), bunrouter.HTTPHandlerFunc(posting.ServeCaptcha)) router.GET(config.WebPath("/captcha"), bunrouter.HTTPHandlerFunc(posting.ServeCaptcha))
@ -41,9 +46,7 @@ func initServer() {
if systemCritical.UseFastCGI { if systemCritical.UseFastCGI {
listener, err = net.Listen("tcp", listenAddr) listener, err = net.Listen("tcp", listenAddr)
if err != nil { if err != nil {
gcutil.LogFatal().Err(err).Caller(). fatalEv.Err(err).Caller().Msg("Failed listening on address/port")
Str("ListenAddress", systemCritical.ListenAddress).
Int("Port", systemCritical.Port).Msg("Failed listening on address/port")
} }
err = fcgi.Serve(listener, router) err = fcgi.Serve(listener, router)
} else { } else {
@ -56,8 +59,7 @@ func initServer() {
} }
if err != nil { if err != nil {
gcutil.LogFatal().Err(err).Caller(). fatalEv.Err(err).Caller().Msg("Error initializing server")
Msg("Error initializing server")
} }
} }

View file

@ -41,6 +41,8 @@ var (
ErrNoMatchingEmbedHandler = errors.New("no matching handler for the embed URL") ErrNoMatchingEmbedHandler = errors.New("no matching handler for the embed URL")
) )
type InitialSetupStatus int
type GochanConfig struct { type GochanConfig struct {
SystemCriticalConfig SystemCriticalConfig
SiteConfig SiteConfig
@ -863,6 +865,14 @@ func (em *EmbedMatcher) HasThumbnail() bool {
return em.ThumbnailURLTemplate != "" return em.ThumbnailURLTemplate != ""
} }
func GetInitialSetupStatus() InitialSetupStatus {
return initialSetupStatus
}
func GetDefaultConfig() *GochanConfig {
return defaultGochanConfig
}
func WriteConfig() error { func WriteConfig() error {
return cfg.Write() return cfg.Write()
} }

View file

@ -44,21 +44,6 @@ func SetRandomSeed(seed string) {
cfg.RandomSeed = seed cfg.RandomSeed = seed
} }
// SetSystemCriticalConfig sets system critical configuration values in testing. It will panic if it is not run in a
// test environment
func SetSystemCriticalConfig(systemCritical *SystemCriticalConfig) {
testutil.PanicIfNotTest()
setDefaultCfgIfNotSet()
cfg.SystemCriticalConfig = *systemCritical
}
// SetSiteConfig sets the site configuration values in testing. It will panic if it is not run in a test environment
func SetSiteConfig(siteConfig *SiteConfig) {
testutil.PanicIfNotTest()
setDefaultCfgIfNotSet()
cfg.SiteConfig = *siteConfig
}
// SetBoardConfig applies the configuration to the given board. It will panic if it is not run in a test environment // SetBoardConfig applies the configuration to the given board. It will panic if it is not run in a test environment
func SetBoardConfig(board string, boardCfg *BoardConfig) error { func SetBoardConfig(board string, boardCfg *BoardConfig) error {
testutil.PanicIfNotTest() testutil.PanicIfNotTest()

View file

@ -17,13 +17,20 @@ import (
) )
const ( const (
InitialSetupStatusUnknown InitialSetupStatus = iota
InitialSetupNotStarted
InitialSetupComplete
DirFileMode fs.FileMode = 0775 DirFileMode fs.FileMode = 0775
NormalFileMode fs.FileMode = 0664 NormalFileMode fs.FileMode = 0664
) )
var ( var (
uid int uid int
gid int gid int
standardConfigSearchPaths = []string{"gochan.json", "/usr/local/etc/gochan/gochan.json", "/etc/gochan/gochan.json"}
initialSetupStatus InitialSetupStatus = InitialSetupStatusUnknown
) )
// MissingField represents a field missing from the configuration file // MissingField represents a field missing from the configuration file
@ -48,6 +55,15 @@ func (iv *InvalidValueError) Error() string {
return str return str
} }
// GetGochanJSONPath returns the location of gochan.json, searching in the working directory,
// /usr/local/etc/gochan, and /etc/gochan in that order. If it is not found, it returns an empty string.
func GetGochanJSONPath() string {
if cfgPath != "" {
return cfgPath
}
return gcutil.FindResource(standardConfigSearchPaths...)
}
// GetUser returns the IDs of the user and group gochan should be acting as // GetUser returns the IDs of the user and group gochan should be acting as
// when creating files. If they are 0, it is using the current user // when creating files. If they are 0, it is using the current user
func GetUser() (int, int) { func GetUser() (int, int) {
@ -72,7 +88,19 @@ func TakeOwnershipOfFile(f *os.File) error {
return f.Chown(uid, gid) return f.Chown(uid, gid)
} }
func loadConfig(searchPaths ...string) (err error) { // SetSystemCriticalConfig sets system critical configuration values
func SetSystemCriticalConfig(systemCritical *SystemCriticalConfig) {
setDefaultCfgIfNotSet()
cfg.SystemCriticalConfig = *systemCritical
}
// SetSiteConfig sets the site configuration values
func SetSiteConfig(siteConfig *SiteConfig) {
setDefaultCfgIfNotSet()
cfg.SiteConfig = *siteConfig
}
func loadConfig() (err error) {
cfg = defaultGochanConfig cfg = defaultGochanConfig
if testing.Testing() { if testing.Testing() {
// create a dummy config for testing if we're using go test // create a dummy config for testing if we're using go test
@ -97,7 +125,7 @@ func loadConfig(searchPaths ...string) (err error) {
} }
return return
} }
cfgPath = gcutil.FindResource(searchPaths...) cfgPath = gcutil.FindResource(standardConfigSearchPaths...)
if cfgPath == "" { if cfgPath == "" {
return errors.New("gochan.json not found") return errors.New("gochan.json not found")
} }
@ -121,11 +149,8 @@ func loadConfig(searchPaths ...string) (err error) {
// InitConfig loads and parses gochan.json on startup and verifies its contents // InitConfig loads and parses gochan.json on startup and verifies its contents
func InitConfig() (err error) { func InitConfig() (err error) {
var searchPaths []string initialSetupStatus = InitialSetupNotStarted
if !testing.Testing() { if err = loadConfig(); err != nil {
searchPaths = []string{"gochan.json", "/usr/local/etc/gochan/gochan.json", "/etc/gochan/gochan.json"}
}
if err = loadConfig(searchPaths...); err != nil {
return err return err
} }
@ -188,7 +213,7 @@ func InitConfig() (err error) {
_, zoneOffset := time.Now().Zone() _, zoneOffset := time.Now().Zone()
cfg.TimeZone = zoneOffset / 60 / 60 cfg.TimeZone = zoneOffset / 60 / 60
initialSetupStatus = InitialSetupComplete
return nil return nil
} }

View file

@ -109,7 +109,11 @@ func sectionBoardsTmplFunc(sectionID int) []gcsql.Board {
func init() { func init() {
events.RegisterEvent([]string{"reset-boards-sections"}, func(_ string, _ ...any) error { events.RegisterEvent([]string{"reset-boards-sections"}, func(_ string, _ ...any) error {
return gcsql.ResetBoardSectionArrays() if config.GetSQLConfig().DBhost != "" {
// Only reset if SQL is configured
return gcsql.ResetBoardSectionArrays()
}
return nil
}) })
gctemplates.AddTemplateFuncs(template.FuncMap{ gctemplates.AddTemplateFuncs(template.FuncMap{
"banMask": banMaskTmplFunc, "banMask": banMaskTmplFunc,

View file

@ -20,10 +20,13 @@ type templateRef interface {
// InitMinifier sets up the HTML/JS/JSON minifier if enabled in gochan.json // InitMinifier sets up the HTML/JS/JSON minifier if enabled in gochan.json
func InitMinifier() { func InitMinifier() {
siteConfig := config.GetSiteConfig() var siteConfig *config.SiteConfig
if !siteConfig.MinifyHTML && !siteConfig.MinifyJS { if config.GetInitialSetupStatus() == config.InitialSetupComplete {
return siteConfig = config.GetSiteConfig()
} else {
siteConfig = &config.GetDefaultConfig().SiteConfig
} }
minifier = minify.New() minifier = minify.New()
if siteConfig.MinifyHTML { if siteConfig.MinifyHTML {
minifier.AddFunc("text/html", minifyHTML.Minify) minifier.AddFunc("text/html", minifyHTML.Minify)
@ -40,6 +43,9 @@ func canMinify(mediaType string) (minify bool) {
InitMinifier() InitMinifier()
} }
}() }()
if config.GetInitialSetupStatus() != config.InitialSetupComplete {
return true
}
siteConfig := config.GetSiteConfig() siteConfig := config.GetSiteConfig()
if mediaType == "text/html" && siteConfig.MinifyHTML { if mediaType == "text/html" && siteConfig.MinifyHTML {
return true return true

5
templates/install.html Normal file
View file

@ -0,0 +1,5 @@
{{if eq "" .page}}
Welcome to the Gochan installer! This installer will help you configure Gochan to run on your system, including setting the necessary directories and connecting to the SQL database.
{{else if eq .page "license"}}
{{else}}
{{end}}