| 1 | package movement |
| 2 | |
| 3 | import ( |
| 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. |
| 18 | const CanonicalHost = "movement.bdj.oncarlos.com" |
| 19 | |
| 20 | // Env is the configuration this app refuses to start without. |
| 21 | type 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. |
| 38 | func 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. |
| 61 | func 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. |
| 102 | func 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. |
| 121 | func (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 | |