485 lines
16 KiB
Go
485 lines
16 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||
|
|
|
||
|
|
// Package app assembles the volumen HTTP server: shared services,
|
||
|
|
// route registration, and the middleware chain.
|
||
|
|
package app
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/rand"
|
||
|
|
"encoding/hex"
|
||
|
|
json "encoding/json/v2"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"slices"
|
||
|
|
"strings"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/admin"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/audit"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/backup"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/httpapi"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/session"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/templates"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/version"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Server holds every long-lived service the handlers share.
|
||
|
|
type Server struct {
|
||
|
|
Config *config.Config
|
||
|
|
Store *store.Store
|
||
|
|
Users *users.Users
|
||
|
|
Templates *templates.Store
|
||
|
|
Tokens *tokens.Store
|
||
|
|
Audit *audit.Log
|
||
|
|
LoginLim *ratelimit.LoginLimiter
|
||
|
|
Sessions *session.Store
|
||
|
|
Webhooks *webhooks.Manager
|
||
|
|
Admin *admin.Admin
|
||
|
|
OnEvent func(event string, payload map[string]any)
|
||
|
|
|
||
|
|
// previewKey is the session secret New resolved, which the admin
|
||
|
|
// signs preview links with and the API verifies them against.
|
||
|
|
previewKey string
|
||
|
|
}
|
||
|
|
|
||
|
|
// New validates the configuration and builds the server with all
|
||
|
|
// file-backed stores derived from it.
|
||
|
|
func New(cfg *config.Config, st *store.Store) (*Server, error) {
|
||
|
|
if err := cfg.Validate(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
secret, err := sessionSecret(cfg)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
cookieSecure := cfg.Server.CookieSecure || cfg.Server.TrustProxy
|
||
|
|
usersPath := cfg.UsersFile
|
||
|
|
hooks := make([]webhooks.Webhook, 0, len(cfg.Webhooks))
|
||
|
|
for _, hook := range cfg.Webhooks {
|
||
|
|
hooks = append(hooks, webhooks.Webhook{
|
||
|
|
URL: hook.URL, Secret: hook.Secret,
|
||
|
|
Events: hook.Events, Enabled: hook.Delivers(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
staticHooks := slices.Clone(hooks)
|
||
|
|
// The admin-managed hooks live beside the users file and apply
|
||
|
|
// without a restart. A file that cannot be read is a real fault and
|
||
|
|
// is reported, but it does not take the server down: the configured
|
||
|
|
// hooks still deliver.
|
||
|
|
webhooksFile := filepath.Join(filepath.Dir(usersPath), "webhooks.toml")
|
||
|
|
fileHooks, err := webhooks.LoadFile(webhooksFile)
|
||
|
|
if err != nil {
|
||
|
|
slog.Warn("app: ignoring the webhook store", "path", webhooksFile, "error", err)
|
||
|
|
} else {
|
||
|
|
hooks = append(hooks, fileHooks...)
|
||
|
|
}
|
||
|
|
manager := webhooks.NewManager(hooks, version.Version())
|
||
|
|
srv := &Server{
|
||
|
|
Config: cfg,
|
||
|
|
Store: st,
|
||
|
|
Users: users.New(usersPath),
|
||
|
|
Templates: templates.New(cfg.TemplatesFile()),
|
||
|
|
Tokens: tokens.New(cfg.TokensFile()),
|
||
|
|
Audit: audit.New(cfg.AuditLog),
|
||
|
|
LoginLim: ratelimit.NewLoginLimiter(),
|
||
|
|
Sessions: session.New(secret, time.Duration(cfg.Admin.SessionTTL)*time.Second, cookieSecure),
|
||
|
|
Webhooks: manager,
|
||
|
|
previewKey: secret,
|
||
|
|
OnEvent: func(event string, payload map[string]any) {
|
||
|
|
manager.Fire(event, payload, false)
|
||
|
|
},
|
||
|
|
}
|
||
|
|
adminHandler, err := admin.New(admin.Deps{
|
||
|
|
Config: cfg,
|
||
|
|
Store: st,
|
||
|
|
Users: srv.Users,
|
||
|
|
Templates: srv.Templates,
|
||
|
|
Tokens: srv.Tokens,
|
||
|
|
Audit: srv.Audit,
|
||
|
|
LoginLim: srv.LoginLim,
|
||
|
|
Sessions: srv.Sessions,
|
||
|
|
Webhooks: manager,
|
||
|
|
PreviewKey: secret,
|
||
|
|
WebhooksFile: webhooksFile,
|
||
|
|
StaticWebhooks: staticHooks,
|
||
|
|
Version: version.Version(),
|
||
|
|
OnEvent: srv.OnEvent,
|
||
|
|
Backup: BackupOptions(cfg),
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("init admin UI: %w", err)
|
||
|
|
}
|
||
|
|
srv.Admin = adminHandler
|
||
|
|
return srv, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handler builds the full middleware chain and route tree.
|
||
|
|
//
|
||
|
|
// Uploaded media and the backup export are served on their own branches:
|
||
|
|
// the session middleware and the gzip wrapper buffer whole responses,
|
||
|
|
// which would hold entire files in memory. Everything else flows through
|
||
|
|
// the full chain.
|
||
|
|
func (s *Server) Handler() http.Handler {
|
||
|
|
secure := s.Config.Server.CookieSecure || s.Config.Server.TrustProxy
|
||
|
|
|
||
|
|
mediaMux := http.NewServeMux()
|
||
|
|
mediaMux.HandleFunc("GET /media/{name...}", s.handleMedia)
|
||
|
|
mediaHandler := web.SecurityHeaders(secure)(mediaMux)
|
||
|
|
|
||
|
|
adminHandler := s.Admin.Handler()
|
||
|
|
|
||
|
|
// The backup export streams: it keeps the admin authentication but
|
||
|
|
// bypasses the wrappers that hold a whole response in memory, the
|
||
|
|
// session recorder and the gzip wrapper, so the archive reaches the
|
||
|
|
// client as it is written instead of waiting in a second copy. The
|
||
|
|
// session attaches read-only; a GET never mutates it.
|
||
|
|
var exportHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
sess := s.Sessions.Load(r)
|
||
|
|
adminHandler.ServeHTTP(w, r.WithContext(session.WithContext(r.Context(), sess)))
|
||
|
|
})
|
||
|
|
exportHandler = web.CrossOrigin()(exportHandler)
|
||
|
|
exportHandler = web.SecurityHeaders(secure)(exportHandler)
|
||
|
|
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
|
||
|
|
api := httpapi.New(httpapi.Deps{
|
||
|
|
Config: s.Config,
|
||
|
|
Store: s.Store,
|
||
|
|
Tokens: s.Tokens,
|
||
|
|
OnEvent: s.OnEvent,
|
||
|
|
PreviewKey: s.previewKey,
|
||
|
|
})
|
||
|
|
mux.Handle("/api/volumen/", api)
|
||
|
|
mux.Handle("/admin/", adminHandler)
|
||
|
|
mux.Handle("/admin", adminHandler)
|
||
|
|
|
||
|
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
w.Header().Set("Location", "/admin/")
|
||
|
|
w.WriteHeader(http.StatusSeeOther)
|
||
|
|
})
|
||
|
|
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||
|
|
mux.HandleFunc("GET /robots.txt", s.handleRobots)
|
||
|
|
mux.HandleFunc("GET /sitemap.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
w.Header().Set("Location", "/api/volumen/sitemap.xml")
|
||
|
|
w.WriteHeader(http.StatusMovedPermanently)
|
||
|
|
})
|
||
|
|
mux.HandleFunc("GET /favicon.ico", s.handleFavicon)
|
||
|
|
mux.HandleFunc("/", s.handleNotFound)
|
||
|
|
|
||
|
|
var handler http.Handler = web.RequestLogger(mux)
|
||
|
|
handler = s.Sessions.Middleware(handler)
|
||
|
|
handler = s.apiRateLimit(handler)
|
||
|
|
handler = web.SecurityHeaders(secure)(handler)
|
||
|
|
handler = web.CrossOrigin()(handler)
|
||
|
|
handler = web.Gzip(500)(handler)
|
||
|
|
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.URL.Path == "/admin/settings/export" {
|
||
|
|
exportHandler.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if strings.HasPrefix(r.URL.Path, "/media/") {
|
||
|
|
mediaHandler.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
handler.ServeHTTP(w, r)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) apiRateLimit(next http.Handler) http.Handler {
|
||
|
|
if s.Config.API.RateLimit <= 0 {
|
||
|
|
return next
|
||
|
|
}
|
||
|
|
limiter := ratelimit.New(
|
||
|
|
s.Config.API.RateLimit,
|
||
|
|
time.Duration(s.Config.API.RateLimitWindow)*time.Second,
|
||
|
|
)
|
||
|
|
trusted, err := s.Config.TrustedProxyPrefixes()
|
||
|
|
if err != nil || !s.Config.Server.TrustProxy {
|
||
|
|
trusted = nil
|
||
|
|
}
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.URL.Path != "/api/volumen" && !strings.HasPrefix(r.URL.Path, "/api/volumen/") {
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
allowed, remaining, retryAfter := limiter.Check(web.ClientIP(r, trusted))
|
||
|
|
if !allowed {
|
||
|
|
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
|
||
|
|
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limiter.Limit()))
|
||
|
|
w.Header().Set("X-RateLimit-Remaining", "0")
|
||
|
|
for key, value := range map[string]string{
|
||
|
|
"Access-Control-Allow-Origin": "*",
|
||
|
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||
|
|
"Access-Control-Allow-Headers": "Content-Type",
|
||
|
|
} {
|
||
|
|
w.Header().Set(key, value)
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
||
|
|
_ = json.MarshalWrite(w, map[string]any{
|
||
|
|
"error": "rate_limited",
|
||
|
|
"retry_after": retryAfter,
|
||
|
|
}, json.Deterministic(true))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limiter.Limit()))
|
||
|
|
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
checks := map[string]string{}
|
||
|
|
overall := "ok"
|
||
|
|
|
||
|
|
if info, err := os.Stat(s.Config.ContentDir); err == nil && info.IsDir() {
|
||
|
|
checks["content_dir"] = "ok"
|
||
|
|
} else {
|
||
|
|
checks["content_dir"] = "missing"
|
||
|
|
overall = "degraded"
|
||
|
|
}
|
||
|
|
|
||
|
|
switch err := s.Users.Health(); {
|
||
|
|
case err != nil:
|
||
|
|
// A file that cannot be read means nobody can sign in, which is
|
||
|
|
// not a healthy deployment: say so rather than reporting a count
|
||
|
|
// of zero accounts.
|
||
|
|
slog.Error("volumen: healthz cannot read the users file", "error", err)
|
||
|
|
checks["users_file"] = "unreadable"
|
||
|
|
overall = "degraded"
|
||
|
|
default:
|
||
|
|
if info, statErr := os.Stat(s.Config.UsersFile); statErr == nil && info.Mode().IsRegular() {
|
||
|
|
checks["users_file"] = "ok"
|
||
|
|
} else {
|
||
|
|
checks["users_file"] = "missing (no accounts yet; /admin runs the first-run wizard)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := s.Tokens.Health(); err != nil {
|
||
|
|
slog.Error("volumen: healthz cannot read the tokens file", "error", err)
|
||
|
|
checks["tokens_file"] = "unreadable"
|
||
|
|
overall = "degraded"
|
||
|
|
}
|
||
|
|
if err := s.Templates.Health(); err != nil {
|
||
|
|
slog.Error("volumen: healthz cannot read the templates file", "error", err)
|
||
|
|
checks["templates_file"] = "unreadable"
|
||
|
|
overall = "degraded"
|
||
|
|
}
|
||
|
|
if skipped := s.Store.Unreadable(); len(skipped) > 0 {
|
||
|
|
checks["content_files"] = fmt.Sprintf("%d file(s) cannot be parsed", len(skipped))
|
||
|
|
overall = "degraded"
|
||
|
|
}
|
||
|
|
|
||
|
|
freeMB, err := freeDiskMB(s.Config.ContentDir)
|
||
|
|
switch {
|
||
|
|
case err != nil:
|
||
|
|
// The path and the OS error are logged, not published: this
|
||
|
|
// endpoint is anonymous.
|
||
|
|
slog.Warn("volumen: healthz cannot read the content directory", "error", err)
|
||
|
|
checks["disk"] = "error"
|
||
|
|
case freeMB < 100:
|
||
|
|
checks["disk"] = fmt.Sprintf("low: %.0f MB free", freeMB)
|
||
|
|
overall = "degraded"
|
||
|
|
default:
|
||
|
|
checks["disk"] = fmt.Sprintf("ok (%.0f MB free)", freeMB)
|
||
|
|
}
|
||
|
|
|
||
|
|
status := http.StatusOK
|
||
|
|
if overall != "ok" {
|
||
|
|
status = http.StatusServiceUnavailable
|
||
|
|
}
|
||
|
|
w.Header().Set("Cache-Control", "no-store")
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(status)
|
||
|
|
_ = json.MarshalWrite(w, map[string]any{"status": overall, "checks": checks}, json.Deterministic(true))
|
||
|
|
}
|
||
|
|
|
||
|
|
func freeDiskMB(path string) (float64, error) {
|
||
|
|
var st syscall.Statfs_t
|
||
|
|
if err := syscall.Statfs(path, &st); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
return float64(st.Bavail) * float64(st.Bsize) / (1024 * 1024), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleRobots(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
base := s.Config.Site.BaseURL
|
||
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
|
|
fmt.Fprintf(w, "User-agent: *\nAllow: /\nSitemap: %s/api/volumen/sitemap.xml\n", base)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleFavicon(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
icon, err := web.StaticFile("volumen-icon.svg")
|
||
|
|
if err != nil {
|
||
|
|
w.WriteHeader(http.StatusNotFound)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
_, _ = w.Write(icon)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
|
||
|
|
name := r.PathValue("name")
|
||
|
|
mediaPath, err := s.Store.MediaPath(name)
|
||
|
|
if err != nil {
|
||
|
|
// A name that is not an allowed image, that would escape the
|
||
|
|
// media directory, or that names nothing, is a 404: the route is
|
||
|
|
// public, so it says nothing about why.
|
||
|
|
s.writeNotFound(w)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
// The type is set from the name's extension rather than sniffed, so a
|
||
|
|
// file whose bytes do not match its extension is still served as an
|
||
|
|
// image and never as a document.
|
||
|
|
contentType := imagefile.ContentType(name)
|
||
|
|
w.Header().Set("Cache-Control", "public, max-age=604800")
|
||
|
|
w.Header().Set("Content-Type", contentType)
|
||
|
|
if contentType == imagefile.MIMESVG {
|
||
|
|
// An SVG is the one accepted image that is also a document:
|
||
|
|
// opened at its own URL it would run on this origin. The
|
||
|
|
// sandbox and the locked-down policy make that a dead
|
||
|
|
// document, while the <img> uses of the file ignore both.
|
||
|
|
w.Header().Set("Content-Security-Policy",
|
||
|
|
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; sandbox")
|
||
|
|
}
|
||
|
|
http.ServeFile(w, r, mediaPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) writeNotFound(w http.ResponseWriter) {
|
||
|
|
w.Header().Set("Cache-Control", "no-store")
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(http.StatusNotFound)
|
||
|
|
_ = json.MarshalWrite(w, map[string]any{"error": "not_found"}, json.Deterministic(true))
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
s.writeNotFound(w)
|
||
|
|
}
|
||
|
|
|
||
|
|
// BackupOptions names the files an archive carries, for the admin UI and
|
||
|
|
// the CLI export and import.
|
||
|
|
func BackupOptions(cfg *config.Config) backup.Options {
|
||
|
|
return backup.Options{
|
||
|
|
ContentDir: cfg.ContentDir,
|
||
|
|
UsersFile: cfg.UsersFile,
|
||
|
|
TemplatesFile: cfg.TemplatesFile(),
|
||
|
|
TokensFile: cfg.TokensFile(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// PublishEvent reports that a scheduled post went live, as the same
|
||
|
|
// post.published event the admin delivers, so a hook subscribed to it
|
||
|
|
// hears about a post the scheduler published.
|
||
|
|
func (s *Server) PublishEvent(p *post.Post) {
|
||
|
|
if s.OnEvent == nil || p == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.OnEvent("post.published", map[string]any{"post": payloads.BuildSummary(p)})
|
||
|
|
}
|
||
|
|
|
||
|
|
// sessionSecret resolves the cookie signing key. The [admin].session_key
|
||
|
|
// in config is an override; with nothing set the server keeps its own
|
||
|
|
// secret in secret.key beside the users file, generating one on first
|
||
|
|
// start so a fresh installation can sign in without the operator
|
||
|
|
// editing the config. Production refuses a key shorter than 64 bytes
|
||
|
|
// whatever its source; development falls back to the ephemeral secret
|
||
|
|
// of session.New when the file cannot be written.
|
||
|
|
func sessionSecret(cfg *config.Config) (string, error) {
|
||
|
|
if key := cfg.Admin.SessionKey; key != "" {
|
||
|
|
return checkedSecret(cfg, key, "[admin].session_key")
|
||
|
|
}
|
||
|
|
path := cfg.SecretKeyFile()
|
||
|
|
raw, err := os.ReadFile(path)
|
||
|
|
switch {
|
||
|
|
case err == nil:
|
||
|
|
if key := strings.TrimSpace(string(raw)); key != "" {
|
||
|
|
return checkedSecret(cfg, key, path)
|
||
|
|
}
|
||
|
|
// An empty file is treated as no file: one more start and the
|
||
|
|
// key is generated and written, so the state converges.
|
||
|
|
case !errors.Is(err, os.ErrNotExist):
|
||
|
|
if cfg.IsProduction() {
|
||
|
|
return "", fmt.Errorf("read %s: %w", path, err)
|
||
|
|
}
|
||
|
|
slog.Warn("app: cannot read the session secret file", "path", path, "error", err)
|
||
|
|
return "", nil
|
||
|
|
}
|
||
|
|
// 32 random bytes as 64 hex characters, which is the length
|
||
|
|
// production requires. A system failure here dies inside
|
||
|
|
// crypto/rand rather than signing sessions with less entropy than
|
||
|
|
// the key looks like.
|
||
|
|
buf := make([]byte, 32)
|
||
|
|
if _, err := rand.Read(buf); err != nil {
|
||
|
|
return "", fmt.Errorf("generate the session secret: %w", err)
|
||
|
|
}
|
||
|
|
key := hex.EncodeToString(buf)
|
||
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
|
|
if cfg.IsProduction() {
|
||
|
|
return "", fmt.Errorf("create %s: %w", filepath.Dir(path), err)
|
||
|
|
}
|
||
|
|
slog.Warn("app: cannot create the session secret directory; using an ephemeral secret", "error", err)
|
||
|
|
return "", nil
|
||
|
|
}
|
||
|
|
// O_EXCL so two servers racing on a fresh data directory cannot
|
||
|
|
// each write a different key: the loser reads the winner's file.
|
||
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||
|
|
if errors.Is(err, os.ErrExist) {
|
||
|
|
raw, err := os.ReadFile(path)
|
||
|
|
if err != nil {
|
||
|
|
if cfg.IsProduction() {
|
||
|
|
return "", fmt.Errorf("read %s: %w", path, err)
|
||
|
|
}
|
||
|
|
return "", nil
|
||
|
|
}
|
||
|
|
return checkedSecret(cfg, strings.TrimSpace(string(raw)), path)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
if cfg.IsProduction() {
|
||
|
|
return "", fmt.Errorf("write %s: %w (or set [admin].session_key)", path, err)
|
||
|
|
}
|
||
|
|
slog.Warn("app: cannot write the session secret file; using an ephemeral secret", "error", err)
|
||
|
|
return "", nil
|
||
|
|
}
|
||
|
|
if _, err := f.WriteString(key + "\n"); err != nil {
|
||
|
|
f.Close()
|
||
|
|
if cfg.IsProduction() {
|
||
|
|
return "", fmt.Errorf("write %s: %w", path, err)
|
||
|
|
}
|
||
|
|
return "", nil
|
||
|
|
}
|
||
|
|
if err := f.Close(); err != nil && cfg.IsProduction() {
|
||
|
|
return "", fmt.Errorf("write %s: %w", path, err)
|
||
|
|
}
|
||
|
|
slog.Info("app: generated the session secret", "path", path)
|
||
|
|
return key, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// checkedSecret applies the production length rule to a key named by
|
||
|
|
// its source, which is the config field or the secret file.
|
||
|
|
func checkedSecret(cfg *config.Config, key, source string) (string, error) {
|
||
|
|
if len(key) < 64 && cfg.IsProduction() {
|
||
|
|
return "", fmt.Errorf(
|
||
|
|
"%s must be at least 64 bytes in production (got %d). "+
|
||
|
|
"Generate one with: openssl rand -hex 32", source, len(key))
|
||
|
|
}
|
||
|
|
return key, nil
|
||
|
|
}
|