| 1 | package main |
| 2 | |
| 3 | import ( |
| 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 | |
| 23 | func 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. |
| 46 | func 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. |
| 136 | func 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. |
| 145 | func 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. |
| 156 | func 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. |
| 167 | func 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. |
| 191 | type lockedWriter struct { |
| 192 | mu sync.Mutex |
| 193 | w io.Writer |
| 194 | } |
| 195 | |
| 196 | func (l *lockedWriter) Write(p []byte) (int, error) { |
| 197 | l.mu.Lock() |
| 198 | defer l.mu.Unlock() |
| 199 | return l.w.Write(p) |
| 200 | } |
| 201 | |