carlos / movement Public

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

Plain git — no account needed to clone.

Download

Download this file

1package movement
2
3import (
4 "errors"
5 "fmt"
6 "net/url"
7 "os"
8 "strings"
9
10 "github.com/carlosframework/rastrillo/mail"
11)
12
13// CanonicalHost is the one host this app is meant to serve in production.
14// It gates the placeholder-copy refusal (copy.go): a canary runs at
15// <canary>.movement.bdj.oncarlos.com and must be able to boot with
16// unreviewed copy, because the shipping cadence reviews branches on a
17// deployed canary and there is only one build.
18const CanonicalHost = "movement.bdj.oncarlos.com"
19
20// Env is the configuration this app refuses to start without.
21type Env struct {
22 Origin string
23 InstanceKey string
24 Admins []string
25
26 // Mailer, when set, replaces the platform SMTP sender. LoadEnv never
27 // fills it: it is the seam main.go could use to wire something else and
28 // that tests use to read what was sent. Left nil, App builds the real one
29 // from CARLOS_SMTP_*.
30 Mailer mail.Sender
31}
32
33// loopback reports whether origin is a local development origin.
34//
35// Only the mail refusal below is relaxed for loopback, and the check is
36// deliberately narrow: an instance claiming a loopback origin in production
37// is a misconfiguration worth seeing loudly rather than quietly excusing.
38func loopback(origin string) bool {
39 u, err := url.Parse(origin)
40 if err != nil {
41 return false
42 }
43 switch u.Hostname() {
44 case "localhost", "127.0.0.1", "::1":
45 return true
46 }
47 return false
48}
49
50// LoadEnv reads configuration and refuses rather than guessing.
51//
52// A missing origin refuses here; a wrong one boots happily and mails links
53// to the wrong host, which is the failure that costs an afternoon. It is
54// also why the canary story matters: a hard-coded production origin would
55// have a canary mailing people into production.
56//
57// auth.New refuses an empty InstanceKey on its own, but it does so at the
58// wiring call with a framework message. Refusing here, naming the variable
59// an operator has to set, is the difference between a five-minute fix and a
60// confusing one.
61func LoadEnv() (Env, error) {
62 env := Env{
63 Origin: strings.TrimSpace(os.Getenv("MOVEMENT_ORIGIN")),
64 InstanceKey: strings.TrimSpace(os.Getenv("MOVEMENT_INSTANCE_KEY")),
65 Admins: parseAdmins(os.Getenv("MOVEMENT_ADMINS")),
66 }
67
68 var missing []string
69 if env.Origin == "" {
70 missing = append(missing, "MOVEMENT_ORIGIN")
71 }
72 if env.InstanceKey == "" {
73 missing = append(missing, "MOVEMENT_INSTANCE_KEY")
74 }
75 if len(missing) > 0 {
76 return Env{}, fmt.Errorf("movement: set %s", strings.Join(missing, ", "))
77 }
78 if _, err := url.Parse(env.Origin); err != nil {
79 return Env{}, errors.New("movement: MOVEMENT_ORIGIN is not a URL")
80 }
81
82 // mail.FromEnv falls back to mail.Logged when SMTP is unconfigured, and
83 // Logged writes recipient, subject and body — which here means the magic
84 // link and the confirmation token — into carlos logs. That is exactly
85 // the storage the logging rule exists to keep clean.
86 //
87 // The check is on the environment rather than on the returned Sender
88 // because loggedSender is unexported: the fallback cannot be detected
89 // from the value, so it has to be predicted from its cause.
90 if os.Getenv("CARLOS_SMTP_HOST") == "" || os.Getenv("CARLOS_SMTP_FROM") == "" {
91 if !loopback(env.Origin) {
92 return Env{}, errors.New(
93 "movement: no SMTP configured and origin is not loopback; " +
94 "run carlos email enable -app movement")
95 }
96 }
97 return env, nil
98}
99
100// parseAdmins splits and normalises the allowlist. An empty result is
101// meaningful: every admin route answers 404, which is the fail-closed state.
102func parseAdmins(raw string) []string {
103 var out []string
104 for _, a := range strings.Split(raw, ",") {
105 if a = strings.ToLower(strings.TrimSpace(a)); a != "" {
106 out = append(out, a)
107 }
108 }
109 return out
110}
111
112// IsAdmin reports whether address is on the allowlist. An empty allowlist
113// admits nobody.
114//
115// This is consulted on every admin request, not once at sign-in. auth's own
116// Authorize runs only when a magic link is redeemed, so without a
117// per-request check a removed admin would keep access for the life of their
118// session. The list itself is read at boot: a process's environment is fixed
119// at exec, so nothing re-reads it — what makes removal take effect quickly is
120// `carlos env set` converging and bouncing the instance.
121func (e Env) IsAdmin(address string) bool {
122 address = strings.ToLower(strings.TrimSpace(address))
123 if address == "" {
124 return false
125 }
126 for _, a := range e.Admins {
127 if a == address {
128 return true
129 }
130 }
131 return false
132}
133