oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "log/slog"
8 "os"
9 "os/signal"
10 "strings"
11 "sync"
12 "syscall"
13 _ "time/tzdata" // the browser's zone for a call's title, wherever the host has no zoneinfo
14
15 "amadan.net/rastrillo/rastrillo"
16 "amadan.net/rastrillo/rastrillo/db"
17 "amadan.net/rastrillo/rastrillo/mail"
18
19 "amadan.net/oficina/calls/internal/calls"
20 "amadan.net/oficina/home/connect"
21)
22
23func main() {
24 // One writer for both handlers, so a text line and a JSON line are
25 // never interleaved mid-write.
26 stderr := &lockedWriter{w: os.Stderr}
27 logger := slog.New(slog.NewTextHandler(stderr, nil))
28 // Quality and problem reports are JSON lines (spec §15.26): the
29 // platform's `carlos logs --field` reads fields only from a line that
30 // is a JSON object.
31 reportLogger := slog.New(slog.NewJSONHandler(stderr, nil))
32
33 // The only os.Exit after startup. run owns every resource and
34 // returns its error only after its own defers have run: the sign-in
35 // mail queue's bounded drain, then the database close. An os.Exit
36 // inside it would skip them, and a failed HTTP shutdown (rastrillo's
37 // ten-second deadline passing with a request still running) used to
38 // cost a link already answered ?sent=1 (Astra on #25, round 3).
39 if err := run(logger, reportLogger); err != nil {
40 logger.Error("calls: exiting", "err", err)
41 os.Exit(1)
42 }
43}
44
45// run is main's body. Every failure returns, never exits; see main.
46func run(logger, reportLogger *slog.Logger) error {
47 // -env is stripped before Resolve sees argv, because Resolve parses
48 // os.Args itself for -socket, -addr and -db and a second flag set
49 // would refuse the platform's own activation arguments. Only the
50 // flag can select development: an env var that could would let a
51 // misconfigured box relax every refusal (config.go).
52 envFlag, rest, err := extractEnvFlag(os.Args[1:])
53 if err != nil {
54 return fmt.Errorf("read flags: %w", err)
55 }
56 os.Args = append(os.Args[:1], rest...)
57 env := &envFlag
58
59 opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "calls.db", Logger: logger})
60 if err != nil {
61 return fmt.Errorf("resolve activation: %w", err)
62 }
63
64 d, err := db.Open(opts.DBPath, logger)
65 if err != nil {
66 return fmt.Errorf("open database: %w", err)
67 }
68 defer d.Close()
69
70 // Raw: calls.resolve wraps every mailer in RedactMailErrors, so the
71 // invariant holds for whatever a caller passes, not only for what
72 // this main happens to build (review C1-2).
73 mailer := newMailer(logger)
74
75 cfg := calls.Config{
76 Home: connect.CredentialsFromEnv("CALLS"),
77 Env: *env,
78 Origin: os.Getenv("CALLS_ORIGIN"),
79 InstanceKey: os.Getenv("CALLS_INSTANCE_KEY"),
80 DEK: os.Getenv("CALLS_DEK"),
81 AllowedAddresses: strings.Split(os.Getenv("CALLS_ALLOWED_ADDRESSES"), ","),
82 Mailer: mailer,
83 Relay: os.Getenv("CARLOS_RELAY"),
84 Zone: os.Getenv("CALLS_ZONE"),
85 Logger: logger,
86 QualityReports: os.Getenv("CALLS_QUALITY_REPORTS"),
87 ReportLogger: reportLogger,
88 }
89
90 // Cancelled on SIGTERM, which is how a live SSE stream learns the
91 // process is stopping: rastrillo.Serve handles the same signal with
92 // a ten-second Shutdown, and Shutdown waits for handlers without
93 // cancelling their request contexts, so a stream that watched
94 // nothing would hold that whole deadline and turn a clean stop into
95 // a failed one. signal.Notify fans out, so rastrillo's own handler
96 // still fires. The hourly sweep and the hub tick end here too, at
97 // the moment the process is going down anyway (review C2-3).
98 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
99 defer stop()
100
101 mux, rt, err := calls.App(ctx, d, cfg)
102 if err != nil {
103 return fmt.Errorf("build app: %w", err)
104 }
105 // After rastrillo.Serve returns, however it returns, and before
106 // d.Close() above (defers run last-first): close the sign-in mail
107 // queue and wait for its bounded drain. Not at SIGTERM: Serve's
108 // Shutdown is still letting accepted requests finish then, and a
109 // link one of them queues must reach the relay rather than be
110 // refused or die with the process.
111 //
112 // The cutoff, when Shutdown times out: a link queued before this
113 // close is drained; a handler still running past rastrillo's
114 // deadline that reaches the queue afterwards is refused, and dies
115 // with the process like the rest of its request.
116 defer rt.Close()
117
118 opts.DBPath = ""
119 // The mux, the scheduled-wake poll and the error page, through the
120 // same calls.ServeOptions the CSP tests and the browser gate serve
121 // with, so what they check is what this serves.
122 opts = calls.ServeOptions(opts, mux, rt)
123 if err := rastrillo.Serve(opts); err != nil {
124 return fmt.Errorf("serve failed: %w", err)
125 }
126 return nil
127}
128
129// smtpSetting reads one SMTP setting, this app's own variable first and
130// the platform's as the fallback: $CALLS_SMTP_HOST, else $CARLOS_SMTP_HOST.
131// Per variable, not per set: `carlos email enable` delivers
132// CARLOS_SMTP_{HOST,PORT,USER,PASS,FROM}, and the common case is wanting
133// every one of them except FROM, which $CALLS_SMTP_FROM then overrides on
134// its own. Trimmed, because these arrive from an env block where a
135// trailing newline is a routine accident. Slides' shape, verbatim.
136func smtpSetting(name string) string {
137 if v := strings.TrimSpace(os.Getenv("CALLS_SMTP_" + name)); v != "" {
138 return v
139 }
140 return strings.TrimSpace(os.Getenv("CARLOS_SMTP_" + name))
141}
142
143// smtpSecret is smtpSetting for the two credentials, untrimmed: a
144// password's surrounding whitespace may be part of the password.
145func smtpSecret(name string) string {
146 if v := os.Getenv("CALLS_SMTP_" + name); v != "" {
147 return v
148 }
149 return os.Getenv("CARLOS_SMTP_" + name)
150}
151
152// newMailer builds the magic-link sender. HOST and FROM have no sane
153// default, so either missing means no relay, and a nil return is what
154// lets calls.App refuse to start outside development rather than log
155// live links.
156func newMailer(logger *slog.Logger) mail.Sender {
157 host, from := smtpSetting("HOST"), smtpSetting("FROM")
158 if host == "" || from == "" {
159 return nil
160 }
161 logger.Info("calls: SMTP configured", "host", host, "from", from)
162 return mail.SMTP(host, smtpSetting("PORT"), from, smtpSecret("USER"), smtpSecret("PASS"))
163}
164
165// extractEnvFlag pulls -env (in its four spellings) out of argv and
166// returns the rest untouched for rastrillo.Resolve. Slides' shape.
167func extractEnvFlag(args []string) (env string, rest []string, err error) {
168 for i := 0; i < len(args); i++ {
169 a := args[i]
170 switch {
171 case a == "-env" || a == "--env":
172 if i+1 >= len(args) {
173 return "", nil, fmt.Errorf("%s needs a value, for example: %s development", a, a)
174 }
175 env = args[i+1]
176 i++
177 continue
178 case strings.HasPrefix(a, "-env="):
179 env = strings.TrimPrefix(a, "-env=")
180 continue
181 case strings.HasPrefix(a, "--env="):
182 env = strings.TrimPrefix(a, "--env=")
183 continue
184 }
185 rest = append(rest, a)
186 }
187 return env, rest, nil
188}
189
190// lockedWriter serializes writes from the two log handlers.
191type lockedWriter struct {
192 mu sync.Mutex
193 w io.Writer
194}
195
196func (l *lockedWriter) Write(p []byte) (int, error) {
197 l.mu.Lock()
198 defer l.mu.Unlock()
199 return l.w.Write(p)
200}
201