misaki/cmd/main.go
q 4da1cb6d84
All checks were successful
Build & push Docker image / build (push) Successful in 1m44s
wal database fix
2026-11-07 19:31:49 -05:00

324 lines
14 KiB
Go

package main
import (
"context"
"errors"
"flag"
"html/template"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"misaki/db"
"misaki/server"
"github.com/BurntSushi/toml"
)
type fileCfg struct {
Addr string `toml:"addr"`
DB string `toml:"db"`
InternalSecret string `toml:"internal_secret"`
AuthSecret string `toml:"auth_secret"`
BaseDomain string `toml:"base_domain"`
DefaultNote string `toml:"default_note"`
PublicURL string `toml:"public_url"`
TOTPKeyFile string `toml:"totp_key_file"`
SMTP smtpFileCfg `toml:"smtp"`
OIDC oidcFileCfg `toml:"oidc"`
Services map[string]svcFileCfg `toml:"services"`
}
type smtpFileCfg struct {
Host string `toml:"host"`
User string `toml:"user"`
Pass string `toml:"pass"`
From string `toml:"from"`
NotifyEmail string `toml:"notify_email"`
TLSName string `toml:"tls_name"`
}
type oidcFileCfg struct {
Issuer string `toml:"issuer"`
KeyFile string `toml:"key_file"`
Clients map[string]oidcClientFileCfg `toml:"clients"`
}
type oidcClientFileCfg struct {
SecretSHA256 string `toml:"secret_sha256"`
RedirectURIs []string `toml:"redirect_uris"`
Service string `toml:"service"`
RequireEmail bool `toml:"require_email"`
}
type svcFileCfg struct {
Enabled *bool `toml:"enabled"`
Desc string `toml:"desc"`
InfoURL string `toml:"info_url"`
URL string `toml:"url"`
Host string `toml:"host"`
User string `toml:"user"`
Pass string `toml:"pass"`
}
func main() {
configPath := flag.String("config", "", "path to TOML config file (optional)")
addrFlag := flag.String("addr", "", "listen address (default :8080)")
dbFlag := flag.String("db", "", "sqlite database path (default /data/misaki.db)")
defaultNoteFlag := flag.String("default-note", "", "default note for new users (default: \"I love misaki!\")")
gancioURL := flag.String("gancio-url", "", "Gancio registration page URL")
akkomaURL := flag.String("akkoma-url", "", "Akkoma registration page URL")
prosodyURL := flag.String("prosody-url", "", "Prosody registration page URL")
forgejoURL := flag.String("forgejo-url", "", "Forgejo registration page URL")
mumbleHost := flag.String("mumble-host", "", "Mumble server host:port")
mumblePass := flag.String("mumble-pass", "", "Mumble server password")
transferHost := flag.String("transfer-host", "", "Transfer.sh host URL")
transferUser := flag.String("transfer-user", "", "Transfer.sh username")
transferPass := flag.String("transfer-pass", "", "Transfer.sh password")
mailEnabled := flag.String("mail-enabled", "", "globally enable/disable mail (1/0)")
mailDesc := flag.String("mail-desc", "", "HTML description for Mail")
wercEnabled := flag.String("werc-enabled", "", "globally enable/disable werc (1/0)")
wercDesc := flag.String("werc-desc", "", "HTML description for Werc")
ergoEnabled := flag.String("ergo-enabled", "", "globally enable/disable ergo (1/0)")
ergoDesc := flag.String("ergo-desc", "", "HTML description for Ergo")
gancioEnabled := flag.String("gancio-enabled", "", "globally enable/disable gancio (1/0)")
gancioDesc := flag.String("gancio-desc", "", "HTML description for Gancio")
akkomaEnabled := flag.String("akkoma-enabled", "", "globally enable/disable akkoma (1/0)")
akkomaDesc := flag.String("akkoma-desc", "", "HTML description for Akkoma")
prosodyEnabled := flag.String("prosody-enabled", "", "globally enable/disable prosody (1/0)")
prosodyDesc := flag.String("prosody-desc", "", "HTML description for Prosody")
forgejoEnabled := flag.String("forgejo-enabled", "", "globally enable/disable forgejo (1/0)")
forgejoDesc := flag.String("forgejo-desc", "", "HTML description for Forgejo")
cercaEnabled := flag.String("cerca-enabled", "", "globally enable/disable cerca (1/0)")
cercaDesc := flag.String("cerca-desc", "", "HTML description for Cerca")
mumbleEnabled := flag.String("mumble-enabled", "", "globally enable/disable mumble (1/0)")
mumbleDesc := flag.String("mumble-desc", "", "HTML description for Mumble")
transferEnabled := flag.String("transfer-enabled", "", "globally enable/disable transfer (1/0)")
transferDesc := flag.String("transfer-desc", "", "HTML description for Transfer")
streamingEnabled := flag.String("streaming-enabled", "", "globally enable/disable streaming (1/0)")
streamingDesc := flag.String("streaming-desc", "", "HTML description for Streaming")
radioEnabled := flag.String("radio-enabled", "", "globally enable/disable radio (1/0)")
radioDesc := flag.String("radio-desc", "", "HTML description for Radio")
filesEnabled := flag.String("files-enabled", "", "globally enable/disable files (1/0)")
filesDesc := flag.String("files-desc", "", "HTML description for Files")
notesEnabled := flag.String("notes-enabled", "", "globally enable/disable notes (1/0)")
notesDesc := flag.String("notes-desc", "", "HTML description for HedgeDoc")
notesURL := flag.String("notes-url", "", "HedgeDoc URL")
pasteEnabled := flag.String("paste-enabled", "", "globally enable/disable paste (1/0)")
pasteDesc := flag.String("paste-desc", "", "HTML description for PrivateBin")
pasteURL := flag.String("paste-url", "", "PrivateBin URL")
rssEnabled := flag.String("rss-enabled", "", "globally enable/disable rss (1/0)")
rssDesc := flag.String("rss-desc", "", "HTML description for RSS Bridge")
rssURL := flag.String("rss-url", "", "RSS Bridge URL")
smtpHost := flag.String("smtp-host", "", "SMTP host:port e.g. smtp.example.com:587")
smtpUser := flag.String("smtp-user", "", "SMTP username")
smtpPass := flag.String("smtp-pass", "", "SMTP password (or SMTP_PASS env)")
smtpFrom := flag.String("smtp-from", "", "From address for notification emails")
notifyTo := flag.String("notify-email", "", "Address to receive notifications")
smtpTLSName := flag.String("smtp-tls-name", "", "Hostname for TLS cert verification (useful when connecting via internal hostname)")
internalSecret := flag.String("internal-secret", "", "bearer token protecting POST /internal/auth (leave empty to disable)")
authSecret := flag.String("auth-secret", "", "HMAC secret for signing session-check identity (shared with werc; leave empty to disable signing)")
baseDomain := flag.String("base-domain", "", "base domain for allowed post-login redirects (e.g. example.com)")
secureCookiesFlag := flag.Bool("secure-cookies", false, "set Secure flag on session cookies (enable when serving over HTTPS)")
flag.Parse()
var cfg fileCfg
if path := firstNonEmpty(*configPath, os.Getenv("CONFIG_FILE")); path != "" {
if _, err := toml.DecodeFile(path, &cfg); err != nil {
if errors.Is(err, os.ErrNotExist) {
log.Printf("config file %q not found, using flags/env only", path)
} else {
log.Fatalf("load config %q: %v", path, err)
}
} else {
log.Printf("loaded config from %s", path)
}
}
cfgOr := func(flagVal, envKey, cfgVal string) string {
return firstNonEmpty(flagVal, os.Getenv(envKey), cfgVal)
}
svc := func(name string) svcFileCfg { return cfg.Services[name] }
resolveEnabled := func(flagVal, envKey string, cfgEnabled *bool) string {
if flagVal != "" {
return flagVal
}
if ev := os.Getenv(envKey); ev != "" {
return ev
}
if cfgEnabled != nil {
if *cfgEnabled {
return "1"
}
return "0"
}
return ""
}
dbPath := firstNonEmpty(*dbFlag, os.Getenv("DB_PATH"), cfg.DB, "/data/misaki.db")
database, err := db.Open(dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
type svcSeed struct {
name string
flagEnabled string
envEnabled string
cfgSvc svcFileCfg
flagDesc string
envDesc string
}
seeds := []svcSeed{
{"mail", *mailEnabled, "MAIL_ENABLED", svc("mail"), *mailDesc, "MAIL_DESC"},
{"werc", *wercEnabled, "WERC_ENABLED", svc("werc"), *wercDesc, "WERC_DESC"},
{"ergo", *ergoEnabled, "ERGO_ENABLED", svc("ergo"), *ergoDesc, "ERGO_DESC"},
{"gancio", *gancioEnabled, "GANCIO_ENABLED", svc("gancio"), *gancioDesc, "GANCIO_DESC"},
{"akkoma", *akkomaEnabled, "AKKOMA_ENABLED", svc("akkoma"), *akkomaDesc, "AKKOMA_DESC"},
{"pixelfed", "", "PIXELFED_ENABLED", svc("pixelfed"), "", "PIXELFED_DESC"},
{"prosody", *prosodyEnabled, "PROSODY_ENABLED", svc("prosody"), *prosodyDesc, "PROSODY_DESC"},
{"forgejo", *forgejoEnabled, "FORGEJO_ENABLED", svc("forgejo"), *forgejoDesc, "FORGEJO_DESC"},
{"cerca", *cercaEnabled, "CERCA_ENABLED", svc("cerca"), *cercaDesc, "CERCA_DESC"},
{"mumble", *mumbleEnabled, "MUMBLE_ENABLED", svc("mumble"), *mumbleDesc, "MUMBLE_DESC"},
{"transfer", *transferEnabled, "TRANSFER_ENABLED", svc("transfer"), *transferDesc, "TRANSFER_DESC"},
{"streaming", *streamingEnabled, "STREAMING_ENABLED", svc("streaming"), *streamingDesc, "STREAMING_DESC"},
{"radio", *radioEnabled, "RADIO_ENABLED", svc("radio"), *radioDesc, "RADIO_DESC"},
{"files", *filesEnabled, "FILES_ENABLED", svc("files"), *filesDesc, "FILES_DESC"},
{"notes", *notesEnabled, "NOTES_ENABLED", svc("notes"), *notesDesc, "NOTES_DESC"},
{"paste", *pasteEnabled, "PASTE_ENABLED", svc("paste"), *pasteDesc, "PASTE_DESC"},
{"rss", *rssEnabled, "RSS_ENABLED", svc("rss"), *rssDesc, "RSS_DESC"},
}
svcDescs := make(map[string]template.HTML)
for _, s := range seeds {
switch strings.ToLower(resolveEnabled(s.flagEnabled, s.envEnabled, s.cfgSvc.Enabled)) {
case "1", "true":
if err := database.SetServiceEnabled(s.name, true); err != nil {
log.Printf("enable %s: %v", s.name, err)
}
case "0", "false":
if err := database.SetServiceEnabled(s.name, false); err != nil {
log.Printf("disable %s: %v", s.name, err)
}
}
if desc := cfgOr(s.flagDesc, s.envDesc, s.cfgSvc.Desc); desc != "" {
svcDescs[s.name] = template.HTML(desc)
}
}
infoURLs := make(map[string]string)
for _, s := range seeds {
if u := firstNonEmpty(os.Getenv(strings.ToUpper(s.name)+"_INFO_URL"), s.cfgSvc.InfoURL); u != "" {
infoURLs[s.name] = u
}
}
svcURLs := server.ServiceURLs{
GancioURL: cfgOr(*gancioURL, "GANCIO_URL", svc("gancio").URL),
AkkomaURL: cfgOr(*akkomaURL, "AKKOMA_URL", svc("akkoma").URL),
ProsodyURL: cfgOr(*prosodyURL, "PROSODY_URL", svc("prosody").URL),
ForgejoURL: cfgOr(*forgejoURL, "FORGEJO_URL", svc("forgejo").URL),
MumbleHost: cfgOr(*mumbleHost, "MUMBLE_HOST", svc("mumble").Host),
MumblePass: cfgOr(*mumblePass, "MUMBLE_PASS", svc("mumble").Pass),
TransferHost: cfgOr(*transferHost, "TRANSFER_HOST", svc("transfer").Host),
TransferUser: cfgOr(*transferUser, "TRANSFER_USER", svc("transfer").User),
TransferPass: cfgOr(*transferPass, "TRANSFER_PASS", svc("transfer").Pass),
}
smtpCfg := server.SMTPConfig{
Host: cfgOr(*smtpHost, "SMTP_HOST", cfg.SMTP.Host),
User: cfgOr(*smtpUser, "SMTP_USER", cfg.SMTP.User),
Pass: cfgOr(*smtpPass, "SMTP_PASS", cfg.SMTP.Pass),
From: cfgOr(*smtpFrom, "SMTP_FROM", cfg.SMTP.From),
To: cfgOr(*notifyTo, "NOTIFY_EMAIL", cfg.SMTP.NotifyEmail),
TLSName: cfgOr(*smtpTLSName, "SMTP_TLS_NAME", cfg.SMTP.TLSName),
}
if smtpCfg.Host == "" {
log.Printf("smtp: not configured — emails will be skipped")
} else if smtpCfg.To == "" {
log.Printf("smtp: host=%s from=%s (warning: no notify_email set, admin notifications disabled)", smtpCfg.Host, smtpCfg.From)
} else {
log.Printf("smtp: host=%s from=%s to=%s", smtpCfg.Host, smtpCfg.From, smtpCfg.To)
}
go func() {
for range time.Tick(24 * time.Hour) {
if err := database.PruneExpiredSessions(); err != nil {
log.Printf("prune sessions: %v", err)
}
}
}()
secureCookies := *secureCookiesFlag ||
strings.EqualFold(os.Getenv("SECURE_COOKIES"), "true") ||
os.Getenv("SECURE_COOKIES") == "1"
addr := firstNonEmpty(*addrFlag, os.Getenv("ADDR"), cfg.Addr, ":8080")
cookieURLs := map[string]string{
"transfer": cfgOr(*transferHost, "TRANSFER_HOST", svc("transfer").URL),
"notes": cfgOr(*notesURL, "NOTES_URL", svc("notes").URL),
"paste": cfgOr(*pasteURL, "PASTE_URL", svc("paste").URL),
"rss": cfgOr(*rssURL, "RSS_URL", svc("rss").URL),
"akkoma": cfgOr(*akkomaURL, "AKKOMA_URL", svc("akkoma").URL),
"pixelfed": cfgOr("", "PIXELFED_URL", svc("pixelfed").URL),
}
var oidcCfg *server.OIDCConfig
if cfg.OIDC.Issuer != "" {
oidcCfg = &server.OIDCConfig{Issuer: cfg.OIDC.Issuer, KeyFile: cfg.OIDC.KeyFile}
for id, c := range cfg.OIDC.Clients {
oidcCfg.Clients = append(oidcCfg.Clients, server.OIDCClient{
ID: id, SecretSHA256: c.SecretSHA256, RedirectURIs: c.RedirectURIs,
Service: c.Service, RequireEmail: c.RequireEmail,
})
}
log.Printf("oidc: issuer=%s clients=%d", oidcCfg.Issuer, len(oidcCfg.Clients))
}
srv := server.New(database, svcURLs, svcDescs, smtpCfg, server.Config{
SecureCookies: secureCookies,
InfoURLs: infoURLs,
CookieURLs: cookieURLs,
InternalSecret: firstNonEmpty(*internalSecret, os.Getenv("INTERNAL_SECRET"), cfg.InternalSecret),
AuthSecret: firstNonEmpty(*authSecret, os.Getenv("AUTH_SECRET"), cfg.AuthSecret),
BaseDomain: firstNonEmpty(*baseDomain, os.Getenv("BASE_DOMAIN"), cfg.BaseDomain),
DefaultNote: firstNonEmpty(*defaultNoteFlag, os.Getenv("DEFAULT_NOTE"), cfg.DefaultNote),
OIDC: oidcCfg,
TOTPKeyFile: firstNonEmpty(os.Getenv("TOTP_KEY_FILE"), cfg.TOTPKeyFile, filepath.Join(filepath.Dir(dbPath), "totp.key")),
PublicURL: firstNonEmpty(os.Getenv("PUBLIC_URL"), cfg.PublicURL, cfg.OIDC.Issuer),
})
// Docker stops us with SIGTERM: drain, then close the db so SQLite checkpoints and drops the WAL.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
log.Printf("listening on %s", addr)
err = srv.ListenAndServe(ctx, addr)
if cerr := database.Close(); cerr != nil {
log.Printf("close db: %v", cerr)
}
if err != nil {
log.Fatal(err)
}
log.Print("stopped")
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}