oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "amadan.net/oficina/home/connect"
5 "encoding/base64"
6 "fmt"
7 "log/slog"
8 "net/url"
9 "strings"
10
11 "amadan.net/rastrillo/rastrillo/mail"
12)
13
14// DefaultOrigin is what an Env == "development" instance falls back to
15// when Config.Origin is empty. Every other Env refuses instead: see
16// resolveOrigin.
17const DefaultOrigin = "http://localhost:8080"
18
19// Config is everything the app needs from its environment, resolved by
20// cmd/calendar/main.go and handed to New. Every field with a
21// fail-closed rule states it here, and New applies the rule at boot —
22// the family's shape: a misconfigured instance refuses to start, never
23// quietly does the wrong thing.
24type Config struct {
25 Home *connect.Credentials
26 // DBPath is the SQLite file this instance owns. The platform hands
27 // it in as activation argv; a developer passes -db.
28 DBPath string
29
30 // Env is "production" unless a developer said otherwise with the
31 // literal -env flag. It is never honoured as "development" when it
32 // arrives from the process environment — see chooseEnv in
33 // cmd/calendar/main.go for why that one direction is asymmetric.
34 Env string
35
36 // Origin is this instance's external origin, scheme included
37 // ("https://calendar.bds.oncarlos.com"). It is the CSRF check's
38 // yardstick, the base of every emailed sign-in link, the decider
39 // of Secure/__Host- cookie attributes, and — because a calendar
40 // subscription URL has to be absolute to be subscribable — the
41 // base of every published .ics feed URL. Empty is refused outside
42 // development.
43 Origin string
44
45 // InstanceKey seals the sign-in flow's pending blob. Empty is
46 // refused outside development: an empty key hashes to one fixed,
47 // publicly computable value, identical across every deployment
48 // that left it unset.
49 InstanceKey string
50
51 // Mailer delivers sign-in links, and the reminders themselves.
52 // Nil is refused outside development — a sign-in that cannot send
53 // its link is not sign-in, and a reminder that cannot be delivered
54 // is not a reminder.
55 Mailer mail.Sender
56
57 // DEK is the base64 data encryption key, exactly 32 bytes decoded
58 // ($CALENDAR_DEK, set with `carlos secrets set`). It seals every
59 // title, place and note, and it is never written to the database it
60 // decrypts. Empty is refused outside development: the one rule is
61 // not optional, and an instance that booted without a key would
62 // write plaintext into a file the platform replicates to object
63 // storage.
64 DEK string
65
66 // AllowedAddresses is the comma-separated admission allowlist
67 // ($CALENDAR_ALLOWED_ADDRESSES). Empty is refused outside
68 // development rather than defaulting to "admit nobody": an
69 // instance that silently admitted nobody would look identical from
70 // the outside to one working correctly. See resolveAllowlist.
71 AllowedAddresses string
72
73 Logger *slog.Logger
74}
75
76// resolveOrigin applies Config.Origin's fail-closed rule: a valid
77// non-empty origin always wins; empty is fine only in development,
78// where it defaults to DefaultOrigin; anywhere else it is a startup
79// refusal.
80func resolveOrigin(cfg Config) (string, error) {
81 trimmed := strings.TrimSpace(cfg.Origin)
82 switch {
83 case trimmed == "" && cfg.Env != "development":
84 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")
85 case trimmed == "":
86 return DefaultOrigin, nil
87 }
88 if err := validateOrigin(trimmed); err != nil {
89 return "", fmt.Errorf("calendar: Config.Origin is invalid: %w", err)
90 }
91 return trimmed, nil
92}
93
94// devDEK is resolveDEK's development-only fallback: a fixed, public,
95// all-zero key. Not a secret, and spelled out here rather than left
96// implicit so that nothing can mistake it for one. It seals nothing
97// outside a throwaway development database.
98var devDEK = make([]byte, 32)
99
100// resolveDEK applies Config.DEK's fail-closed rule. The length check is
101// exact rather than a minimum: AES-256 wants 32 bytes, and a shorter
102// value that got padded somewhere would produce ciphertext that looks
103// entirely fine and is not.
104func resolveDEK(cfg Config) ([]byte, error) {
105 raw := strings.TrimSpace(cfg.DEK)
106 if raw == "" {
107 if cfg.Env == "development" {
108 return devDEK, nil
109 }
110 return nil, fmt.Errorf("calendar: Config.DEK is empty and Env is %q, not %q — refusing to start: an event's contents are sealed at rest, and a boot without a key would write plaintext titles into a file the platform replicates to object storage; set $CALENDAR_DEK to 32 base64 bytes with `carlos secrets set`", cfg.Env, "development")
111 }
112 key, err := base64.StdEncoding.DecodeString(raw)
113 if err != nil {
114 return nil, fmt.Errorf("calendar: $CALENDAR_DEK is not valid base64: %w", err)
115 }
116 if len(key) != 32 {
117 return nil, fmt.Errorf("calendar: $CALENDAR_DEK decodes to %d bytes, want exactly 32", len(key))
118 }
119 return key, nil
120}
121
122// validateOrigin rejects anything that is not a bare scheme://host[:port].
123// A path, query or fragment on an origin is the tell that someone pasted
124// a page URL into the setting; carried into a magic link's base or the
125// CSRF yardstick it produces links that 404 and an origin comparison
126// that never matches, both of which surface later looking like unrelated
127// bugs.
128func validateOrigin(origin string) error {
129 u, err := url.Parse(origin)
130 if err != nil {
131 return fmt.Errorf("parse %q: %w", origin, err)
132 }
133 if u.Scheme != "http" && u.Scheme != "https" {
134 return fmt.Errorf("origin %q: scheme must be http or https, got %q", origin, u.Scheme)
135 }
136 // Hostname(), not Host: "http://:8099" parses with an empty
137 // hostname and a port, and reads as valid to anything that only
138 // checks Host != "".
139 if u.Hostname() == "" {
140 return fmt.Errorf("origin %q: no host", origin)
141 }
142 if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
143 return fmt.Errorf("origin %q: an origin is scheme://host[:port] and nothing else — no path, query, fragment or userinfo", origin)
144 }
145 return nil
146}
147