| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | const 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. |
| 24 | type 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. |
| 80 | func 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. |
| 98 | var 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. |
| 104 | func 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. |
| 128 | func 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 | |