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