2026-10-02 22:20:34 -04:00
|
|
|
// Package server builds the MealQuest HTTP router.
|
|
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-10-03 11:50:15 -04:00
|
|
|
"errors"
|
2026-10-02 22:47:01 -04:00
|
|
|
"io/fs"
|
2026-10-02 22:20:34 -04:00
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
2026-10-02 22:47:01 -04:00
|
|
|
|
2026-10-03 11:05:33 -04:00
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
2026-10-02 22:47:01 -04:00
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
2026-10-03 11:50:15 -04:00
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
2026-10-03 15:05:23 -04:00
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
2026-10-02 22:20:34 -04:00
|
|
|
)
|
|
|
|
|
|
2026-10-02 22:47:01 -04:00
|
|
|
// reservedPrefixes are owned by the API router and are never answered with the
|
|
|
|
|
// SPA's index.html.
|
|
|
|
|
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
|
|
|
|
|
|
2026-10-03 09:58:05 -04:00
|
|
|
// RouteMounter registers its own routes on the router.
|
|
|
|
|
type RouteMounter interface {
|
|
|
|
|
Mount(r chi.Router)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 11:05:33 -04:00
|
|
|
// NewRouter returns the HTTP handler for the service. Every request first
|
|
|
|
|
// has its client address and scheme resolved, honouring forwarded headers
|
|
|
|
|
// only from a peer in trusted (see resolveOrigin). version is reported by
|
2026-10-03 10:30:07 -04:00
|
|
|
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
2026-10-03 15:05:23 -04:00
|
|
|
// /media; settings serves /api/v1/settings/household; runner serves
|
|
|
|
|
// /api/v1/jobs/{id}; assets is the web build
|
2026-10-03 11:50:15 -04:00
|
|
|
// served for every other GET. /health, /auth and the web build are public.
|
|
|
|
|
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
2026-10-03 15:05:23 -04:00
|
|
|
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
|
2026-10-03 11:50:15 -04:00
|
|
|
if settings == nil {
|
|
|
|
|
return nil, errors.New("server: nil household settings service")
|
|
|
|
|
}
|
2026-10-03 15:05:23 -04:00
|
|
|
if runner == nil {
|
|
|
|
|
return nil, errors.New("server: nil job runner")
|
|
|
|
|
}
|
2026-10-02 22:47:01 -04:00
|
|
|
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
|
|
|
|
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
|
|
|
|
|
|
|
|
web, err := frontend.NewHandler(assets, logger, notFound, reservedPrefixes...)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 22:20:34 -04:00
|
|
|
r := chi.NewRouter()
|
2026-10-03 11:05:33 -04:00
|
|
|
r.Use(resolveOrigin(logger, trusted))
|
2026-10-02 22:47:01 -04:00
|
|
|
r.MethodNotAllowed(methodNotAllowed)
|
|
|
|
|
r.NotFound(notFound)
|
|
|
|
|
|
2026-10-02 22:20:34 -04:00
|
|
|
r.Get("/health", health(logger, version))
|
2026-10-03 09:58:05 -04:00
|
|
|
auth.Mount(r)
|
2026-10-03 10:30:07 -04:00
|
|
|
// The session middleware wraps the whole /api/v1 and /media subrouters
|
|
|
|
|
// rather than being added with Use: chi skips a subrouter's middleware
|
|
|
|
|
// when it has no routes, which would leave it unguarded.
|
|
|
|
|
signedIn := requireSession(logger, sessions)
|
2026-10-03 10:51:26 -04:00
|
|
|
csrf := requireCSRF(logger)
|
2026-10-03 10:30:07 -04:00
|
|
|
apiV1, media := chi.NewRouter(), chi.NewRouter()
|
|
|
|
|
for _, sub := range []*chi.Mux{apiV1, media} {
|
|
|
|
|
sub.MethodNotAllowed(methodNotAllowed)
|
|
|
|
|
sub.NotFound(notFound)
|
|
|
|
|
}
|
2026-10-03 10:51:26 -04:00
|
|
|
apiV1.Get("/me", me(logger))
|
2026-10-03 11:50:15 -04:00
|
|
|
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
|
|
|
|
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
2026-10-03 15:05:23 -04:00
|
|
|
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
2026-10-02 22:47:01 -04:00
|
|
|
r.Route("/api", func(r chi.Router) {
|
|
|
|
|
r.MethodNotAllowed(methodNotAllowed)
|
|
|
|
|
r.NotFound(notFound)
|
2026-10-03 10:51:26 -04:00
|
|
|
r.Mount("/v1", signedIn(csrf(apiV1)))
|
2026-10-02 22:47:01 -04:00
|
|
|
})
|
2026-10-03 10:30:07 -04:00
|
|
|
r.Mount("/media", signedIn(media))
|
|
|
|
|
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
|
|
|
|
|
// which passes them to notFound rather than the SPA fallback.
|
2026-10-02 22:47:01 -04:00
|
|
|
r.Method(http.MethodGet, "/*", web)
|
|
|
|
|
r.Method(http.MethodHead, "/*", web)
|
|
|
|
|
return r, nil
|
2026-10-02 22:20:34 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type errorBody struct {
|
|
|
|
|
Error apiError `json:"error"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type apiError struct {
|
|
|
|
|
Code string `json:"code"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
Retryable bool `json:"retryable"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// apiErrorHandler responds with status and a fixed, non-retryable API error.
|
|
|
|
|
func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
2026-10-03 11:50:15 -04:00
|
|
|
writeError(w, r, logger, status, code, message)
|
2026-10-02 22:20:34 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type healthResponse struct {
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
Version string `json:"version"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func health(logger *slog.Logger, version string) http.HandlerFunc {
|
|
|
|
|
body, err := json.Marshal(healthResponse{Status: "ok", Version: version})
|
|
|
|
|
if err != nil {
|
|
|
|
|
panic(err) // two string fields cannot fail to marshal
|
|
|
|
|
}
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
if _, err := w.Write(body); err != nil {
|
|
|
|
|
logger.WarnContext(r.Context(), "write health response", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|