| 1 | // Command slides wires the app: resolve the platform's activation |
| 2 | // argv, read the environment, open the database, build the app, serve. |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "amadan.net/oficina/home/connect" |
| 7 | "fmt" |
| 8 | "log/slog" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | |
| 13 | "amadan.net/rastrillo/rastrillo" |
| 14 | "amadan.net/rastrillo/rastrillo/blobs" |
| 15 | "amadan.net/rastrillo/rastrillo/db" |
| 16 | "amadan.net/rastrillo/rastrillo/mail" |
| 17 | |
| 18 | slides "amadan.net/oficina/slides/internal/slides" |
| 19 | ) |
| 20 | |
| 21 | // extractEnvFlag pulls "-env value"/"-env=value" (or the "--env" |
| 22 | // spelling) out of args, returning the value ("" if absent) and |
| 23 | // everything else unchanged, order preserved. |
| 24 | // |
| 25 | // It has to run before rastrillo.Resolve: Resolve parses os.Args[1:] |
| 26 | // with its own flag.FlagSet, which knows only -socket/-addr/-db and |
| 27 | // errors on anything else, so a flag this binary adds on top must be |
| 28 | // stripped out first. |
| 29 | // |
| 30 | // A trailing "-env" with nothing after it is an error, not an empty |
| 31 | // value. Silently reading as "" would make chooseEnv fall through to |
| 32 | // $SLIDES_ENV and then to production — so `slides -env` (a typo for |
| 33 | // `-env development`, or a truncated argv) would boot a production app |
| 34 | // while its operator believed they had asked for a development one. |
| 35 | // Refusing is the only answer that cannot be misread. |
| 36 | func extractEnvFlag(args []string) (env string, rest []string, err error) { |
| 37 | for i := 0; i < len(args); i++ { |
| 38 | a := args[i] |
| 39 | switch { |
| 40 | case a == "-env" || a == "--env": |
| 41 | if i+1 >= len(args) { |
| 42 | return "", nil, fmt.Errorf("%s needs a value, for example: %s development", a, a) |
| 43 | } |
| 44 | env = args[i+1] |
| 45 | i++ |
| 46 | continue |
| 47 | case strings.HasPrefix(a, "-env="): |
| 48 | env = strings.TrimPrefix(a, "-env=") |
| 49 | continue |
| 50 | case strings.HasPrefix(a, "--env="): |
| 51 | env = strings.TrimPrefix(a, "--env=") |
| 52 | continue |
| 53 | } |
| 54 | rest = append(rest, a) |
| 55 | } |
| 56 | return env, rest, nil |
| 57 | } |
| 58 | |
| 59 | // chooseEnv decides slides.Config.Env from -env's extracted value |
| 60 | // (flagEnv, "" when the flag was absent) and $SLIDES_ENV (osEnv). |
| 61 | // |
| 62 | // "development" is honoured ONLY from the flag. This is the rule Docs |
| 63 | // arrived at the hard way, and it is the whole safety story for every |
| 64 | // fail-closed default in config.go: platform instance configuration IS |
| 65 | // environment variables — $SLIDES_ORIGIN, $SLIDES_INSTANCE_KEY, |
| 66 | // $SLIDES_SMTP_* — so a bare $SLIDES_ENV=development sitting one line |
| 67 | // away from its neighbours in the same block would be a |
| 68 | // production-reachable path to the public constant standing in for the |
| 69 | // HMAC key that seals auth's pending blob, to an allowlist that admits |
| 70 | // everyone, and to every magic link — token included — landing in the |
| 71 | // instance's own log instead of being mailed. |
| 72 | // |
| 73 | // osEnv is still consulted for any OTHER value (a future "staging", |
| 74 | // say): every check in slides.New reads as "== development" or "!= |
| 75 | // development", so any non-"development" string already behaves exactly |
| 76 | // like production and there is nothing for $SLIDES_ENV to unlock. |
| 77 | func chooseEnv(flagEnv, osEnv string) string { |
| 78 | if flagEnv != "" { |
| 79 | return flagEnv |
| 80 | } |
| 81 | if osEnv != "" && osEnv != slides.EnvDevelopment { |
| 82 | return osEnv |
| 83 | } |
| 84 | return slides.EnvProduction |
| 85 | } |
| 86 | |
| 87 | // smtpSetting reads one SMTP setting, this app's own variable first and |
| 88 | // the platform's as the fallback: $SLIDES_SMTP_HOST, else |
| 89 | // $CARLOS_SMTP_HOST. |
| 90 | // |
| 91 | // Per variable, not per set. `carlos email enable` delivers |
| 92 | // CARLOS_SMTP_{HOST,PORT,USER,PASS,FROM}, and the common case is |
| 93 | // wanting every one of them except FROM — the address this app sends |
| 94 | // as — which $SLIDES_SMTP_FROM then overrides on its own. An |
| 95 | // all-or-nothing choice between the two prefixes would make that |
| 96 | // ordinary case impossible to express without restating the platform's |
| 97 | // own credentials in this app's variables, which is exactly the kind of |
| 98 | // copy that goes stale after a credential rotation. |
| 99 | // |
| 100 | // Trimmed, because these five arrive from a deploy-time env block or a |
| 101 | // secret store, where a trailing newline is a routine accident and a |
| 102 | // leading space in a hostname is never meant. |
| 103 | func smtpSetting(name string) string { |
| 104 | if v := strings.TrimSpace(os.Getenv("SLIDES_SMTP_" + name)); v != "" { |
| 105 | return v |
| 106 | } |
| 107 | return strings.TrimSpace(os.Getenv("CARLOS_SMTP_" + name)) |
| 108 | } |
| 109 | |
| 110 | // smtpSecret is smtpSetting for the two values that are credentials: |
| 111 | // same per-variable fallback, no trimming. rastrillo/mail.FromEnv does |
| 112 | // not trim these either, and for the same reason — a password's |
| 113 | // surrounding whitespace may be part of the password, and silently |
| 114 | // eating it would fail authentication with no way to see why. |
| 115 | func smtpSecret(name string) string { |
| 116 | if v := os.Getenv("SLIDES_SMTP_" + name); v != "" { |
| 117 | return v |
| 118 | } |
| 119 | return os.Getenv("CARLOS_SMTP_" + name) |
| 120 | } |
| 121 | |
| 122 | // newMailer builds the magic-link sender from the SMTP settings above. |
| 123 | // |
| 124 | // mail.SMTP directly rather than mail.FromEnv, because FromEnv reads |
| 125 | // one prefix and this needs the per-variable SLIDES-then-CARLOS |
| 126 | // fallback. It also falls back to a warning-and-log sender when HOST or |
| 127 | // FROM is missing, which is the wrong answer outside development: a nil |
| 128 | // return here is what lets slides.New refuse to start rather than |
| 129 | // quietly logging live credentials in a real deployment's log. |
| 130 | // |
| 131 | // HOST and FROM are the two values with no sane default, so either one |
| 132 | // missing means this instance has no relay. PORT defaults to 587 inside |
| 133 | // mail.SMTP; USER empty means anonymous submission. |
| 134 | func newMailer(logger *slog.Logger) mail.Sender { |
| 135 | host, from := smtpSetting("HOST"), smtpSetting("FROM") |
| 136 | if host == "" || from == "" { |
| 137 | return nil |
| 138 | } |
| 139 | logger.Info("slides: SMTP configured", "host", host, "from", from) |
| 140 | return mail.SMTP(host, smtpSetting("PORT"), from, smtpSecret("USER"), smtpSecret("PASS")) |
| 141 | } |
| 142 | |
| 143 | // newBlobs binds the object store: the platform's bucket when it |
| 144 | // delivered one, a directory beside the database in development, and |
| 145 | // nothing otherwise. |
| 146 | // |
| 147 | // S3FromEnv answers (nil, nil) — not an error — when the platform |
| 148 | // delivered no bucket, and a typed nil assigned straight into the |
| 149 | // blobs.Store interface would be a non-nil interface holding a nil |
| 150 | // pointer, panicking on first use rather than reading as absent. Hence |
| 151 | // the explicit nil check rather than `if err == nil { return s }`. |
| 152 | func newBlobs(env, dbPath string, logger *slog.Logger) (blobs.Store, error) { |
| 153 | s3, err := blobs.S3FromEnv() |
| 154 | if err != nil { |
| 155 | return nil, err |
| 156 | } |
| 157 | if s3 != nil { |
| 158 | return s3, nil |
| 159 | } |
| 160 | if env != slides.EnvDevelopment { |
| 161 | // No bucket outside development is not a refusal here: nothing |
| 162 | // in this slice stores bytes yet, and the screens that will do |
| 163 | // so are what should refuse. Said out loud so the silence is a |
| 164 | // decision rather than an oversight. |
| 165 | logger.Warn("slides: no object store bound (CARLOS_STORE_* unset)") |
| 166 | return nil, nil |
| 167 | } |
| 168 | root := filepath.Join(filepath.Dir(dbPath), "blobs") |
| 169 | return blobs.Dir(root) |
| 170 | } |
| 171 | |
| 172 | func main() { |
| 173 | logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) |
| 174 | |
| 175 | // -env is stripped before Resolve sees argv (extractEnvFlag), and |
| 176 | // it is the only thing that can ever select "development" |
| 177 | // (chooseEnv). |
| 178 | envFlag, rest, err := extractEnvFlag(os.Args[1:]) |
| 179 | if err != nil { |
| 180 | logger.Error("read flags", "err", err) |
| 181 | os.Exit(1) |
| 182 | } |
| 183 | os.Args = append(os.Args[:1], rest...) |
| 184 | env := chooseEnv(envFlag, os.Getenv("SLIDES_ENV")) |
| 185 | |
| 186 | // Resolve, not Run: this app opens its own database handle via |
| 187 | // db.Open — a *gorm.DB with a split reader/writer pool, not the |
| 188 | // bare *sql.DB Options.Router would hand back — so Options.DBPath |
| 189 | // must be empty again by the Serve call below, or Serve would open |
| 190 | // a second, unused connection to the same file. |
| 191 | opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "slides.db", Logger: logger}) |
| 192 | if err != nil { |
| 193 | logger.Error("resolve activation", "err", err) |
| 194 | os.Exit(1) |
| 195 | } |
| 196 | |
| 197 | // $SLIDES_ORIGIN is the deploy-time origin — the platform's real |
| 198 | // per-instance hostname belongs here. Falling back to a TCP -addr |
| 199 | // helps only the local path (scripts/run-dev.sh, or a person |
| 200 | // passing -addr by hand); that is exactly the host a browser |
| 201 | // loading such an instance sends back as its own Origin. The |
| 202 | // platform's real activation path is a unix socket with no -addr to |
| 203 | // derive anything from, so with $SLIDES_ORIGIN unset there this |
| 204 | // stays empty and slides.New refuses to start — rather than mailing |
| 205 | // sign-in links pointing at a placeholder. |
| 206 | origin := os.Getenv("SLIDES_ORIGIN") |
| 207 | if origin == "" && opts.Addr != "" { |
| 208 | origin = "http://" + opts.Addr |
| 209 | } |
| 210 | |
| 211 | store, err := newBlobs(env, opts.DBPath, logger) |
| 212 | if err != nil { |
| 213 | logger.Error("bind object store", "err", err) |
| 214 | os.Exit(1) |
| 215 | } |
| 216 | |
| 217 | d, err := db.Open(opts.DBPath, logger) |
| 218 | if err != nil { |
| 219 | logger.Error("open database", "err", err) |
| 220 | os.Exit(1) |
| 221 | } |
| 222 | defer d.Close() |
| 223 | |
| 224 | // Every empty value below is a startup refusal inside slides.New |
| 225 | // unless env is "development" — see config.go's package comment. |
| 226 | var homeConfig *connect.Credentials |
| 227 | hc := connect.Credentials{Issuer: os.Getenv("SLIDES_HOME_ISSUER"), ConsumerID: os.Getenv("SLIDES_HOME_CONSUMER_ID"), KeysBase64: os.Getenv("SLIDES_HOME_KEYS_BASE64"), DEKBase64: os.Getenv("SLIDES_HOME_DEK_BASE64")} |
| 228 | if hc.Issuer != "" || hc.ConsumerID != "" || hc.KeysBase64 != "" || hc.DEKBase64 != "" { |
| 229 | // The hub directory the app switcher's "All apps" links to. |
| 230 | // Optional, and unprefixed: the same name and the same value |
| 231 | // Home itself reads (connect.CredentialsFromEnv reads it the |
| 232 | // same way; this app builds Credentials by hand). Checked by |
| 233 | // connect.DecodeCredentials, so a malformed value refuses boot. |
| 234 | hc.Directory = os.Getenv("HOME_DIRECTORY_ORIGIN") |
| 235 | homeConfig = &hc |
| 236 | } |
| 237 | mux, a, err := slides.New(d, slides.Config{Home: homeConfig, |
| 238 | Origin: origin, |
| 239 | Env: env, |
| 240 | InstanceKey: os.Getenv("SLIDES_INSTANCE_KEY"), |
| 241 | Mailer: newMailer(logger), |
| 242 | // Split here rather than inside New so Config carries a value |
| 243 | // that is already the shape it means: a list of addresses, not |
| 244 | // a string that happens to have commas in it. |
| 245 | AllowedAddresses: splitList(os.Getenv("SLIDES_ALLOWED_ADDRESSES")), |
| 246 | Blobs: store, |
| 247 | Logger: logger, |
| 248 | }) |
| 249 | if err != nil { |
| 250 | logger.Error("build app", "err", err) |
| 251 | os.Exit(1) |
| 252 | } |
| 253 | // Releases the compiled deck core's wazero runtime on the way out — |
| 254 | // after rastrillo.Serve returns (a signal, or a fatal error), same |
| 255 | // as d.Close() above. |
| 256 | defer a.Close() |
| 257 | |
| 258 | // The router, the locales, the error page and the CSP |
| 259 | // (slides.ServeOptions, serving.go). |
| 260 | opts = a.ServeOptions(opts, mux) |
| 261 | opts.DBPath = "" |
| 262 | if err := rastrillo.Serve(opts); err != nil { |
| 263 | logger.Error("serve failed", "err", err) |
| 264 | os.Exit(1) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | // splitList parses a comma-separated environment value into the entries |
| 269 | // it names, dropping empties so a trailing comma or a stray space is |
| 270 | // not an entry. Normalization (case, surrounding whitespace) is |
| 271 | // slides.Config's own job, so that a caller filling it directly gets |
| 272 | // the same treatment. |
| 273 | func splitList(raw string) []string { |
| 274 | var out []string |
| 275 | for _, part := range strings.Split(raw, ",") { |
| 276 | if part = strings.TrimSpace(part); part != "" { |
| 277 | out = append(out, part) |
| 278 | } |
| 279 | } |
| 280 | return out |
| 281 | } |
| 282 | |