diff --git a/.gitignore b/.gitignore index ee38b4e..7523873 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,13 @@ .DS_Store design/.DS_Store + +# Secrets and local environment +.env* +deploy/secrets/ + +# Build artifacts and dependencies +node_modules +dist + +# Local databases +*.db diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..b9e99bd --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,74 @@ +// Command server runs the MealQuest HTTP service. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" +) + +// version is set at build time with -ldflags "-X main.version=". +var version = "dev" + +func main() { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + logger := slog.New(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() + + srv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: server.NewRouter(logger, version), + ReadHeaderTimeout: 10 * time.Second, + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + } + + errc := make(chan error, 1) + go func() { + logger.Info("listening", + "addr", cfg.HTTPAddr, + "public_url", cfg.PublicURL.String(), + "data_dir", cfg.DataDir, + "version", version, + ) + 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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..37e681e --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest + +go 1.25 + +require github.com/go-chi/chi/v5 v5.3.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bdb0eaa --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..dd45f72 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,200 @@ +// Package config loads MealQuest configuration from MEALQUEST_* environment +// variables. Secrets are read only from files named by *_FILE variables. +package config + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "strings" + "time" +) + +// Environment variable names. +const ( + EnvHTTPAddr = "MEALQUEST_HTTP_ADDR" + EnvPublicURL = "MEALQUEST_PUBLIC_URL" + EnvDataDir = "MEALQUEST_DATA_DIR" + EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT" + EnvSessionSecretFile = "MEALQUEST_SESSION_SECRET_FILE" +) + +// Config is the validated service configuration. +type Config struct { + HTTPAddr string + PublicURL *url.URL + DataDir string + ShutdownTimeout time.Duration + SessionSecret Secret +} + +// Load reads and validates configuration from the process environment. +// It reports every missing or invalid variable in a single *Error. +func Load() (Config, error) { + return load(os.LookupEnv) +} + +func load(lookup func(string) (string, bool)) (Config, error) { + r := reader{lookup: lookup} + cfg := Config{ + HTTPAddr: r.listenAddr(EnvHTTPAddr), + PublicURL: r.httpURL(EnvPublicURL), + DataDir: r.dir(EnvDataDir), + ShutdownTimeout: r.duration(EnvShutdownTimeout), + SessionSecret: r.secretFile(EnvSessionSecretFile), + } + if len(r.problems) > 0 { + return Config{}, &Error{Problems: r.problems} + } + return cfg, nil +} + +// Problem describes one missing or invalid variable. +type Problem struct { + Var string + Reason string +} + +// Error lists every configuration problem found by Load. +type Error struct { + Problems []Problem +} + +func (e *Error) Error() string { + var b strings.Builder + b.WriteString("invalid configuration:") + for _, p := range e.Problems { + fmt.Fprintf(&b, "\n %s: %s", p.Var, p.Reason) + } + return b.String() +} + +// Secret holds a sensitive value. It never prints its contents. +type Secret struct { + value string +} + +// Reveal returns the secret value. Call it only where the value is used. +func (s Secret) Reveal() string { return s.value } + +// String redacts the secret so it cannot leak through fmt or logs. +func (s Secret) String() string { return "[REDACTED]" } + +// GoString redacts the secret for %#v. +func (s Secret) GoString() string { return "[REDACTED]" } + +// reader reads variables and collects every problem instead of stopping at +// the first one. +type reader struct { + lookup func(string) (string, bool) + problems []Problem +} + +func (r *reader) fail(name, format string, args ...any) { + r.problems = append(r.problems, Problem{Var: name, Reason: fmt.Sprintf(format, args...)}) +} + +// required returns the variable's value, recording a problem when it is +// unset or empty. +func (r *reader) required(name string) (string, bool) { + v, ok := r.lookup(name) + if !ok || strings.TrimSpace(v) == "" { + r.fail(name, "required but not set") + return "", false + } + return v, true +} + +func (r *reader) listenAddr(name string) string { + v, ok := r.required(name) + if !ok { + return "" + } + if _, _, err := net.SplitHostPort(v); err != nil { + r.fail(name, "invalid listen address %q (want host:port or :port)", v) + return "" + } + return v +} + +func (r *reader) httpURL(name string) *url.URL { + v, ok := r.required(name) + if !ok { + return nil + } + u, err := url.Parse(v) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + r.fail(name, "invalid URL %q (want absolute http:// or https:// URL)", v) + return nil + } + return u +} + +func (r *reader) dir(name string) string { + v, ok := r.required(name) + if !ok { + return "" + } + info, err := os.Stat(v) + if err != nil { + r.fail(name, "cannot access directory %q: %v", v, err) + return "" + } + if !info.IsDir() { + r.fail(name, "%q is not a directory", v) + return "" + } + return v +} + +func (r *reader) duration(name string) time.Duration { + v, ok := r.required(name) + if !ok { + return 0 + } + d, err := time.ParseDuration(v) + if err != nil { + r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v) + return 0 + } + if d <= 0 { + r.fail(name, "duration must be positive, got %q", v) + return 0 + } + return d +} + +// secretFile reads a secret from the file named by name, which must end in +// _FILE. Setting the secret inline (name without _FILE) is rejected. +func (r *reader) secretFile(name string) Secret { + inline := strings.TrimSuffix(name, "_FILE") + if _, set := r.lookup(inline); set { + r.fail(inline, "secrets must not be set inline; use %s", name) + } + path, ok := r.required(name) + if !ok { + return Secret{} + } + data, err := os.ReadFile(path) + if err != nil { + // Report the cause without echoing any file contents. + r.fail(name, "cannot read secret file %q: %v", path, unwrapPathError(err)) + return Secret{} + } + value := strings.TrimRight(string(data), "\r\n") + if strings.TrimSpace(value) == "" { + r.fail(name, "secret file %q is empty", path) + return Secret{} + } + return Secret{value: value} +} + +func unwrapPathError(err error) error { + var pe *os.PathError + if errors.As(err, &pe) { + return pe.Err + } + return err +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b617872 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,217 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +func TestLoad(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + secretPath := filepath.Join(dir, "session_secret") + if err := os.WriteFile(secretPath, []byte("s3cret-value\n"), 0o600); err != nil { + t.Fatal(err) + } + unreadablePath := filepath.Join(dir, "missing_secret") + emptyPath := filepath.Join(dir, "empty_secret") + if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil { + t.Fatal(err) + } + + valid := func() map[string]string { + return map[string]string{ + EnvHTTPAddr: ":8080", + EnvPublicURL: "https://meals.example.com", + EnvDataDir: dir, + EnvShutdownTimeout: "15s", + EnvSessionSecretFile: secretPath, + } + } + with := func(changes map[string]string, unset ...string) map[string]string { + env := valid() + for k, v := range changes { + env[k] = v + } + for _, k := range unset { + delete(env, k) + } + return env + } + + tests := []struct { + name string + env map[string]string + badVars []string // variables the error must name; nil means success + }{ + { + name: "all required vars present", + env: valid(), + }, + { + name: "one missing", + env: with(nil, EnvHTTPAddr), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "empty value counts as missing", + env: with(map[string]string{EnvDataDir: ""}), + badVars: []string{EnvDataDir}, + }, + { + name: "all missing are collected", + env: map[string]string{}, + badVars: []string{ + EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSessionSecretFile, + }, + }, + { + name: "invalid duration", + env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}), + badVars: []string{EnvShutdownTimeout}, + }, + { + name: "non-positive duration", + env: with(map[string]string{EnvShutdownTimeout: "0s"}), + badVars: []string{EnvShutdownTimeout}, + }, + { + name: "invalid listen address", + env: with(map[string]string{EnvHTTPAddr: "8080"}), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "relative public URL", + env: with(map[string]string{EnvPublicURL: "meals.example.com"}), + badVars: []string{EnvPublicURL}, + }, + { + name: "data dir does not exist", + env: with(map[string]string{EnvDataDir: filepath.Join(dir, "nope")}), + badVars: []string{EnvDataDir}, + }, + { + name: "unreadable secret file", + env: with(map[string]string{EnvSessionSecretFile: unreadablePath}), + badVars: []string{EnvSessionSecretFile}, + }, + { + name: "secret file path is a directory", + env: with(map[string]string{EnvSessionSecretFile: dir}), + badVars: []string{EnvSessionSecretFile}, + }, + { + name: "empty secret file", + env: with(map[string]string{EnvSessionSecretFile: emptyPath}), + badVars: []string{EnvSessionSecretFile}, + }, + { + name: "inline secret rejected", + env: with(map[string]string{"MEALQUEST_SESSION_SECRET": "inline"}), + badVars: []string{"MEALQUEST_SESSION_SECRET"}, + }, + { + name: "several invalid values are all named", + env: with(map[string]string{ + EnvShutdownTimeout: "soon", + EnvSessionSecretFile: unreadablePath, + }, EnvPublicURL), + badVars: []string{EnvPublicURL, EnvShutdownTimeout, EnvSessionSecretFile}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + lookup := func(k string) (string, bool) { + v, ok := tc.env[k] + return v, ok + } + cfg, err := load(lookup) + + if tc.badVars == nil { + if err != nil { + t.Fatalf("load() error = %v, want nil", err) + } + return + } + + var cerr *Error + if !errors.As(err, &cerr) { + t.Fatalf("load() error = %v, want *Error", err) + } + var got []string + for _, p := range cerr.Problems { + got = append(got, p.Var) + } + if !slices.Equal(got, tc.badVars) { + t.Errorf("problem vars = %v, want %v", got, tc.badVars) + } + for _, v := range tc.badVars { + if !strings.Contains(err.Error(), v) { + t.Errorf("error message %q does not name %s", err.Error(), v) + } + } + if cfg != (Config{}) { + t.Errorf("load() returned non-zero Config alongside error: %+v", cfg) + } + }) + } +} + +func TestLoadValues(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + secretPath := filepath.Join(dir, "session_secret") + if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil { + t.Fatal(err) + } + env := map[string]string{ + EnvHTTPAddr: "127.0.0.1:9000", + EnvPublicURL: "http://localhost:9000", + EnvDataDir: dir, + EnvShutdownTimeout: "1m30s", + EnvSessionSecretFile: secretPath, + } + cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) + if err != nil { + t.Fatalf("load() error = %v", err) + } + + if cfg.HTTPAddr != "127.0.0.1:9000" { + t.Errorf("HTTPAddr = %q", cfg.HTTPAddr) + } + if cfg.PublicURL.String() != "http://localhost:9000" { + t.Errorf("PublicURL = %q", cfg.PublicURL) + } + if cfg.DataDir != dir { + t.Errorf("DataDir = %q, want %q", cfg.DataDir, dir) + } + if cfg.ShutdownTimeout != 90*time.Second { + t.Errorf("ShutdownTimeout = %v, want 1m30s", cfg.ShutdownTimeout) + } + if cfg.SessionSecret.Reveal() != "s3cret-value" { + t.Errorf("SessionSecret.Reveal() = %q, want trailing newline trimmed", cfg.SessionSecret.Reveal()) + } +} + +func TestSecretRedacted(t *testing.T) { + t.Parallel() + + s := Secret{value: "s3cret-value"} + for _, format := range []string{"%v", "%s", "%+v", "%#v"} { + cfg := Config{SessionSecret: s} + for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} { + if strings.Contains(out, "s3cret-value") { + t.Errorf("format %s leaked secret: %s", format, out) + } + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..fa4f203 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,37 @@ +// Package server builds the MealQuest HTTP router. +package server + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// NewRouter returns the HTTP handler for the service. version is reported by +// GET /health. +func NewRouter(logger *slog.Logger, version string) http.Handler { + r := chi.NewRouter() + r.Get("/health", health(logger, version)) + return r +} + +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) + } + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..0e7e9f9 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealth(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(NewRouter(logger, "1.2.3")) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL + "/health") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + var got map[string]string + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatal(err) + } + want := map[string]string{"status": "ok", "version": "1.2.3"} + if len(got) != len(want) || got["status"] != want["status"] || got["version"] != want["version"] { + t.Errorf("body = %v, want %v", got, want) + } +} + +func TestHealthRejectsOtherMethods(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + rec := httptest.NewRecorder() + NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil)) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } +}