oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Command slides wires the app: resolve the platform's activation
2// argv, read the environment, open the database, build the app, serve.
3package main
4
5import (
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.
36func 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.
77func 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.
103func 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.
115func 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.
134func 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 }`.
152func 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
172func 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.
273func 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