Port of Feature 04 task 3 (7629cab) onto Feature 04 + Feature 03.
On first boot create the single household_settings row with the zone from
MEALQUEST_DEFAULT_TZ and the seed times and days (dinner 17:30, reminder
16:30, rotation check 09:00, plan draft Fri 09:00, suggestions Sun 08:00,
chat autosend Sat 08:00). An existing row is never overwritten.
Add household.SettingsService with Get, a validating Update (IANA zone,
zero-padded 24-hour HH:MM, day of week 0-6) that records updated_by, and
in-process Subscribe for the scheduler. Serve it as GET and PATCH
/api/v1/settings/household; invalid input is a 400 in the standard error
body. Migration 0002 adds household_settings.updated_by. Link time/tzdata
from internal/household so zones load in the minimal image.
Review fixes:
- F1: the settings routes are mounted on the protected /api/v1 subrouter,
so they need a session (RequireSession) and, for PATCH, the CSRF token.
updated_by is the session actor's subject from auth.ActorFrom; the
duplicate internal/actor package is dropped, and Update refuses an empty
member instead of storing NULL. A test drives PATCH through the
production router with a real SessionManager session.
- F2: config no longer validates MEALQUEST_DEFAULT_TZ. Seeding requires a
valid zone only when it creates the row (ErrNoDefaultZone,
ErrInvalidDefaultZone, both naming the variable at boot), so a reboot
over existing settings ignores a missing or invalid default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
4.9 KiB
Go
167 lines
4.9 KiB
Go
// Command server runs the MealQuest HTTP service.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
|
)
|
|
|
|
// version is set at build time with -ldflags "-X main.version=<version>".
|
|
var version = "dev"
|
|
|
|
func main() {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(os.Stdout, nil)))
|
|
if err := run(cfg, logger); err != nil {
|
|
logger.Error("server stopped", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(cfg config.Config, logger *slog.Logger) error {
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
if _, err := openSecretBox(cfg.SecretKey); err != nil {
|
|
return err
|
|
}
|
|
|
|
db, err := openStore(ctx, cfg.DataDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := db.Close(); err != nil {
|
|
logger.Error("close database", "error", err)
|
|
}
|
|
}()
|
|
|
|
if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil {
|
|
return err
|
|
}
|
|
settings := household.NewSettingsService(db)
|
|
|
|
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
authFlow, err := auth.New(ctx, auth.Config{
|
|
Issuer: cfg.OIDCIssuer.String(),
|
|
ClientID: cfg.OIDCClientID,
|
|
ClientSecret: cfg.OIDCClientSecret.Reveal(),
|
|
PublicURL: cfg.PublicURL,
|
|
SecretKey: []byte(cfg.SecretKey.Reveal()),
|
|
AllowedGroups: cfg.OIDCAllowedGroups,
|
|
}, sessions, logger)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, frontend.Assets())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
srv := &http.Server{
|
|
Addr: cfg.HTTPAddr,
|
|
Handler: router,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
|
}
|
|
|
|
errc := make(chan error, 1)
|
|
go func() {
|
|
logListening(logger, cfg)
|
|
errc <- srv.ListenAndServe()
|
|
}()
|
|
|
|
select {
|
|
case err := <-errc:
|
|
return err
|
|
case <-ctx.Done():
|
|
}
|
|
|
|
logger.Info("shutting down", "timeout", cfg.ShutdownTimeout.String())
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
return fmt.Errorf("shutdown: %w", err)
|
|
}
|
|
if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// openSecretBox builds the secret box from the configured key, naming the
|
|
// variable whose file holds a bad key. The key itself is never reported.
|
|
func openSecretBox(key config.Secret) (*secretbox.Box, error) {
|
|
box, err := secretbox.New(key.Reveal())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", config.EnvSecretKeyFile, err)
|
|
}
|
|
return box, nil
|
|
}
|
|
|
|
// bootMigrations is the migration set applied at boot. Tests replace it to
|
|
// prove a failing migration stops the boot.
|
|
var bootMigrations fs.FS = migrations.FS
|
|
|
|
// openStore opens the database in dataDir and applies bootMigrations. On a
|
|
// migration failure it closes the database and returns the error.
|
|
func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
|
db, err := store.Open(ctx, dataDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.Migrate(ctx, db.Writer, bootMigrations); err != nil {
|
|
return nil, errors.Join(err, db.Close())
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// seedSettings creates the household settings on first boot, naming the
|
|
// variable that supplies the zone when it is missing or invalid. Once the
|
|
// settings exist the variable is ignored.
|
|
func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error {
|
|
err := household.SeedSettings(ctx, db, defaultTZ)
|
|
if errors.Is(err, household.ErrNoDefaultZone) || errors.Is(err, household.ErrInvalidDefaultZone) {
|
|
return fmt.Errorf("%s: a valid IANA zone is required on first boot to create the household settings: %w", config.EnvDefaultTZ, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// logListening logs the startup line. Only the public URL's origin is logged
|
|
// so no credential-bearing URL component can reach the log.
|
|
func logListening(logger *slog.Logger, cfg config.Config) {
|
|
logger.Info("listening",
|
|
"addr", cfg.HTTPAddr,
|
|
"public_origin", cfg.PublicOrigin(),
|
|
"trusted_proxy", cfg.TrustedProxy,
|
|
"data_dir", cfg.DataDir,
|
|
"version", version,
|
|
)
|
|
}
|