app: sign-in, sessions, and the shell around them
Magic link only, no passkey second factor: passkey.Config derives its relying party from this instance's own host, so somebody signed into three team instances of this suite would enrol three separate passkeys for what they experience as one product. Same ruling docs made. The admission gate is wired twice, and the two wirings defend different things. rastrillo/auth checks Config.Authorize at the CALLBACK, after Begin has already emailed the link — so gating only Authorize leaves this app's mail-sending identity an open relay for any address a stranger types into the form. gateBegin stops the mail going out; Authorize stops a link minted for an address that is no longer permitted from minting a session. Both have a test. A refused sign-in answers byte-for-byte identically to an accepted one, asserted rather than assumed: any observable difference — a status, a Location, a differently-cased query — makes the endpoint a membership oracle for the allowlist. Every fail-closed setting refuses at boot rather than defaulting: origin, instance key, mailer, allowlist. The allowlist one is the interesting refusal — an instance that silently admitted nobody would look identical from outside to one working correctly, because the oracle-proofing above makes a refused submission indistinguishable from a genuine one. Down gets fixed the same day; quietly closed does not. chooseEnv is deliberately asymmetric: "development" is honoured only from the literal -env flag, never from the environment. Platform instance config IS environment variables, so a stray $CALENDAR_ENV=development one line from its neighbours would be a production-reachable path to the public dev instance key, an allowlist that admits everyone, and every sign-in link landing in the log with its token intact. Light/dark is three states, not two, and auto is the ABSENCE of data-theme rather than a value. The attribute takes the light/dark scheme, never rastrillo's theme name — writing "day" there matches neither toggle rule and the switcher silently does nothing. The whole switcher is a form; somebody who needs a high-contrast theme is not well served by being told to enable JavaScript first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QG3FX6qzynri8yTgMUm7q7
23 files changed,
+1390
−169
cmd/calendar/main.go+96 −25go.mod+5 −1go.sum+2 −0internal/calendar/app.go+136 −31internal/calendar/auth.go+252 −0internal/calendar/config.go+106 −0internal/calendar/handlers.go+83 −8internal/calendar/migrations.go+13 −16internal/calendar/migrations/0001_create_people.sql+4 −0internal/calendar/migrations/0001_init.sql+0 −2internal/calendar/migrations/schema.sql+3 −1internal/calendar/models.go+30 −16internal/calendar/render.go+83 −24internal/calendar/store.go+76 −0internal/calendar/templates/errors.html+2 −1internal/calendar/templates/index.html+9 −1internal/calendar/templates/layout.html+39 −3internal/calendar/templates/signin.html+31 −0internal/calendar/testing.go+123 −0internal/calendar/theme.go+106 −0internal/calendartest/auth_test.go+146 −0internal/calendartest/harness_test.go+29 −27internal/calendartest/index_test.go+16 −13
diff --git a/cmd/calendar/main.go b/cmd/calendar/main.go| index ac51405..a016cb0 100644 |
| --- a/cmd/calendar/main.go |
| +++ b/cmd/calendar/main.go |
| @@ -1,13 +1,24 @@ |
| // Command calendar wires the app: resolve the platform's activation |
| -// argv, open the database, build the app, serve. |
| +// argv, build the app, serve. |
| +// |
| +// The platform activation contract has no $PORT. The platform — or a |
| +// developer — invokes this binary as |
| +// |
| +// calendar --socket <path> --db <path> |
| +// |
| +// and rastrillo.Resolve/Serve own that contract end to end: activation |
| +// argv, LISTEN_FDS, $STATE_DIRECTORY, GET /healthz, GET /api/version, |
| +// SIGTERM drain and the baseline security headers. None of it is |
| +// hand-rolled here. |
| package main |
| import ( |
| "log/slog" |
| "os" |
| + "strings" |
| "github.com/carlosframework/rastrillo" |
| - "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/mail" |
| calendar "amadan.net/oficina/calendar/internal/calendar" |
| ) |
| @@ -15,48 +26,108 @@ import ( |
| func main() { |
| logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) |
| - // Resolve, not Run: this app opens its own database handle via |
| - // db.Open — a *gorm.DB with a split reader/writer pool, not the |
| - // bare *sql.DB Options.Router would hand back — so Options.DBPath |
| - // must be empty again by the Serve call below, or Serve would open |
| - // a second, unused connection to the same file. |
| + // -env is stripped from argv before Resolve sees it: Resolve's own |
| + // flag set knows only -socket, -addr and -db, and errors on |
| + // anything else. |
| + envFlag, rest := extractEnvFlag(os.Args[1:]) |
| + os.Args = append(os.Args[:1], rest...) |
| + |
| + // Resolve, not Run: this app opens its own database handle inside |
| + // calendar.New, so Options.DBPath must be empty again by the Serve |
| + // call below or Serve would open a second connection to the same |
| + // file. Run re-parses argv and would repopulate it. |
| opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "calendar.db", Logger: logger}) |
| if err != nil { |
| logger.Error("resolve activation", "err", err) |
| os.Exit(1) |
| } |
| - d, err := db.Open(opts.DBPath, logger) |
| - if err != nil { |
| - logger.Error("open database", "err", err) |
| - os.Exit(1) |
| + origin := os.Getenv("CALENDAR_ORIGIN") |
| + if origin == "" && opts.Addr != "" { |
| + origin = "http://" + opts.Addr |
| } |
| - defer d.Close() |
| - // Origin decides the CSRF same-origin check (and, when the app |
| - // grows accounts, the Secure/__Host- cookie attributes) — so |
| - // defaulting it silently in production would be a real bug. Loud |
| - // on purpose, harmless in local dev. |
| - origin := os.Getenv("CALENDAR_ORIGIN") |
| - if origin == "" { |
| - origin = "http://localhost:8080" |
| - logger.Warn("CALENDAR_ORIGIN not set; defaulting", "origin", origin) |
| + // A mailer only when both halves are configured. mail.FromEnv reads |
| + // the rest of the CALENDAR_SMTP_* set itself; without a host and a |
| + // from address there is nothing to build, and calendar.New's own |
| + // fail-closed rule turns that into a startup refusal outside |
| + // development rather than a silent no-mail instance. |
| + var mailer mail.Sender |
| + if host := strings.TrimSpace(os.Getenv("CALENDAR_SMTP_HOST")); host != "" { |
| + if from := strings.TrimSpace(os.Getenv("CALENDAR_SMTP_FROM")); from != "" { |
| + mailer = mail.FromEnv("CALENDAR", logger) |
| + } |
| } |
| - mux, err := calendar.App(d, origin, logger) |
| + app, err := calendar.New(calendar.Config{ |
| + DBPath: opts.DBPath, |
| + Env: chooseEnv(envFlag, os.Getenv("CALENDAR_ENV")), |
| + Origin: origin, |
| + InstanceKey: os.Getenv("CALENDAR_INSTANCE_KEY"), |
| + Mailer: mailer, |
| + AllowedAddresses: os.Getenv("CALENDAR_ALLOWED_ADDRESSES"), |
| + Logger: logger, |
| + }) |
| if err != nil { |
| logger.Error("build app", "err", err) |
| os.Exit(1) |
| } |
| + defer app.Close() |
| - opts.Mux = mux |
| + opts.Mux = app.Handler() |
| opts.DBPath = "" |
| - // The same page a handler's own error path renders (render.go's |
| - // ErrorPage — wire it to Ctx.ErrorPage too as the app grows |
| - // handlers), so a panic and a handled failure look identical. |
| + // The same page a handler's own error path renders, so a panic and |
| + // a handled failure look identical to whoever hits one. |
| opts.ErrorPage = calendar.ErrorPage |
| if err := rastrillo.Serve(opts); err != nil { |
| logger.Error("serve failed", "err", err) |
| os.Exit(1) |
| } |
| } |
| + |
| +// chooseEnv decides the environment, and is deliberately asymmetric. |
| +// |
| +// "development" is honoured ONLY from the literal -env flag, never from |
| +// the process environment. Platform instance configuration IS |
| +// environment variables — $CALENDAR_ORIGIN, $CALENDAR_INSTANCE_KEY, the |
| +// SMTP set — so a stray $CALENDAR_ENV=development sitting one line away |
| +// from its neighbours in the same config block would be a |
| +// production-reachable path to the public development instance key, to |
| +// an allowlist that admits everyone, and to every sign-in link landing |
| +// in the instance's own log with its token intact. |
| +// |
| +// Anything else from the environment is taken at face value; the |
| +// default, and the answer for an empty environment, is production. |
| +func chooseEnv(flagEnv, osEnv string) string { |
| + if flagEnv != "" { |
| + return flagEnv |
| + } |
| + if osEnv != "" && osEnv != "development" { |
| + return osEnv |
| + } |
| + return "production" |
| +} |
| + |
| +// extractEnvFlag pulls -env/--env (space- or =-separated) out of args, |
| +// returning its value and everything else in order. |
| +func extractEnvFlag(args []string) (string, []string) { |
| + var env string |
| + rest := make([]string, 0, len(args)) |
| + for i := 0; i < len(args); i++ { |
| + a := args[i] |
| + switch { |
| + case a == "-env" || a == "--env": |
| + if i+1 < len(args) { |
| + env = args[i+1] |
| + i++ |
| + } |
| + case strings.HasPrefix(a, "-env="): |
| + env = strings.TrimPrefix(a, "-env=") |
| + case strings.HasPrefix(a, "--env="): |
| + env = strings.TrimPrefix(a, "--env=") |
| + default: |
| + rest = append(rest, a) |
| + } |
| + } |
| + return env, rest |
| +} |
diff --git a/go.mod b/go.mod| index c1305c2..440a7a7 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -6,9 +6,11 @@ require ( |
| github.com/carlosframework/rastrillo v0.23.0 |
| github.com/chromedp/chromedp v0.14.2 |
| github.com/go-chi/chi/v5 v5.3.2 |
| + gorm.io/gorm v1.31.2 |
| ) |
| require ( |
| + github.com/BurntSushi/toml v1.6.0 // indirect |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect |
| github.com/chromedp/sysutil v1.1.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| @@ -19,15 +21,17 @@ require ( |
| github.com/google/uuid v1.6.0 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
| + github.com/keymaildev/signin v0.1.1 // indirect |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| golang.org/x/sys v0.46.0 // indirect |
| golang.org/x/text v0.20.0 // indirect |
| - gorm.io/gorm v1.31.2 // indirect |
| gorm.io/plugin/dbresolver v1.6.2 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
| modernc.org/memory v1.11.0 // indirect |
| modernc.org/sqlite v1.55.0 // indirect |
| ) |
| + |
| +tool github.com/carlosframework/rastrillo/cmd/rastrillo |
diff --git a/go.sum b/go.sum| index 9febd5c..9983f7d 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,3 +1,5 @@ |
| +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
| github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw= |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
diff --git a/internal/calendar/app.go b/internal/calendar/app.go| index c041d18..99aed35 100644 |
| --- a/internal/calendar/app.go |
| +++ b/internal/calendar/app.go |
| @@ -2,60 +2,165 @@ package calendar |
| import ( |
| "context" |
| + "fmt" |
| "log/slog" |
| "net/http" |
| "github.com/go-chi/chi/v5" |
| + rauth "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/rastrillo/csrf" |
| "github.com/carlosframework/rastrillo/db" |
| "github.com/carlosframework/rastrillo/migrate" |
| ) |
| -// App wires the whole app: schema, router, static files. It returns a |
| -// *http.ServeMux because rastrillo.Options.Mux is typed that way — the |
| -// chi router mounts inside it. origin is the app's external origin |
| -// ("https://app.example.com") — the CSRF check's yardstick, and, once |
| -// the app grows accounts, sessions.Config.Origin too. |
| +// App is the whole running app: the database it owns, the router it |
| +// serves, and the pieces handlers need. Build one with New and close it |
| +// with Close. |
| // |
| -// Growing the app is SKILL.md's five-file shape: models in models.go, |
| -// handlers in handlers.go, and — for a multi-user app — the sessions |
| -// core plus an identity plugin wired right here (examples/notes in the |
| -// rastrillo repo is the worked example to copy). Adding a subsystem |
| -// also means adding its Schema to migrations.go's BootSchema — see |
| -// the comment there. |
| -func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) { |
| - // Apply BootSchema, not Schema: BootSchema is everything this |
| - // app needs at boot (its own migrations plus any subsystem's), |
| - // while Schema (migrations.go) stays just this app's own so the |
| - // generator and its check never see a subsystem's tables and try |
| - // to drop them. |
| - migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| +// A constructor plus App.Handler() rather than SKILL.md's illustrative |
| +// bare App() function, because the test harness (testing.go) needs a |
| +// value it can hold onto — a signed-in request builder and the |
| +// development mail recorder both hang off this struct, and every later |
| +// test depends on them. |
| +type App struct { |
| + cfg Config |
| + db *db.DB |
| + mux *http.ServeMux |
| + logger *slog.Logger |
| + store *Store |
| + |
| + // mailRecorder is non-nil only in development — see buildMailer. |
| + mailRecorder *recordingMailer |
| + |
| + // auth is held so the harness can mint a session the same way a |
| + // real sign-in does, rather than by writing a session row by hand. |
| + auth *rauth.Auth |
| +} |
| + |
| +// New resolves every fail-closed setting, applies the schema, and wires |
| +// the router. Every refusal it can return is a misconfiguration that |
| +// would otherwise produce an instance which looks healthy and is not. |
| +func New(cfg Config) (*App, error) { |
| + logger := cfg.Logger |
| + if logger == nil { |
| + logger = slog.Default() |
| + } |
| + |
| + origin, err := resolveOrigin(cfg) |
| + if err != nil { |
| + return nil, err |
| + } |
| + cfg.Origin = origin |
| + |
| + instanceKey, err := resolveInstanceKey(cfg) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + mailer, recorder, err := buildMailer(cfg, logger) |
| if err != nil { |
| return nil, err |
| } |
| - // One line per boot. Result exists so this can be logged, and |
| - // until something does, a fleet-wide adoption — every database |
| - // stamped, no DDL run, exactly once, never again — produces no |
| - // output at all, which is the last event you want to be silent. |
| + |
| + list, err := resolveAllowlist(cfg) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + d, err := db.Open(cfg.DBPath, logger) |
| + if err != nil { |
| + return nil, fmt.Errorf("calendar: open database: %w", err) |
| + } |
| + |
| + // Apply BootSchema, not Schema: BootSchema is every subsystem's |
| + // migrations plus this app's own, in apply order, while Schema |
| + // stays narrow so the migration generator never sees a subsystem's |
| + // tables and proposes dropping them. |
| + migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| + if err != nil { |
| + d.Close() |
| + return nil, fmt.Errorf("calendar: apply schema: %w", err) |
| + } |
| + // One line per boot. Without it a fleet-wide adoption — every |
| + // database stamped, no DDL run, exactly once, never again — |
| + // produces no output at all, which is the last event that should |
| + // be silent. |
| logger.Info("migrate", "applied", migrated.Applied, |
| "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| - a := &app{db: d, logger: logger} |
| + // The writer pool, not a second connection: sessions and auth are |
| + // database/sql packages and take a *sql.DB, and db.Open's handle is |
| + // already the one with the single-writer routing. Opening our own |
| + // here would be a second writer against a WAL database that expects |
| + // exactly one. |
| + writer, err := d.G.DB() |
| + if err != nil { |
| + d.Close() |
| + return nil, fmt.Errorf("calendar: writer handle: %w", err) |
| + } |
| + |
| + authPlugin, err := newAuthPlugin(origin, writer, instanceKey, mailer, list, logger) |
| + if err != nil { |
| + d.Close() |
| + return nil, fmt.Errorf("calendar: wire sign-in: %w", err) |
| + } |
| + |
| + store := NewStore(d.G, logger) |
| + a := &App{ |
| + cfg: cfg, |
| + db: d, |
| + logger: logger, |
| + store: store, |
| + mailRecorder: recorder, |
| + auth: authPlugin, |
| + } |
| + h := &handlers{store: store, logger: logger, origin: origin} |
| r := chi.NewRouter() |
| - // App-wide, from day one: every state-changing route added below |
| - // is born covered. Origin-checking, not tokens — nothing to mint |
| - // or forget in a form. |
| + // App-wide, from day one, so every state-changing route added later |
| + // is born covered. Origin-checking, not tokens — nothing to mint or |
| + // forget in a form. |
| r.Use(csrf.Protect(origin)) |
| - r.Get("/", a.index) |
| + |
| + // Signed-out. |
| + r.Get("/signin", h.signin) |
| + r.Post("/signin", gateBegin(authPlugin.Begin, list, "/signin")) |
| + r.Get("/auth/callback", authPlugin.Callback) |
| + r.Get("/auth/verify", authPlugin.Verify) |
| + r.Post("/signout", authPlugin.Signout) |
| + |
| + // The light/dark switcher. Outside the session group on purpose: |
| + // the sign-in page is a page too, and someone who needs a |
| + // high-contrast theme should not have to sign in to get one. |
| + r.Post("/mode", h.mode) |
| + |
| + // Signed-in. |
| + r.Group(func(r chi.Router) { |
| + r.Use(authPlugin.RequireSession) |
| + r.Get("/", h.index) |
| + }) |
| mux := http.NewServeMux() |
| // The app serves its own static files — the framework never does. |
| - // They are embedded (render.go) and fingerprinted: templates link |
| - // them via {{asset ...}}, which returns a content-hashed URL the |
| - // handler serves cacheable-forever. |
| + // They are embedded and fingerprinted: templates link them via |
| + // {{asset ...}}, which returns a content-hashed URL this handler |
| + // serves cacheable-forever. |
| mux.Handle("GET /static/", assets.Handler()) |
| mux.Handle("/", r) |
| - return mux, nil |
| + a.mux = mux |
| + return a, nil |
| +} |
| + |
| +// Handler is the app's router, for rastrillo.Options.Mux and for |
| +// tests. It is typed *http.ServeMux rather than http.Handler because |
| +// Options.Mux is typed that way. |
| +func (a *App) Handler() *http.ServeMux { return a.mux } |
| + |
| +// Close releases the database. Safe to call on a partially built app. |
| +func (a *App) Close() error { |
| + if a.db == nil { |
| + return nil |
| + } |
| + return a.db.Close() |
| } |
diff --git a/internal/calendar/auth.go b/internal/calendar/auth.go| new file mode 100644 |
| index 0000000..b075613 |
| --- /dev/null |
| +++ b/internal/calendar/auth.go |
| @@ -0,0 +1,252 @@ |
| +// Sign-in is a magic link and nothing else — rastrillo/auth's turnkey |
| +// plugin: an emailed, single-use link, no password, no credential |
| +// enrolled. |
| +// |
| +// Deliberately no passkey second factor. passkey.Config derives its |
| +// relying party ID from this instance's own host, so a person signed |
| +// into three team instances of this suite would enrol three separate |
| +// passkeys for what they experience as one product. A magic link |
| +// enrols no credential at all, so it creates no relying party and the |
| +// question never arises. This is a ruling, not an omission to improve |
| +// on later; the same one docs made, for the same reason. |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "fmt" |
| + "log/slog" |
| + "net/http" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "sync" |
| + |
| + rauth "github.com/carlosframework/rastrillo/auth" |
| + "github.com/carlosframework/rastrillo/mail" |
| +) |
| + |
| +// authSchema aliases rauth.Schema so BootSchema (migrations.go) reads |
| +// as a list of subsystems rather than a mix of import aliases. Its |
| +// position there is load-bearing: auth's own backfill migration reads |
| +// the sessions table, so sessions.Schema must apply first. |
| +var authSchema = rauth.Schema |
| + |
| +// devInstanceKey is resolveInstanceKey's development-only fallback. It |
| +// is not a secret — it is a fixed, public string, exactly as dangerous |
| +// and exactly as acceptable as leaving the key empty would be, except |
| +// spelled out here rather than left implicit. It never seals anything |
| +// outside a throwaway development database. |
| +const devInstanceKey = "calendar-development-instance-key-not-a-secret" |
| + |
| +// resolveInstanceKey applies Config.InstanceKey's fail-closed rule: |
| +// non-empty always wins; empty is fine only in development; anywhere |
| +// else it is a startup refusal. |
| +func resolveInstanceKey(cfg Config) (string, error) { |
| + key := strings.TrimSpace(cfg.InstanceKey) |
| + if key != "" { |
| + return key, nil |
| + } |
| + if cfg.Env == "development" { |
| + return devInstanceKey, nil |
| + } |
| + return "", fmt.Errorf("calendar: Config.InstanceKey is empty and Env is %q, not %q — refusing to start with no seal for the sign-in link's pending blob; set $CALENDAR_INSTANCE_KEY", cfg.Env, "development") |
| +} |
| + |
| +// buildMailer applies Config.Mailer's fail-closed rule: an explicit |
| +// Mailer always wins; nil is fine only in development, where it falls |
| +// back to rastrillo/mail's logging sender. |
| +// |
| +// In development the chosen sender is always wrapped in a |
| +// *recordingMailer, whether or not Config.Mailer was already set: that |
| +// is what lets TestSignIn (testing.go) read the last mailed link back |
| +// without a mail server. The returned *recordingMailer is nil outside |
| +// development — it holds the plaintext of the last link sent in |
| +// memory, and a real deployment has no reason to carry that. |
| +func buildMailer(cfg Config, logger *slog.Logger) (mail.Sender, *recordingMailer, error) { |
| + sender := cfg.Mailer |
| + if sender == nil { |
| + if cfg.Env != "development" { |
| + return nil, nil, fmt.Errorf("calendar: mail is not configured (set $CALENDAR_SMTP_HOST and $CALENDAR_SMTP_FROM) and Env is %q, not %q — refusing to start: a sign-in that cannot send its link is not sign-in, and a reminder that cannot be delivered is not a reminder", cfg.Env, "development") |
| + } |
| + sender = mail.Logged(logger) |
| + } |
| + if cfg.Env != "development" { |
| + return sender, nil, nil |
| + } |
| + rec := &recordingMailer{next: sender} |
| + return rec, rec, nil |
| +} |
| + |
| +// recordingMailer wraps a mail.Sender, remembering the most recently |
| +// sent body so TestSignIn can read the magic link back out of it. It |
| +// still forwards every Send, so the ordinary dev behaviour — the link |
| +// lands in the log, clearly marked — is unchanged; this only adds a |
| +// second, in-memory place to find it. |
| +type recordingMailer struct { |
| + mu sync.Mutex |
| + next mail.Sender |
| + lastBody string |
| +} |
| + |
| +func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error { |
| + m.mu.Lock() |
| + m.lastBody = body |
| + m.mu.Unlock() |
| + return m.next.Send(ctx, to, subject, body) |
| +} |
| + |
| +func (m *recordingMailer) last() string { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + return m.lastBody |
| +} |
| + |
| +// magicLinkPattern finds the emailed link inside the sign-in mail body. |
| +// Anchored to this app's own callback path so it cannot mistake some |
| +// other URL the body might one day carry for the sign-in link itself. |
| +var magicLinkPattern = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`) |
| + |
| +// normalizeAddress puts an address into the one form the allowlist |
| +// both stores and compares: trimmed, lowercased. Shared by |
| +// resolveAllowlist and allowlist.allows so the two can never drift |
| +// into comparing differently-normalised forms of the same address. |
| +func normalizeAddress(address string) string { |
| + return strings.ToLower(strings.TrimSpace(address)) |
| +} |
| + |
| +// allowlist is Config.AllowedAddresses, parsed and normalised. |
| +// |
| +// addrs == nil means "admit every address" — resolveAllowlist's |
| +// development-only default, spelled out rather than left as an |
| +// accidental nil. addrs != nil means "admit nothing but exactly this |
| +// set". resolveAllowlist never returns a non-nil-but-empty map outside |
| +// development, since an empty parse there is a startup refusal. |
| +type allowlist struct { |
| + addrs map[string]struct{} |
| +} |
| + |
| +func (a allowlist) allows(address string) bool { |
| + if a.addrs == nil { |
| + return true |
| + } |
| + _, ok := a.addrs[normalizeAddress(address)] |
| + return ok |
| +} |
| + |
| +// resolveAllowlist applies Config.AllowedAddresses' fail-closed rule. |
| +// |
| +// Refusal, not "admit nobody" — a deliberate choice. An instance that |
| +// silently admitted nobody would boot looking entirely healthy: |
| +// /signin renders, POST /signin answers its ordinary 303 ?sent=1 on |
| +// every submission (gateBegin's whole point is that a refused |
| +// submission and a genuine one are byte-identical), and nothing would |
| +// surface the mistake short of somebody noticing that no link ever |
| +// arrives. A startup refusal is loud instead: the process does not |
| +// come up and whoever is deploying it sees that immediately. The |
| +// honest cost is that a misconfigured instance is down rather than |
| +// merely closed — accepted here because "down" gets fixed the same |
| +// day, while "quietly admits nobody" on a public hostname could sit |
| +// unnoticed indefinitely. |
| +func resolveAllowlist(cfg Config) (allowlist, error) { |
| + addrs := make(map[string]struct{}) |
| + for _, addr := range strings.Split(cfg.AllowedAddresses, ",") { |
| + addr = normalizeAddress(addr) |
| + if addr == "" { |
| + continue |
| + } |
| + addrs[addr] = struct{}{} |
| + } |
| + if len(addrs) > 0 { |
| + return allowlist{addrs: addrs}, nil |
| + } |
| + if cfg.Env == "development" { |
| + return allowlist{}, nil // addrs stays nil: admit everyone |
| + } |
| + return allowlist{}, fmt.Errorf("calendar: $CALENDAR_ALLOWED_ADDRESSES is empty and Env is %q, not %q — refusing to start with no allowlist configured; set it to this instance's address(es)", cfg.Env, "development") |
| +} |
| + |
| +// gateBegin wraps rauth.Auth.Begin so a non-permitted address never |
| +// reaches it. Begin is what sends the mail, while rauth.Config.Authorize |
| +// is checked afterwards, at the callback, once a link has already been |
| +// mailed and clicked — so gating only Authorize would leave this app's |
| +// mail-sending identity an open relay for any address a stranger types |
| +// into the form. |
| +// |
| +// A non-permitted address gets exactly the response Begin's own success |
| +// path sends: the same 303, to the same signinPath+"?sent=1", built the |
| +// same way. That is deliberate. Any observable difference — a different |
| +// status, a different body, even a differently-cased query string — |
| +// would turn this endpoint into a membership oracle, letting an |
| +// attacker learn which addresses this instance admits by watching how |
| +// its response to each one differs. |
| +// |
| +// force=1 is set unconditionally on every permitted request. Begin |
| +// otherwise auto-upgrades an address that resolves to a claimed keymail |
| +// inbox to the keymail ceremony, which redirects somewhere else |
| +// entirely — reopening the same oracle one level narrower, since an |
| +// attacker who suspects an address is keymail-hosted could tell "on the |
| +// allowlist" from "not" by which way the response went. This app never |
| +// wants that ceremony anyway (see the package comment), so forcing the |
| +// plain-link path here makes it a guarantee for every request this |
| +// handler forwards rather than only for the ones built by our own form. |
| +func gateBegin(next http.HandlerFunc, list allowlist, signinPath string) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + // A malformed body reads back as address == "" below, which is |
| + // never on a non-empty allowlist — the same answer Begin would |
| + // itself reach, just reached before any mail could go out. |
| + _ = r.ParseForm() |
| + if list.allows(r.FormValue("address")) { |
| + if r.Form == nil { |
| + r.Form = url.Values{} |
| + } |
| + r.Form.Set("force", "1") |
| + next(w, r) |
| + return |
| + } |
| + http.Redirect(w, r, signinPath+"?sent=1", http.StatusSeeOther) |
| + } |
| +} |
| + |
| +// newAuthPlugin builds the one *rauth.Auth this process shares. One per |
| +// process, never one per request: the underlying flow carries its |
| +// rate-limiter budget on the value, so a plugin per request would get a |
| +// fresh empty budget every time — no rate limiting at all, silently. |
| +// |
| +// Authorize is list.allows as well as gateBegin's gate. The two defend |
| +// different things: gateBegin stops the mail going out, Authorize stops |
| +// a link that was somehow minted for a non-permitted address (mailed |
| +// before the allowlist changed, say) from minting a session. |
| +func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger) (*rauth.Auth, error) { |
| + return rauth.New(rauth.Config{ |
| + DB: writer, |
| + Origin: origin, |
| + InstanceKey: instanceKey, |
| + Mailer: mailer, |
| + Authorize: list.allows, |
| + Subject: "Your calendar sign-in link", |
| + Logger: logger, |
| + }) |
| +} |
| + |
| +// linkPath extracts the request-target — path plus query, no scheme or |
| +// host — from an emailed magic link. It exists so the test harness does |
| +// not have to know the emailed link is absolute. |
| +func linkPath(link string) (string, error) { |
| + u, err := url.Parse(link) |
| + if err != nil { |
| + return "", fmt.Errorf("calendar: parse magic link %q: %w", link, err) |
| + } |
| + return u.RequestURI(), nil |
| +} |
| + |
| +// viewer is the signed-in identity on r, if any. |
| +// |
| +// auth.From, never sessions.UserID: under the magic-link plugin the |
| +// session Subject is a verified email address, so UserID returns |
| +// (0, false) and every owner-scoped query built on it would silently |
| +// filter to person_id = 0 — a calendar that looks empty rather than |
| +// broken, which is the more expensive failure. |
| +func viewer(r *http.Request) (rauth.Identity, bool) { |
| + return rauth.From(r) |
| +} |
diff --git a/internal/calendar/config.go b/internal/calendar/config.go| new file mode 100644 |
| index 0000000..c0ed75a |
| --- /dev/null |
| +++ b/internal/calendar/config.go |
| @@ -0,0 +1,106 @@ |
| +package calendar |
| + |
| +import ( |
| + "fmt" |
| + "log/slog" |
| + "net/url" |
| + "strings" |
| + |
| + "github.com/carlosframework/rastrillo/mail" |
| +) |
| + |
| +// DefaultOrigin is what an Env == "development" instance falls back to |
| +// when Config.Origin is empty. Every other Env refuses instead: see |
| +// resolveOrigin. |
| +const DefaultOrigin = "http://localhost:8080" |
| + |
| +// Config is everything the app needs from its environment, resolved by |
| +// cmd/calendar/main.go and handed to New. Every field with a |
| +// fail-closed rule states it here, and New applies the rule at boot — |
| +// the family's shape: a misconfigured instance refuses to start, never |
| +// quietly does the wrong thing. |
| +type Config struct { |
| + // DBPath is the SQLite file this instance owns. The platform hands |
| + // it in as activation argv; a developer passes -db. |
| + DBPath string |
| + |
| + // Env is "production" unless a developer said otherwise with the |
| + // literal -env flag. It is never honoured as "development" when it |
| + // arrives from the process environment — see chooseEnv in |
| + // cmd/calendar/main.go for why that one direction is asymmetric. |
| + Env string |
| + |
| + // Origin is this instance's external origin, scheme included |
| + // ("https://calendar.bds.oncarlos.com"). It is the CSRF check's |
| + // yardstick, the base of every emailed sign-in link, the decider |
| + // of Secure/__Host- cookie attributes, and — because a calendar |
| + // subscription URL has to be absolute to be subscribable — the |
| + // base of every published .ics feed URL. Empty is refused outside |
| + // development. |
| + Origin string |
| + |
| + // InstanceKey seals the sign-in flow's pending blob. Empty is |
| + // refused outside development: an empty key hashes to one fixed, |
| + // publicly computable value, identical across every deployment |
| + // that left it unset. |
| + InstanceKey string |
| + |
| + // Mailer delivers sign-in links, and the reminders themselves. |
| + // Nil is refused outside development — a sign-in that cannot send |
| + // its link is not sign-in, and a reminder that cannot be delivered |
| + // is not a reminder. |
| + Mailer mail.Sender |
| + |
| + // AllowedAddresses is the comma-separated admission allowlist |
| + // ($CALENDAR_ALLOWED_ADDRESSES). Empty is refused outside |
| + // development rather than defaulting to "admit nobody": an |
| + // instance that silently admitted nobody would look identical from |
| + // the outside to one working correctly. See resolveAllowlist. |
| + AllowedAddresses string |
| + |
| + Logger *slog.Logger |
| +} |
| + |
| +// resolveOrigin applies Config.Origin's fail-closed rule: a valid |
| +// non-empty origin always wins; empty is fine only in development, |
| +// where it defaults to DefaultOrigin; anywhere else it is a startup |
| +// refusal. |
| +func resolveOrigin(cfg Config) (string, error) { |
| + trimmed := strings.TrimSpace(cfg.Origin) |
| + switch { |
| + case trimmed == "" && cfg.Env != "development": |
| + return "", fmt.Errorf("calendar: Config.Origin is empty and Env is %q, not %q — refusing to start with an unset origin; set $CALENDAR_ORIGIN to this instance's real origin", cfg.Env, "development") |
| + case trimmed == "": |
| + return DefaultOrigin, nil |
| + } |
| + if err := validateOrigin(trimmed); err != nil { |
| + return "", fmt.Errorf("calendar: Config.Origin is invalid: %w", err) |
| + } |
| + return trimmed, nil |
| +} |
| + |
| +// validateOrigin rejects anything that is not a bare scheme://host[:port]. |
| +// A path, query or fragment on an origin is the tell that someone pasted |
| +// a page URL into the setting; carried into a magic link's base or the |
| +// CSRF yardstick it produces links that 404 and an origin comparison |
| +// that never matches, both of which surface later looking like unrelated |
| +// bugs. |
| +func validateOrigin(origin string) error { |
| + u, err := url.Parse(origin) |
| + if err != nil { |
| + return fmt.Errorf("parse %q: %w", origin, err) |
| + } |
| + if u.Scheme != "http" && u.Scheme != "https" { |
| + return fmt.Errorf("origin %q: scheme must be http or https, got %q", origin, u.Scheme) |
| + } |
| + // Hostname(), not Host: "http://:8099" parses with an empty |
| + // hostname and a port, and reads as valid to anything that only |
| + // checks Host != "". |
| + if u.Hostname() == "" { |
| + return fmt.Errorf("origin %q: no host", origin) |
| + } |
| + if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { |
| + return fmt.Errorf("origin %q: an origin is scheme://host[:port] and nothing else — no path, query, fragment or userinfo", origin) |
| + } |
| + return nil |
| +} |
diff --git a/internal/calendar/handlers.go b/internal/calendar/handlers.go| index dbd2b32..56c5282 100644 |
| --- a/internal/calendar/handlers.go |
| +++ b/internal/calendar/handlers.go |
| @@ -3,17 +3,92 @@ package calendar |
| import ( |
| "log/slog" |
| "net/http" |
| - |
| - "github.com/carlosframework/rastrillo/db" |
| + "strings" |
| ) |
| -// app holds what every handler needs. When the app grows accounts, |
| -// this is where the sessions handle lands too. |
| -type app struct { |
| - db *db.DB |
| +// handlers holds what every handler needs. One value, built in New. |
| +type handlers struct { |
| + store *Store |
| logger *slog.Logger |
| + |
| + // origin is this instance's external origin. Handlers read it to |
| + // decide Secure cookie attributes and to build the absolute URLs a |
| + // calendar subscription needs. |
| + origin string |
| +} |
| + |
| +// secure reports whether cookies this app sets should carry Secure. It |
| +// is derived from the origin rather than from the request, so a |
| +// misconfigured proxy cannot talk the app into dropping the attribute. |
| +func (h *handlers) secure() bool { |
| + return strings.HasPrefix(h.origin, "https://") |
| +} |
| + |
| +// safeReturn returns a same-site path to redirect back to after a form |
| +// post, or fallback when there is nothing safe to use. |
| +// |
| +// Same-site means starting with exactly one "/" — never "//host", |
| +// which browsers read as a protocol-relative URL to another origin and |
| +// which is the whole open-redirect bug in one extra character. A |
| +// backslash is refused for the same reason: some browsers normalise it |
| +// to a slash, so "/\evil.com" would otherwise pass this check and |
| +// leave anyway. |
| +func safeReturn(r *http.Request, fallback string) string { |
| + _ = r.ParseForm() |
| + to := r.FormValue("return_to") |
| + if to == "" { |
| + to = r.URL.Query().Get("return_to") |
| + } |
| + if len(to) < 1 || to[0] != '/' { |
| + return fallback |
| + } |
| + if strings.HasPrefix(to, "//") || strings.HasPrefix(to, "/\\") { |
| + return fallback |
| + } |
| + return to |
| +} |
| + |
| +// signin serves GET /signin — the form, and the landing spot for every |
| +// outcome the auth plugin's own handlers redirect back to. The plugin |
| +// never renders a page itself: it reports outcomes by redirecting here |
| +// with a query this handler reads. |
| +// |
| +// Every outcome on this page means the same operational thing — "sign |
| +// in did not complete, ask for a new link" — so the copy stays specific |
| +// where it can be and shares one honest sentence where it cannot. The |
| +// plugin deliberately refuses to distinguish unknown, used and expired |
| +// tokens from one another, so there is nothing finer this page could |
| +// draw. |
| +func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| + q := r.URL.Query() |
| + var d signinData |
| + if q.Get("sent") == "1" { |
| + d.Sent = "Check your email. The link works once, and expires shortly." |
| + } |
| + switch q.Get("err") { |
| + case "": |
| + case "rate": |
| + d.Error = "Too many attempts. Wait a few minutes, then try again." |
| + case "address": |
| + d.Error = "That does not look like an email address." |
| + case "expired": |
| + d.Error = "That link has expired. Ask for a new one." |
| + default: |
| + d.Error = "That link did not work. Ask for a new one." |
| + } |
| + render(w, "signin", h.page(r, "Sign in", d)) |
| +} |
| + |
| +// signinData is the sign-in page's own view data. Sent and Error are |
| +// mutually exclusive in practice but not by construction: somebody who |
| +// asks for a second link after a failed one should see both facts, not |
| +// whichever the template happened to check first. |
| +type signinData struct { |
| + Sent string |
| + Error string |
| } |
| -func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| - render(w, "index", nil) |
| +// index serves GET / — the calendar itself. |
| +func (h *handlers) index(w http.ResponseWriter, r *http.Request) { |
| + render(w, "index", h.page(r, "Calendar", nil)) |
| } |
diff --git a/internal/calendar/migrations.go b/internal/calendar/migrations.go| index a1a17fb..f07e315 100644 |
| --- a/internal/calendar/migrations.go |
| +++ b/internal/calendar/migrations.go |
| @@ -4,6 +4,7 @@ import ( |
| "embed" |
| "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| ) |
| //go:embed migrations/*.sql |
| @@ -23,20 +24,16 @@ var migrationFS embed.FS |
| // edit one that has shipped, add a new one instead. |
| var Schema = migrate.MustFromFS(migrationFS, "calendar") |
| -// BootSchema is everything applied at boot, in order — this app's own |
| -// Schema plus every framework subsystem's — and it is what App() below |
| -// hands to migrate.Apply. It is a separate variable from Schema for |
| -// the same reason Schema must stay narrow: generate and check would |
| -// misread a subsystem's tables as ones Models forgot. |
| +// BootSchema is everything applied at boot, in order — every framework |
| +// subsystem's migrations plus this app's own — and it is what New hands |
| +// to migrate.Apply. It is a separate variable from Schema for the same |
| +// reason Schema must stay narrow: generate and check would misread a |
| +// subsystem's tables as ones Models forgot, and propose dropping every |
| +// one of them. |
| // |
| -// A fresh scaffold uses no subsystem yet, so today this is just |
| -// Schema. Adding one (say, sessions) means editing this line, not |
| -// Schema: |
| -// |
| -// var BootSchema = migrate.Merge(sessions.Schema, Schema) |
| -// |
| -// Merge's argument order is apply order, so a subsystem whose |
| -// migrations another one reads (auth after sessions) goes first. |
| -// Get this line wrong and either boot silently skips a subsystem's |
| -// tables, or generate/check try to drop them. |
| -var BootSchema = migrate.Merge(Schema) |
| +// Merge's argument order is apply order, and this order is load-bearing |
| +// rather than alphabetical: auth's own backfill migration reads the |
| +// sessions table, so sessions.Schema must apply first. Get this line |
| +// wrong and boot either skips a subsystem's tables or fails on a |
| +// migration that references one that does not exist yet. |
| +var BootSchema = migrate.Merge(sessions.Schema, authSchema, Schema) |
diff --git a/internal/calendar/migrations/0001_create_people.sql b/internal/calendar/migrations/0001_create_people.sql| new file mode 100644 |
| index 0000000..2ff1f1a |
| --- /dev/null |
| +++ b/internal/calendar/migrations/0001_create_people.sql |
| @@ -0,0 +1,4 @@ |
| +CREATE TABLE `people` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text NOT NULL,`zone` text NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL); |
| + |
| +CREATE UNIQUE INDEX `idx_people_address` ON `people`(`address`); |
| + |
diff --git a/internal/calendar/migrations/0001_init.sql b/internal/calendar/migrations/0001_init.sql| deleted file mode 100644 |
| index c63a80d..0000000 |
| --- a/internal/calendar/migrations/0001_init.sql |
| +++ /dev/null |
| @@ -1,2 +0,0 @@ |
| -CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime); |
| - |
diff --git a/internal/calendar/migrations/schema.sql b/internal/calendar/migrations/schema.sql| index 239e296..a7194a6 100644 |
| --- a/internal/calendar/migrations/schema.sql |
| +++ b/internal/calendar/migrations/schema.sql |
| @@ -1,5 +1,7 @@ |
| -- Generated by rastrillo migration generate; DO NOT EDIT. |
| -- The schema every migration in this directory adds up to. |
| -CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime); |
| +CREATE TABLE `people` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text NOT NULL,`zone` text NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL); |
| + |
| +CREATE UNIQUE INDEX `idx_people_address` ON `people`(`address`); |
diff --git a/internal/calendar/models.go b/internal/calendar/models.go| index 23e8244..7121512 100644 |
| --- a/internal/calendar/models.go |
| +++ b/internal/calendar/models.go |
| @@ -3,23 +3,37 @@ package calendar |
| import "time" |
| // Models is every model the schema generator manages. Keep it in step |
| -// with the structs below: `rastrillo migration generate` reads |
| -// it to work out what the database should look like, and |
| -// `rastrillo migration check` fails CI when it and the |
| -// migrations disagree. |
| +// with the structs below: `rastrillo migration generate` reads it to |
| +// work out what the database should look like, and `rastrillo |
| +// migration check` fails CI when it and the migrations disagree. |
| var Models = []any{ |
| - &Note{}, |
| + &Person{}, |
| } |
| -// Note is a starting example model — rename it, replace it, add more |
| -// beside it. Whatever it becomes, the rule stays the same: every query |
| -// touching user-owned rows goes through scope.Owned (SKILL.md) — reads |
| -// AND writes — and a request body is never bound onto a model directly |
| -// (explicit map[string]any + .Select allowlist). |
| -type Note struct { |
| - ID int64 |
| - Title string |
| - Body string |
| - CreatedAt time.Time |
| - UpdatedAt time.Time |
| +// Person is an address that has signed in here, and the row everything |
| +// owned is scoped to. |
| +// |
| +// The indirection is not ceremony. Sign-in is a magic link, so the |
| +// session Subject is a verified email address, not a numeric id — |
| +// which is exactly why sessions.UserID is unusable under this plugin |
| +// and returns (0, false). Owning rows by an address column instead |
| +// would scatter a piece of personal data across every table and make |
| +// a change of address a migration; owning them by this row's id keeps |
| +// the address in one place. |
| +type Person struct { |
| + ID int64 `gorm:"primaryKey"` |
| + |
| + // Address is the verified sign-in address, normalised (trimmed, |
| + // lowercased) by normalizeAddress before it is ever written or |
| + // compared, so two spellings of one person cannot become two rows. |
| + Address string `gorm:"column:address;uniqueIndex;not null"` |
| + |
| + // Zone is the IANA time zone this person's calendar is read in |
| + // ("Europe/Dublin"). It is a display and interpretation setting, |
| + // not a property of any event: an event carries its own zone, and |
| + // this is the one a viewer sees it converted into. |
| + Zone string `gorm:"column:zone;not null"` |
| + |
| + CreatedAt time.Time `gorm:"column:created_at;not null"` |
| + UpdatedAt time.Time `gorm:"column:updated_at;not null"` |
| } |
diff --git a/internal/calendar/render.go b/internal/calendar/render.go| index 934ba97..59c311b 100644 |
| --- a/internal/calendar/render.go |
| +++ b/internal/calendar/render.go |
| @@ -17,56 +17,115 @@ var appFS embed.FS |
| // assets fingerprints static/: {{asset "static/tokens.css"}} in a |
| // template returns a content-hashed URL, served immutable-cacheable by |
| -// the handler App mounts. |
| +// the handler New mounts. |
| var assets = rastrillo.NewAssets(appFS) |
| +// pageNames is every page this app renders. Kept as a list rather than |
| +// discovered from the filesystem so a template that is never rendered |
| +// is a compile-time-visible mistake rather than dead weight nobody |
| +// notices. |
| +var pageNames = []string{"index", "signin", "errors"} |
| + |
| // pages is one *template.Template per page, each combining layout.html |
| -// with that page's own file — kept separate so every page can define a |
| +// with that page's own file — separate trees so every page can define a |
| // template named "content" without colliding with the others. |
| var pages = map[string]*template.Template{} |
| func init() { |
| - for _, name := range []string{"index", "errors"} { |
| + for _, name := range pageNames { |
| pages[name] = template.Must( |
| // ui's partials and their helpers first, so every page can |
| // call one without registering anything. WithIcons points |
| - // ui's two icon seams at the app's own set |
| - // (internal/calendar/icons, scaffolded by rastrillo new) — |
| - // both of them, always: passing only icon would leave |
| - // iconAssets on the framework default and silently break |
| - // cdn and js delivery. iconAssets is whatever <head> markup |
| - // the chosen delivery needs, empty for the vendored-inline |
| - // default, so the layout calls it unconditionally and |
| - // switching delivery later needs no template edit. |
| + // ui's two icon seams at the app's own set — both of them, |
| + // always: passing only icon would leave iconAssets on the |
| + // framework default and silently break cdn and js delivery. |
| template.Must(template.New("layout"). |
| Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| Funcs(template.FuncMap{"asset": assets.Path}). |
| ParseFS(ui.Templates(), "*.html")). |
| - // Then the app's own: the shell and this page's body. |
| ParseFS(appFS, "templates/layout.html", "templates/"+name+".html")) |
| } |
| } |
| -// render executes name's layout into a buffer before anything touches |
| -// the wire: a template error becomes a clean 500 instead of garbage |
| -// appended to a half-written page, and headers (flash cookies, say) |
| -// stay settable until the status line is written. |
| +// PageData is the shell every page renders inside. Data is NAMED |
| +// rather than embedded, so a template always says ".Data.Events" and |
| +// can never accidentally resolve a page field where it meant a shell |
| +// one, or the reverse. |
| +type PageData struct { |
| + // Title is the <title>, and usually the page's own <h1>. |
| + Title string |
| + |
| + // ThemeAttr is the value of <html data-theme>, or "" for auto — |
| + // which the layout renders as no attribute at all. See theme.go: |
| + // this is the light/dark SCHEME, never rastrillo's theme NAME. |
| + ThemeAttr string |
| + |
| + // Mode is the stored preference behind ThemeAttr ("auto", "light", |
| + // "dark"), so the switcher can mark its current choice. |
| + Mode string |
| + |
| + // Path is the current request path, so a form that posts elsewhere |
| + // and redirects back — the mode switcher — can name where "back" is. |
| + Path string |
| + |
| + // SignedIn and Address describe the viewer. Address is shown in the |
| + // account menu and nowhere else. |
| + SignedIn bool |
| + Address string |
| + |
| + Data any |
| +} |
| + |
| +// page builds the shell for a request. One place decides how the theme, |
| +// the viewer and the return path are read, so no handler can forget one |
| +// and render a page whose switcher silently does nothing. |
| +func (h *handlers) page(r *http.Request, title string, data any) PageData { |
| + mode := readMode(r) |
| + pd := PageData{ |
| + Title: title, |
| + ThemeAttr: themeAttr(mode), |
| + Mode: mode, |
| + Path: r.URL.Path, |
| + Data: data, |
| + } |
| + if id, ok := viewer(r); ok { |
| + pd.SignedIn = true |
| + pd.Address = id.Address |
| + } |
| + return pd |
| +} |
| + |
| +// render executes a page into a buffer before anything touches the |
| +// wire: a template error becomes a clean 500 instead of garbage |
| +// appended to a half-written page, and headers stay settable until the |
| +// status line is written. |
| +// |
| +// The error string is fixed and says nothing about what failed. |
| +// html/template's own errors quote the data being rendered, so passing |
| +// one through to the browser would be a way to read a stranger's |
| +// calendar back out of an error page. |
| func render(w http.ResponseWriter, name string, data any) { |
| var buf bytes.Buffer |
| if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil { |
| - http.Error(w, "something went wrong", http.StatusInternalServerError) |
| + http.Error(w, "Something went wrong.", http.StatusInternalServerError) |
| return |
| } |
| buf.WriteTo(w) |
| } |
| -// ErrorPage renders the "errors" page — templates/errors.html, which |
| -// wraps ui's error-page partial in the layout — for status inside ref |
| -// (rastrillo.NewRef, or empty when there is none to show). It is the |
| -// rastrillo.ErrorPageFunc cmd/calendar/main.go points Options.ErrorPage at, |
| -// so a panic gets the same page a handled failure would; wire it to |
| -// Ctx.ErrorPage too as the app grows handlers of its own. |
| +// ErrorPage renders the shared error page for status, with ref being |
| +// rastrillo's own request reference or empty when there is none. It is |
| +// the rastrillo.ErrorPageFunc cmd/calendar/main.go points |
| +// Options.ErrorPage at, so a panic gets the same page a handled failure |
| +// would. |
| func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| + mode := readMode(r) |
| w.WriteHeader(status) |
| - render(w, "errors", map[string]any{"Status": status, "Ref": ref}) |
| + render(w, "errors", PageData{ |
| + Title: "Something went wrong", |
| + ThemeAttr: themeAttr(mode), |
| + Mode: mode, |
| + Path: r.URL.Path, |
| + Data: map[string]any{"Status": status, "Ref": ref}, |
| + }) |
| } |
diff --git a/internal/calendar/store.go b/internal/calendar/store.go| new file mode 100644 |
| index 0000000..7a16c47 |
| --- /dev/null |
| +++ b/internal/calendar/store.go |
| @@ -0,0 +1,76 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log/slog" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// defaultZone is the zone a person gets before they have chosen one. |
| +// UTC, not the server's local zone: a server's zone is an accident of |
| +// where it was provisioned, and inheriting it would silently shift |
| +// everyone's day boundaries the first time the fleet moved. |
| +const defaultZone = "UTC" |
| + |
| +// Store is every database access this app makes. Handlers never touch |
| +// gorm directly — one seam, so owner scoping is applied in one place |
| +// and can be tested in one place. |
| +type Store struct { |
| + g *gorm.DB |
| + logger *slog.Logger |
| +} |
| + |
| +func NewStore(g *gorm.DB, logger *slog.Logger) *Store { |
| + return &Store{g: g, logger: logger} |
| +} |
| + |
| +// EnsurePerson returns the Person row for a verified address, creating |
| +// it on first sign-in. |
| +// |
| +// The address is normalised first, so the unique index sees one |
| +// spelling per person. The insert is an upsert on that index rather |
| +// than a read-then-write: two sign-in links clicked at the same moment |
| +// would otherwise both read "no row" and both insert. |
| +func (s *Store) EnsurePerson(ctx context.Context, address string) (Person, error) { |
| + addr := normalizeAddress(address) |
| + if addr == "" { |
| + return Person{}, errors.New("calendar: EnsurePerson: empty address") |
| + } |
| + now := time.Now().UTC() |
| + p := Person{Address: addr, Zone: defaultZone, CreatedAt: now, UpdatedAt: now} |
| + |
| + // OnConflict DoNothing, then read back: the row we want may be the |
| + // one this statement wrote or the one a concurrent sign-in wrote, |
| + // and either is correct. Reading back unconditionally is what makes |
| + // the two cases indistinguishable to the caller. |
| + if err := s.g.WithContext(ctx). |
| + Where(Person{Address: addr}). |
| + Attrs(Person{Zone: defaultZone, CreatedAt: now, UpdatedAt: now}). |
| + FirstOrCreate(&p).Error; err != nil { |
| + return Person{}, fmt.Errorf("calendar: ensure person: %w", err) |
| + } |
| + return p, nil |
| +} |
| + |
| +// PersonByAddress reads the row for an already-known address. A missing |
| +// row is (Person{}, false, nil) rather than an error: "this address has |
| +// never signed in" is an ordinary answer, not a failure. |
| +func (s *Store) PersonByAddress(ctx context.Context, address string) (Person, bool, error) { |
| + addr := normalizeAddress(address) |
| + if addr == "" { |
| + return Person{}, false, nil |
| + } |
| + var p Person |
| + err := s.g.WithContext(ctx).Where("address = ?", addr).First(&p).Error |
| + switch { |
| + case errors.Is(err, gorm.ErrRecordNotFound): |
| + return Person{}, false, nil |
| + case err != nil: |
| + return Person{}, false, fmt.Errorf("calendar: person by address: %w", err) |
| + } |
| + return p, true, nil |
| +} |
diff --git a/internal/calendar/templates/errors.html b/internal/calendar/templates/errors.html| index 73a0e8c..7fa0354 100644 |
| --- a/internal/calendar/templates/errors.html |
| +++ b/internal/calendar/templates/errors.html |
| @@ -6,4 +6,5 @@ |
| editing this file, or hand it Title/Body/HomeHref/BackHref to |
| override the partial's own catalog defaults; see |
| ui/partials/error-page.html for what it accepts. */}} |
| -{{define "content"}}{{template "error-page" dict "Status" .Status "Ref" .Ref}}{{end}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}}{{template "error-page" dict "Status" .Data.Status "Ref" .Data.Ref}}{{end}} |
diff --git a/internal/calendar/templates/index.html b/internal/calendar/templates/index.html| index dd9abab..7706037 100644 |
| --- a/internal/calendar/templates/index.html |
| +++ b/internal/calendar/templates/index.html |
| @@ -1,3 +1,11 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| {{define "content"}} |
| -<h1>Hello, World — this is a rastrillo app.</h1> |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>{{.Title}}</h1> |
| + </div> |
| +</header> |
| +{{template "empty-state" dict |
| + "Title" "Nothing here yet" |
| + "Body" "The calendar itself lands on this screen."}} |
| {{end}} |
diff --git a/internal/calendar/templates/layout.html b/internal/calendar/templates/layout.html| index 947b565..28fa126 100644 |
| --- a/internal/calendar/templates/layout.html |
| +++ b/internal/calendar/templates/layout.html |
| @@ -1,5 +1,11 @@ |
| {{define "layout"}}<!doctype html> |
| -<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}"> |
| +{{/* data-theme is ABSENT for auto, and that absence is the mechanism, |
| + not an omission: with no attribute the browser resolves against the |
| + operating system through the theme's own `color-scheme: light dark`. |
| + The value is the light/dark SCHEME — never rastrillo's theme NAME. |
| + Writing "day" here would match neither toggle rule in theme.css, and |
| + the switcher would silently do nothing. See theme.go. */}} |
| +<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}"{{if .ThemeAttr}} data-theme="{{.ThemeAttr}}"{{end}}> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| @@ -20,7 +26,7 @@ |
| <div rst-shell-topbar> |
| <a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a> |
| <header rst-shell-bar> |
| -{{block "brand" .}}<a rst-shell-brand href="/">{{T "rastrillo.ui.shell_home"}}</a>{{end}} |
| +{{block "brand" .}}<a rst-shell-brand href="/">Calendar</a>{{end}} |
| {{/* The narrow layout. Below 800px the bar's tail — nav, account, |
| locale — hides behind this disclosure; above it, tokens.css hides |
| the summary and gives [rst-shell-tail] display: contents, so the |
| @@ -66,7 +72,37 @@ |
| links that sit inside [rst-dropdown-menu]. Sidebar's account block |
| is the other shape — a bare slot in the rail — so moving a block |
| between the two shells needs an edit. */}} |
| -<div rst-dropdown-menu>{{block "account" .}}{{end}}</div> |
| +<div rst-dropdown-menu>{{block "account" .}} |
| +{{if .SignedIn}} |
| +{{/* The address is shown here and nowhere else on any page: it is how |
| + you check which account you are looking at, and it is the one piece |
| + of personal data this chrome carries. */}} |
| +<p rst-menu-group>{{.Address}}</p> |
| +{{end}} |
| + |
| +{{/* The whole light/dark switcher, as a plain form. No script, |
| + deliberately: somebody who needs a high-contrast theme is not well |
| + served by being told to enable JavaScript first. |
| + |
| + Three buttons, not a toggle, because there are three states and the |
| + third — auto — is not the midpoint of the other two. return_to |
| + carries the current path so the choice takes effect on the page |
| + being read rather than dropping the reader at the root. |
| + |
| + aria-pressed, not colour alone, marks the current choice. */}} |
| +<form rst-menu-group method="post" action="/mode"> |
| + <input type="hidden" name="return_to" value="{{.Path}}"> |
| + <button rst-btn="ghost compact" type="submit" name="mode" value="light" aria-pressed="{{if eq .Mode "light"}}true{{else}}false{{end}}">Light</button> |
| + <button rst-btn="ghost compact" type="submit" name="mode" value="dark" aria-pressed="{{if eq .Mode "dark"}}true{{else}}false{{end}}">Dark</button> |
| + <button rst-btn="ghost compact" type="submit" name="mode" value="auto" aria-pressed="{{if eq .Mode "auto"}}true{{else}}false{{end}}">Auto</button> |
| +</form> |
| + |
| +{{if .SignedIn}} |
| +<form rst-menu-group method="post" action="/signout"> |
| + <button rst-btn="ghost" type="submit">Sign out</button> |
| +</form> |
| +{{end}} |
| +{{end}}</div> |
| </details> |
| {{block "locale" .}}{{end}} |
| </div> |
diff --git a/internal/calendar/templates/signin.html b/internal/calendar/templates/signin.html| new file mode 100644 |
| index 0000000..e2bce4f |
| --- /dev/null |
| +++ b/internal/calendar/templates/signin.html |
| @@ -0,0 +1,31 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>{{.Title}}</h1> |
| + <p rst-page-header-sub>A calendar for one team, on one instance.</p> |
| + </div> |
| +</header> |
| + |
| +{{with .Data.Error}} |
| +{{/* Alert, not a plain callout: this reports a live problem with the |
| + thing the person just did, which is what role="alert" is for. */}} |
| +{{template "callout" dict "Tone" "negative" "Body" . "Alert" true}} |
| +{{end}} |
| + |
| +{{with .Data.Sent}} |
| +{{template "callout" dict "Tone" "positive" "Body" .}} |
| +{{end}} |
| + |
| +<form rst-form method="post" action="/signin"> |
| + {{template "field" dict |
| + "ID" "address" |
| + "Name" "address" |
| + "Label" "Email address" |
| + "Type" "email" |
| + "Autocomplete" "email" |
| + "Required" true |
| + "Hint" "We email you a link. There is no password to forget."}} |
| + {{template "form-foot" dict "Submit" "Email me a link"}} |
| +</form> |
| +{{end}} |
diff --git a/internal/calendar/testing.go b/internal/calendar/testing.go| new file mode 100644 |
| index 0000000..b993b5c |
| --- /dev/null |
| +++ b/internal/calendar/testing.go |
| @@ -0,0 +1,123 @@ |
| +package calendar |
| + |
| +import ( |
| + "fmt" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "strings" |
| +) |
| + |
| +// TB is the slice of *testing.T this file needs. |
| +// |
| +// An interface rather than *testing.T because this is an ordinary |
| +// source file, not a _test.go one — other packages' tests need these |
| +// helpers, which is exactly why they cannot live in a test file. Naming |
| +// *testing.T here would link the testing package, and its flag |
| +// registrations, into the shipping binary. |
| +type TB interface { |
| + Helper() |
| + Fatalf(format string, args ...any) |
| + Cleanup(f func()) |
| +} |
| + |
| +// TestOrigin is the origin NewForTest wires, and the same-origin |
| +// evidence a test client must present on a state-changing request. |
| +const TestOrigin = "http://app.test" |
| + |
| +// NewForTest builds the whole app over a fresh temporary database, |
| +// wired as cmd/calendar/main.go wires it, with Env fixed at |
| +// "development" — which is what lets the allowlist admit everyone and |
| +// the mail recorder capture sign-in links without a mail server. |
| +// |
| +// Env is set here rather than taken from the caller so no test can |
| +// accidentally exercise a production-shaped configuration and pass for |
| +// the wrong reason. |
| +func NewForTest(t TB, dir string) *App { |
| + t.Helper() |
| + app, err := New(Config{ |
| + DBPath: filepath.Join(dir, "calendar.db"), |
| + Env: "development", |
| + Origin: TestOrigin, |
| + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| + }) |
| + if err != nil { |
| + t.Fatalf("calendar.NewForTest: %v", err) |
| + } |
| + t.Cleanup(func() { app.Close() }) |
| + return app |
| +} |
| + |
| +// SignInForTest completes a real sign-in for address and returns the |
| +// session cookie it minted. |
| +// |
| +// It drives the actual flow — POST /signin, read the emailed link out |
| +// of the development mail recorder, follow it — rather than writing a |
| +// session row directly. A helper that forged a session would keep |
| +// passing after the real sign-in path broke, which is the one thing a |
| +// sign-in test must not do. |
| +func (a *App) SignInForTest(t TB, address string) *http.Cookie { |
| + t.Helper() |
| + if a.mailRecorder == nil { |
| + t.Fatalf("calendar: SignInForTest needs Env == \"development\" (no mail recorder is wired otherwise)") |
| + } |
| + |
| + form := url.Values{"address": {address}, "force": {"1"}} |
| + req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", TestOrigin) |
| + a.mux.ServeHTTP(httptest.NewRecorder(), req) |
| + |
| + link := magicLinkPattern.FindString(a.mailRecorder.last()) |
| + if link == "" { |
| + t.Fatalf("calendar: SignInForTest: no sign-in link was mailed for %q", address) |
| + } |
| + target, err := linkPath(link) |
| + if err != nil { |
| + t.Fatalf("calendar: SignInForTest: %v", err) |
| + } |
| + |
| + rec := httptest.NewRecorder() |
| + a.mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Name == a.auth.SessionCookie() { |
| + return c |
| + } |
| + } |
| + t.Fatalf("calendar: SignInForTest: following the link minted no session cookie (status %d)", rec.Code) |
| + return nil |
| +} |
| + |
| +// RequestForTest builds a request already carrying session, with the |
| +// same-origin evidence csrf.Protect requires on a state-changing |
| +// method. Every current browser sends that evidence on a form |
| +// submission; a test client must too, or every POST 403s for a reason |
| +// unrelated to what the test is checking. |
| +func RequestForTest(session *http.Cookie, method, target string, form url.Values) *http.Request { |
| + var req *http.Request |
| + if form == nil { |
| + req = httptest.NewRequest(method, target, nil) |
| + } else { |
| + req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + } |
| + if method != http.MethodGet && method != http.MethodHead { |
| + req.Header.Set("Origin", TestOrigin) |
| + } |
| + if session != nil { |
| + req.AddCookie(session) |
| + } |
| + return req |
| +} |
| + |
| +// LastMailForTest returns the body of the most recent message this app |
| +// sent, or an error outside development where nothing records one. |
| +func (a *App) LastMailForTest() (string, error) { |
| + if a.mailRecorder == nil { |
| + return "", fmt.Errorf("calendar: no mail recorder (Env is %q, not \"development\")", a.cfg.Env) |
| + } |
| + return a.mailRecorder.last(), nil |
| +} |
diff --git a/internal/calendar/theme.go b/internal/calendar/theme.go| new file mode 100644 |
| index 0000000..5d87469 |
| --- /dev/null |
| +++ b/internal/calendar/theme.go |
| @@ -0,0 +1,106 @@ |
| +package calendar |
| + |
| +import ( |
| + "net/http" |
| +) |
| + |
| +// modeCookie carries the reader's light/dark choice. A cookie, not a |
| +// row: the choice belongs to a browser rather than to an account, it |
| +// must be readable server-side before the first byte of HTML is |
| +// written, and a signed-out visitor on the sign-in page has no row to |
| +// read it from. |
| +const modeCookie = "calendar_mode" |
| + |
| +// Three states, not two. "auto" is the absence of a stored choice and |
| +// renders as the ABSENCE of the data-theme attribute — that is the |
| +// mechanism, not an omission. With no attribute the browser resolves |
| +// against the operating system through the theme's own |
| +// `color-scheme: light dark`; with one, the two rules at the foot of |
| +// theme.css re-resolve every light-dark() in the file and the explicit |
| +// choice beats the OS in both directions. |
| +// |
| +// The bug this names: data-theme takes the light/dark SCHEME, never |
| +// rastrillo's theme NAME. "day", "plain" and "signal" are palette |
| +// files picked once at scaffold time; feeding one of those to the |
| +// attribute matches neither toggle rule, so the switcher silently does |
| +// nothing. Sheets shipped exactly that and wrote a test to stop it |
| +// coming back. |
| +const ( |
| + modeAuto = "auto" |
| + modeLight = "light" |
| + modeDark = "dark" |
| +) |
| + |
| +// themeAttr maps a stored mode onto the value of <html data-theme>. |
| +// Auto — and anything unrecognised, including a hand-edited cookie — |
| +// maps to the empty string, which the layout renders as no attribute |
| +// at all. |
| +func themeAttr(mode string) string { |
| + switch mode { |
| + case modeLight: |
| + return modeLight |
| + case modeDark: |
| + return modeDark |
| + default: |
| + return "" |
| + } |
| +} |
| + |
| +// readMode returns the mode this request should render in. A missing or |
| +// unreadable cookie is auto, which is the correct answer for a first |
| +// visit and for a hostile value alike. |
| +func readMode(r *http.Request) string { |
| + c, err := r.Cookie(modeCookie) |
| + if err != nil { |
| + return modeAuto |
| + } |
| + switch c.Value { |
| + case modeLight, modeDark: |
| + return c.Value |
| + default: |
| + return modeAuto |
| + } |
| +} |
| + |
| +// mode handles POST /mode: the whole switcher, as a plain form. No |
| +// script, deliberately — somebody who needs a high-contrast theme is |
| +// not well served by being told to enable JavaScript first. |
| +// |
| +// It answers 303 back to where the person was, so the choice takes |
| +// effect on the page they were reading rather than dropping them at |
| +// the root. |
| +func (h *handlers) mode(w http.ResponseWriter, r *http.Request) { |
| + _ = r.ParseForm() |
| + mode := r.FormValue("mode") |
| + switch mode { |
| + case modeLight, modeDark: |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: modeCookie, |
| + Value: mode, |
| + Path: "/", |
| + MaxAge: int(oneYearSeconds), |
| + HttpOnly: true, |
| + SameSite: http.SameSiteLaxMode, |
| + Secure: h.secure(), |
| + }) |
| + default: |
| + // Auto is stored as the absence of the cookie, so choosing it |
| + // deletes rather than writes. One representation of "no |
| + // choice", so a cookie can never disagree with its own absence. |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: modeCookie, |
| + Value: "", |
| + Path: "/", |
| + MaxAge: -1, |
| + HttpOnly: true, |
| + SameSite: http.SameSiteLaxMode, |
| + Secure: h.secure(), |
| + }) |
| + } |
| + http.Redirect(w, r, safeReturn(r, "/"), http.StatusSeeOther) |
| +} |
| + |
| +// oneYearSeconds is the mode cookie's lifetime. A display preference |
| +// with no expiry to speak of; a year is simply longer than anyone |
| +// leaves a browser profile alone. |
| +const oneYearSeconds = 365 * 24 * 60 * 60 |
diff --git a/internal/calendartest/auth_test.go b/internal/calendartest/auth_test.go| new file mode 100644 |
| index 0000000..2c3bdbf |
| --- /dev/null |
| +++ b/internal/calendartest/auth_test.go |
| @@ -0,0 +1,146 @@ |
| +package calendartest |
| + |
| +import ( |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/url" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// Everything behind the session group is behind it. A test per route |
| +// would rot the moment somebody adds one, so this walks the list and |
| +// the list is the thing to extend. |
| +func TestSignInRequiredForEverything(t *testing.T) { |
| + h := newApp(t).Handler() |
| + for _, target := range []string{"/"} { |
| + rec := get(t, h, target) |
| + if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound { |
| + t.Errorf("GET %s signed out: status %d, want a redirect to sign-in", target, rec.Code) |
| + continue |
| + } |
| + if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/signin") { |
| + t.Errorf("GET %s signed out: Location = %q, want /signin", target, loc) |
| + } |
| + } |
| +} |
| + |
| +func TestSignInMintsASession(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + if session == nil || session.Value == "" { |
| + t.Fatal("sign-in minted no session cookie") |
| + } |
| + if rec := getAs(t, app.Handler(), session, "/"); rec.Code != http.StatusOK { |
| + t.Fatalf("GET / signed in: status %d, want 200", rec.Code) |
| + } |
| +} |
| + |
| +// The defect this pins is an open relay on our own mail-sending |
| +// identity: rastrillo/auth checks Config.Authorize at the CALLBACK, |
| +// after Begin has already emailed the link. Gating only Authorize |
| +// would let anyone POST any address and have this instance mail it a |
| +// working sign-in link. |
| +func TestSignInMailsNothingForANonPermittedAddress(t *testing.T) { |
| + app := newAppWithAllowlist(t, "allowed@example.com") |
| + h := app.Handler() |
| + |
| + post(t, h, "/signin", url.Values{"address": {"stranger@example.com"}}) |
| + if body, _ := app.LastMailForTest(); body != "" { |
| + t.Fatalf("a non-permitted address was mailed a sign-in link:\n%s", body) |
| + } |
| + |
| + post(t, h, "/signin", url.Values{"address": {"allowed@example.com"}}) |
| + body, err := app.LastMailForTest() |
| + if err != nil { |
| + t.Fatalf("LastMailForTest: %v", err) |
| + } |
| + if body == "" { |
| + t.Fatal("a permitted address was mailed nothing") |
| + } |
| +} |
| + |
| +// Any observable difference between the two responses turns this |
| +// endpoint into a membership oracle: an attacker could learn which |
| +// addresses this instance admits by watching how it answers each one. |
| +// Byte-for-byte, not merely "both are 303". |
| +func TestSignInAnswersIdenticallyForPermittedAndNot(t *testing.T) { |
| + h := newAppWithAllowlist(t, "allowed@example.com").Handler() |
| + |
| + yes := post(t, h, "/signin", url.Values{"address": {"allowed@example.com"}}) |
| + no := post(t, h, "/signin", url.Values{"address": {"stranger@example.com"}}) |
| + |
| + if yes.Code != no.Code { |
| + t.Errorf("status differs: permitted %d, non-permitted %d", yes.Code, no.Code) |
| + } |
| + if yes.Header().Get("Location") != no.Header().Get("Location") { |
| + t.Errorf("Location differs: permitted %q, non-permitted %q", |
| + yes.Header().Get("Location"), no.Header().Get("Location")) |
| + } |
| + if yes.Body.String() != no.Body.String() { |
| + t.Errorf("body differs:\npermitted: %q\nnon-permitted: %q", |
| + yes.Body.String(), no.Body.String()) |
| + } |
| +} |
| + |
| +// A link minted for an address that is no longer permitted must not |
| +// mint a session — the second of the two wirings, defending admission |
| +// itself rather than delivery. |
| +func TestASessionIsRefusedForANonPermittedAddress(t *testing.T) { |
| + app := newAppWithAllowlist(t, "allowed@example.com") |
| + h := app.Handler() |
| + |
| + post(t, h, "/signin", url.Values{"address": {"allowed@example.com"}}) |
| + body, err := app.LastMailForTest() |
| + if err != nil || body == "" { |
| + t.Fatalf("no link was mailed: %v", err) |
| + } |
| + |
| + // A second app, with a different allowlist, standing in for "the |
| + // allowlist changed between the link being mailed and clicked". |
| + other := newAppWithAllowlist(t, "somebody-else@example.com") |
| + link := magicLink(t, body) |
| + rec := get(t, other.Handler(), link) |
| + if rec.Code == http.StatusOK { |
| + t.Error("a link for a non-permitted address was accepted") |
| + } |
| +} |
| + |
| +// newAppWithAllowlist builds an app admitting only the given addresses. |
| +// Env stays "development" so mail is still recorded rather than sent; |
| +// the allowlist is what is under test, not the environment gate. |
| +func newAppWithAllowlist(t *testing.T, addresses ...string) *calendar.App { |
| + t.Helper() |
| + app, err := calendar.New(calendar.Config{ |
| + DBPath: filepath.Join(t.TempDir(), "calendar.db"), |
| + Env: "development", |
| + Origin: testOrigin, |
| + AllowedAddresses: strings.Join(addresses, ","), |
| + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| + }) |
| + if err != nil { |
| + t.Fatalf("calendar.New: %v", err) |
| + } |
| + t.Cleanup(func() { app.Close() }) |
| + return app |
| +} |
| + |
| +// magicLink pulls the request target out of a mailed sign-in link. |
| +func magicLink(t *testing.T, body string) string { |
| + t.Helper() |
| + i := strings.Index(body, "/auth/verify?token=") |
| + if i < 0 { |
| + t.Fatalf("no sign-in link in mail body:\n%s", body) |
| + } |
| + rest := body[i:] |
| + if j := strings.IndexAny(rest, " \r\n\t"); j >= 0 { |
| + rest = rest[:j] |
| + } |
| + if _, err := url.Parse(rest); err != nil { |
| + t.Fatalf("unparseable sign-in link %q: %v", rest, err) |
| + } |
| + return rest |
| +} |
diff --git a/internal/calendartest/harness_test.go b/internal/calendartest/harness_test.go| index 706f191..1ba5d52 100644 |
| --- a/internal/calendartest/harness_test.go |
| +++ b/internal/calendartest/harness_test.go |
| @@ -7,39 +7,30 @@ |
| package calendartest |
| import ( |
| - "io" |
| - "log/slog" |
| "net/http" |
| "net/http/httptest" |
| "net/url" |
| - "path/filepath" |
| - "strings" |
| "testing" |
| - "github.com/carlosframework/rastrillo/db" |
| - |
| calendar "amadan.net/oficina/calendar/internal/calendar" |
| ) |
| // testOrigin is the origin newApp wires and post presents as its |
| // same-origin evidence — the pair a browser form submission would be. |
| -const testOrigin = "http://app.test" |
| +const testOrigin = calendar.TestOrigin |
| + |
| +// newApp builds the whole app per test over a fresh temp database. |
| +func newApp(t *testing.T) *calendar.App { |
| + t.Helper() |
| + return calendar.NewForTest(t, t.TempDir()) |
| +} |
| -// newApp builds the whole app per test over a fresh temp database, |
| -// exactly as main.go does. |
| -func newApp(t *testing.T) http.Handler { |
| +// signedIn builds an app and a session cookie for address, by driving |
| +// the real sign-in flow. |
| +func signedIn(t *testing.T, address string) (*calendar.App, *http.Cookie) { |
| t.Helper() |
| - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| - d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| - if err != nil { |
| - t.Fatalf("db.Open: %v", err) |
| - } |
| - t.Cleanup(func() { d.Close() }) |
| - mux, err := calendar.App(d, testOrigin, logger) |
| - if err != nil { |
| - t.Fatalf("App: %v", err) |
| - } |
| - return mux |
| + app := newApp(t) |
| + return app, app.SignInForTest(t, address) |
| } |
| func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { |
| @@ -49,14 +40,25 @@ func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder |
| return rec |
| } |
| +// getAs is get with a session attached. |
| +func getAs(t *testing.T, h http.Handler, session *http.Cookie, target string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, calendar.RequestForTest(session, http.MethodGet, target, nil)) |
| + return rec |
| +} |
| + |
| func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { |
| t.Helper() |
| - req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| - // The same-origin evidence csrf.Protect requires — every current |
| - // browser sends it on a form POST; a test client must too. |
| - req.Header.Set("Origin", testOrigin) |
| rec := httptest.NewRecorder() |
| - h.ServeHTTP(rec, req) |
| + h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodPost, target, form)) |
| + return rec |
| +} |
| + |
| +// postAs is post with a session attached. |
| +func postAs(t *testing.T, h http.Handler, session *http.Cookie, target string, form url.Values) *httptest.ResponseRecorder { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, calendar.RequestForTest(session, http.MethodPost, target, form)) |
| return rec |
| } |
diff --git a/internal/calendartest/index_test.go b/internal/calendartest/index_test.go| index a2e1973..7877eaf 100644 |
| --- a/internal/calendartest/index_test.go |
| +++ b/internal/calendartest/index_test.go |
| @@ -10,25 +10,29 @@ import ( |
| calendar "amadan.net/oficina/calendar/internal/calendar" |
| ) |
| -// hashedStylesheet is the fingerprinted URL shape the index page |
| -// links: /static/tokens.<16 hex>.css. |
| +// hashedStylesheet is the fingerprinted URL shape a page links: |
| +// /static/tokens.<16 hex>.css. |
| var hashedStylesheet = regexp.MustCompile(`/static/tokens\.[0-9a-f]{16}\.css`) |
| -func TestIndexRenders(t *testing.T) { |
| - rec := get(t, newApp(t), "/") |
| +func TestIndexRendersForASignedInPerson(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + rec := getAs(t, app.Handler(), session, "/") |
| if rec.Code != http.StatusOK { |
| t.Fatalf("GET /: status %d, want 200", rec.Code) |
| } |
| - if !strings.Contains(rec.Body.String(), "this is a rastrillo app") { |
| + if !strings.Contains(rec.Body.String(), "<h1>Calendar</h1>") { |
| t.Errorf("GET /: page is missing its heading:\n%s", rec.Body.String()) |
| } |
| } |
| func TestIndexLinksFingerprintedStylesheet(t *testing.T) { |
| - h := newApp(t) |
| - href := hashedStylesheet.FindString(get(t, h, "/").Body.String()) |
| + // The sign-in page, not the index: this pins the asset pipeline, |
| + // which every page shares, and a signed-out page is the one every |
| + // visitor reaches. |
| + h := newApp(t).Handler() |
| + href := hashedStylesheet.FindString(get(t, h, "/signin").Body.String()) |
| if href == "" { |
| - t.Fatal("index page links no fingerprinted stylesheet") |
| + t.Fatal("sign-in page links no fingerprinted stylesheet") |
| } |
| rec := get(t, h, href) |
| if rec.Code != http.StatusOK { |
| @@ -42,7 +46,7 @@ func TestIndexLinksFingerprintedStylesheet(t *testing.T) { |
| // The bare name keeps working, just never long-cached — so a changed |
| // file always shows on an ordinary reload. |
| func TestBareAssetNameStaysFresh(t *testing.T) { |
| - rec := get(t, newApp(t), "/static/tokens.css") |
| + rec := get(t, newApp(t).Handler(), "/static/tokens.css") |
| if rec.Code != http.StatusOK { |
| t.Fatalf("GET /static/tokens.css: status %d, want 200", rec.Code) |
| } |
| @@ -51,10 +55,9 @@ func TestBareAssetNameStaysFresh(t *testing.T) { |
| } |
| } |
| -// ErrorPage is what main.go points Options.ErrorPage at (and the seam |
| -// to wire into Ctx.ErrorPage as handlers grow); called directly, it |
| -// pins that a panic's page is the framework's error-page partial, not |
| -// a stray net/http default. |
| +// ErrorPage is what main.go points Options.ErrorPage at; called |
| +// directly, it pins that a panic's page is the framework's error-page |
| +// partial rather than a stray net/http default. |
| func TestErrorPageRendersFrameworkPartial(t *testing.T) { |
| rec := httptest.NewRecorder() |
| calendar.ErrorPage(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusInternalServerError, "abc123") |