carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// Command contra wires the app: resolve the platform's activation argv, open
2// the database, build the app, serve.
3package main
4
5import (
6 "context"
7 "encoding/hex"
8 "log/slog"
9 "os"
10 "path/filepath"
11
12 "github.com/carlosframework/rastrillo"
13 "github.com/carlosframework/rastrillo/db"
14
15 contra "github.com/carlosframework/contra/internal/contra"
16 "github.com/carlosframework/contra/internal/contra/mail"
17)
18
19func main() {
20 logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
21
22 // Resolve, not Run: this app opens its own database handle via db.Open —
23 // a *gorm.DB with a split reader/writer pool, not the bare *sql.DB
24 // Options.Router would hand back — so Options.DBPath must be empty again
25 // by the Serve call below, or Serve would open a second, unused
26 // connection to the same file.
27 opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "contra.db", Logger: logger})
28 if err != nil {
29 logger.Error("resolve activation", "err", err)
30 os.Exit(1)
31 }
32
33 // CONTRA_DB_NAME selects which database file inside the platform's state
34 // directory to open. It exists so a preview instance can be started clean
35 // without anything in this binary being able to destroy data.
36 //
37 // The platform parks an instance's state when the instance is deleted —
38 // "parked data stays" — so recreating one does NOT reset it, and there is
39 // no purge verb. The alternatives were an endpoint that wipes the
40 // database, which is not a thing to put in a password manager however
41 // well guarded, or this: point at a different file and leave the old one
42 // where it is. Reversible, and destroys nothing.
43 //
44 // Only the base name is taken, never a path: the directory stays the one
45 // the platform resolved, so this cannot be used to read or write outside
46 // the state directory.
47 dbPath := opts.DBPath
48 if name := os.Getenv("CONTRA_DB_NAME"); name != "" {
49 if name != filepath.Base(name) || name == "." || name == ".." {
50 logger.Error("CONTRA_DB_NAME must be a bare file name", "got", name)
51 os.Exit(1)
52 }
53 dbPath = filepath.Join(filepath.Dir(opts.DBPath), name)
54 logger.Warn("opening a non-default database", "file", name)
55 }
56
57 d, err := db.Open(dbPath, logger)
58 if err != nil {
59 logger.Error("open database", "err", err)
60 os.Exit(1)
61 }
62 defer d.Close()
63
64 // Origin decides the CSRF same-origin check and the session cookie's
65 // Secure/__Host- attributes, so defaulting it silently in production
66 // would be a real bug. Loud on purpose, harmless in local dev.
67 origin := os.Getenv("CONTRA_ORIGIN")
68 if origin == "" {
69 origin = "http://localhost:8080"
70 logger.Warn("CONTRA_ORIGIN not set; defaulting", "origin", origin)
71 }
72
73 // The email pepper. Refused rather than invented: an ephemeral pepper
74 // would change every account's blind index on the next restart and make
75 // every one of them unreachable — data loss wearing a default's clothes.
76 //
77 // PROTOCOL.md wants this off the instance entirely, at the routing layer or
78 // in a KMS, so that a database-plus-bucket leak alone cannot unblind an
79 // address. Reading it from the environment is the interim, and the
80 // interim is worth naming out loud rather than forgetting.
81 pepperHex := os.Getenv("CONTRA_EMAIL_PEPPER")
82 if pepperHex == "" {
83 logger.Error("CONTRA_EMAIL_PEPPER is not set",
84 "why", "the email blind index needs a stable pepper; an ephemeral one "+
85 "would strand every existing account on the next restart",
86 "fix", "carlos secrets set --app contra CONTRA_EMAIL_PEPPER=<64 hex chars>")
87 os.Exit(1)
88 }
89 pepper, err := hex.DecodeString(pepperHex)
90 if err != nil || len(pepper) < 32 {
91 logger.Error("CONTRA_EMAIL_PEPPER must be at least 64 hex characters")
92 os.Exit(1)
93 }
94
95 // The mailer. A deployment without it boots and warns rather than
96 // refusing: everything except signup still works, and a hard failure here
97 // would take a running instance down over a feature it may not be using
98 // yet. `carlos email enable --app contra` is what fills it in.
99 mailer, err := mail.FromEnv()
100 if err != nil {
101 logger.Warn("no email configured; signup links cannot be sent",
102 "err", err, "fix", "carlos email enable --app contra")
103 mailer = nil
104 }
105
106 mux, err := contra.App(d, contra.Config{
107 Origin: origin, Pepper: pepper, Mailer: mailer,
108 }, logger)
109 if err != nil {
110 logger.Error("build app", "err", err)
111 os.Exit(1)
112 }
113
114 // Expired signup sessions and device handoffs. Both tables are already
115 // correct without this — every query carries its own expiry — so the
116 // sweep is not about correctness. It is that an unswept table becomes a
117 // log of who tried to sign up and when, and of when each account enrolled
118 // a device. Started here rather than inside App so a test gets a server
119 // with no background goroutine and no clock of its own.
120 sweepCtx, stopSweeping := context.WithCancel(context.Background())
121 defer stopSweeping()
122 go contra.SweepLoop(sweepCtx, d, logger)
123
124 opts.Mux = mux
125 opts.DBPath = ""
126 // The same page a handler's own error path renders, so a panic and a
127 // handled failure look identical.
128 opts.ErrorPage = contra.ErrorPage
129 if err := rastrillo.Serve(opts); err != nil {
130 logger.Error("serve failed", "err", err)
131 os.Exit(1)
132 }
133}
134