| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "amadan.net/oficina/estilo.git/sky" |
| 5 | "amadan.net/oficina/estilo.git/theme" |
| 6 | "amadan.net/oficina/home/connect" |
| 7 | "amadan.net/oficina/home/handoff" |
| 8 | "net/http" |
| 9 | |
| 10 | "github.com/go-chi/chi/v5" |
| 11 | |
| 12 | rauth "amadan.net/rastrillo/rastrillo/auth" |
| 13 | "amadan.net/rastrillo/rastrillo/csrf" |
| 14 | ) |
| 15 | |
| 16 | // routes builds the whole router. |
| 17 | // |
| 18 | // Three tiers, and which tier a route sits in is a security decision |
| 19 | // rather than a filing one: |
| 20 | // |
| 21 | // - Outside CSRF entirely: the scheduled tick. See mountTick. |
| 22 | // - Inside CSRF, outside the session: sign-in, the theme switcher, |
| 23 | // and the subscription feed, which is a capability URL rather than |
| 24 | // a signed-in page. |
| 25 | // - Inside the session: everything else. |
| 26 | func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin string) *http.ServeMux { |
| 27 | r := chi.NewRouter() |
| 28 | r.Use(a.native.Resume) |
| 29 | // App-wide, so every state-changing route added later is born |
| 30 | // covered. Origin-checking, not tokens — nothing to mint or forget |
| 31 | // in a form. |
| 32 | if a.home != nil { |
| 33 | r.Use(a.home.routes.Middleware) |
| 34 | r.Use(a.home.sessions.Middleware) |
| 35 | } else { |
| 36 | r.Use(csrf.Protect(origin)) |
| 37 | } |
| 38 | |
| 39 | r.Handle("/native/*", a.native) |
| 40 | r.Get("/help/open-source", h.credits) |
| 41 | r.NotFound(h.notFound) |
| 42 | |
| 43 | // Signed-out. |
| 44 | if a.home != nil { |
| 45 | r.Get("/signin", a.homeSignin) |
| 46 | r.Get("/signin/local", h.signin) |
| 47 | r.Get("/signed-out", a.home.routes.SignedOut) |
| 48 | r.Get(connect.LocalCompletePath, a.home.routes.LocalComplete) |
| 49 | r.Get(handoff.StartPath, a.home.bridge.Start) |
| 50 | r.Post(handoff.CallbackPath, a.home.bridge.Callback) |
| 51 | r.Get(handoff.FinishPath, a.home.bridge.Finish) |
| 52 | r.With(a.home.sessions.Require).Get("/account/home", a.home.routes.Account) |
| 53 | r.With(a.home.sessions.Require).Post("/account/home", a.home.routes.LinkStart) |
| 54 | r.With(a.home.sessions.Require).Get("/account/home/confirm", a.home.bridge.ConfirmPage) |
| 55 | r.With(a.home.sessions.Require).Post("/account/home/confirm", a.home.routes.ConfirmPost) |
| 56 | } else { |
| 57 | r.Get("/signin", h.signin) |
| 58 | r.Get("/signed-out", h.signedOut) |
| 59 | } |
| 60 | // The handlers rastrillo/auth expects an app to mount. Verify answers |
| 61 | // GET with the confirm door (handlers.renderConfirm), which spends |
| 62 | // nothing, and POST with the redemption, so it is mounted for both |
| 63 | // methods in both modes: without the POST the door's Sign in button |
| 64 | // gets a 405 and nobody signs in. The path is baked into every |
| 65 | // emailed link, so moving it later invalidates links already in |
| 66 | // people's inboxes. A verified address the allowlist does not hold |
| 67 | // is answered by auth's Refused hook (handlers.refused), inside |
| 68 | // Callback and Verify. |
| 69 | begin := beginSignin(origin, authPlugin.Begin, h.renderKeymail) |
| 70 | if a.home != nil { |
| 71 | r.Post("/signin", a.home.routes.LocalGuard(begin)) |
| 72 | r.Get("/auth/callback", a.home.routes.LocalGuard(authPlugin.Callback)) |
| 73 | r.Get("/auth/verify", a.home.routes.LocalGuard(authPlugin.Verify)) |
| 74 | r.Post("/auth/verify", a.home.routes.LocalGuard(authPlugin.Verify)) |
| 75 | r.Post("/signout", a.home.routes.Signout) |
| 76 | } else { |
| 77 | r.Post("/signin", begin) |
| 78 | r.Get("/auth/callback", authPlugin.Callback) |
| 79 | r.Get("/auth/verify", authPlugin.Verify) |
| 80 | r.Post("/auth/verify", authPlugin.Verify) |
| 81 | r.Post("/signout", signOutToSignedOut(authPlugin.Signout)) |
| 82 | } |
| 83 | |
| 84 | // The light/dark switcher, outside the session on purpose: the |
| 85 | // sign-in page is a page too, and somebody who needs a |
| 86 | // high-contrast theme should not have to sign in to get one. |
| 87 | r.Post("/appearance", h.appearance) |
| 88 | |
| 89 | // A subscription feed is a capability: the token in the URL is the |
| 90 | // whole authorisation, because Google and Apple will not sign in. |
| 91 | // Deliberately outside the session group — a signed-in check here |
| 92 | // would make the feed work in a browser and nowhere else. |
| 93 | r.Get("/feed/{token}.ics", h.feed) |
| 94 | |
| 95 | // Booking, the stranger's side (booking spec §5). Inside CSRF and |
| 96 | // outside the session, like the feed; every route passes the gate. |
| 97 | // /book/m/... is registered BEFORE /book/{handle}/{code}, and "m" |
| 98 | // is a reserved handle, so the two cannot collide. |
| 99 | r.Get("/book/m/{token}", h.publicGate(h.bookManage)) |
| 100 | r.Post("/book/m/{token}/confirm", h.publicGate(h.bookConfirm)) |
| 101 | r.Get("/book/m/{token}/calendar.ics", h.publicGate(h.bookICS)) |
| 102 | r.Get("/book/m/{token}/reschedule", h.publicGate(h.bookReschedulePicker)) |
| 103 | r.Post("/book/m/{token}/reschedule", h.publicGate(h.bookReschedule)) |
| 104 | r.Post("/book/m/{token}/cancel", h.publicGate(h.bookCancel)) |
| 105 | r.Get("/book/{handle}/{code}", h.publicGate(h.bookPage)) |
| 106 | r.Post("/book/{handle}/{code}", h.publicGate(h.bookPost)) |
| 107 | |
| 108 | r.Group(func(r chi.Router) { |
| 109 | if a.home != nil { |
| 110 | r.Use(a.home.sessions.Require) |
| 111 | } else { |
| 112 | r.Use(authPlugin.RequireSession) |
| 113 | } |
| 114 | r.Use(a.requirePerson) |
| 115 | r.Use(h.drainOpportunistically) |
| 116 | |
| 117 | r.Post("/api/native/quick/parse", h.nativeQuickParse) |
| 118 | r.Post("/api/native/quick", h.nativeQuickSave) |
| 119 | r.Get("/api/native/session", h.nativeSession) |
| 120 | r.Get("/api/native/events", h.nativeEvents) |
| 121 | r.Post("/api/native/events", h.nativeSave) |
| 122 | r.Post("/api/native/events/{id}", h.nativeSave) |
| 123 | |
| 124 | r.Get("/", h.today) |
| 125 | r.Get("/today", h.today) |
| 126 | r.Get("/day/{date}", h.day) |
| 127 | r.Get("/week/{date}", h.week) |
| 128 | r.Get("/month/{month}", h.month) |
| 129 | r.Get("/agenda", h.agenda) |
| 130 | |
| 131 | r.Get("/events/new", h.newEvent) |
| 132 | r.Post("/events", h.createEvent) |
| 133 | // Quick add. Both are literal segments and both are declared |
| 134 | // BEFORE /events/{id}, so a sentence can never be mistaken for |
| 135 | // an event id. |
| 136 | r.Post("/events/quick", h.quickAdd) |
| 137 | r.Post("/events/quick/parse", h.quickParse) |
| 138 | r.Get("/events/{id}", h.showEvent) |
| 139 | r.Get("/events/{id}/edit", h.editEvent) |
| 140 | r.Post("/events/{id}", h.updateEvent) |
| 141 | // The destructive action gets its own URL and its own page, |
| 142 | // never a modal: it works with no script, it can be linked to, |
| 143 | // and the browser's back button is a working cancel. |
| 144 | r.Get("/events/{id}/delete", h.confirmDelete) |
| 145 | r.Post("/events/{id}/delete", h.deleteEvent) |
| 146 | r.Post("/events/{id}/reminders", h.addReminder) |
| 147 | r.Post("/events/{id}/reminders/remove", h.removeReminder) |
| 148 | // One occurrence of a series. Last, so it cannot shadow the |
| 149 | // literal segments above. |
| 150 | r.Get("/events/{id}/{occ}", h.showEvent) |
| 151 | |
| 152 | r.Get("/calendars", h.calendars) |
| 153 | r.Get("/calendars/{id}.ics", h.exportCalendar) |
| 154 | r.Get("/calendars/{id}/share", h.share) |
| 155 | r.Post("/calendars/{id}/feeds", h.mintFeed) |
| 156 | r.Post("/calendars/{id}/feeds/revoke", h.revokeFeed) |
| 157 | |
| 158 | r.Get("/import", h.importForm) |
| 159 | r.Post("/import", h.importICS) |
| 160 | |
| 161 | r.Get("/settings", h.settings) |
| 162 | r.Post("/settings", h.saveSettings) |
| 163 | |
| 164 | // Booking pages, the host's side. Person-scoped throughout: a |
| 165 | // page id that is not yours 404s (booking spec §2.6). |
| 166 | r.Get("/settings/booking", h.bookingPages) |
| 167 | r.Get("/settings/booking/new", h.newBookingPage) |
| 168 | r.Post("/settings/booking", h.saveBookingPage) |
| 169 | r.Post("/settings/booking/parse", h.bookingParse) |
| 170 | r.Get("/settings/booking/{id}", h.editBookingPage) |
| 171 | r.Post("/settings/booking/{id}", h.saveBookingPage) |
| 172 | r.Post("/settings/booking/{id}/publish", h.publishBookingPage) |
| 173 | }) |
| 174 | |
| 175 | mux := http.NewServeMux() |
| 176 | // The app serves its own static files — the framework never does. |
| 177 | mux.Handle("GET /static/", assets.Handler()) |
| 178 | // Onest, Calma's typeface, served by estilo under its versioned prefix. |
| 179 | mux.Handle("GET "+theme.FontPrefix, theme.FontHandler()) |
| 180 | // The sign-in door's sky: a closed set of drawings, sessionless and |
| 181 | // reachable signed out. More specific than "GET /static/", so the |
| 182 | // app's own asset handler never sees these paths. |
| 183 | mux.Handle("GET "+sky.Prefix, sky.Handler()) |
| 184 | mux.HandleFunc("GET /static/credits.css", serveCreditsCSS) |
| 185 | a.mountTick(mux, h) |
| 186 | mux.Handle("/", a.scheduled.middleware(r)) |
| 187 | return mux |
| 188 | } |
| 189 | |
| 190 | // mountTick puts the platform's scheduled-work route on the bare mux, |
| 191 | // OUTSIDE csrf.Protect. |
| 192 | // |
| 193 | // This is not tidiness, it is the difference between reminders working |
| 194 | // and reminders never firing. csrf.Protect refuses any POST that |
| 195 | // carries no Sec-Fetch-Site, Origin or Referer, and the platform's |
| 196 | // scheduler sends none of the three — it is not a browser. Mounted |
| 197 | // inside the protected router the tick would 403 forever, and the |
| 198 | // platform reads 4xx as "do not retry", so it would fail silently and |
| 199 | // permanently. |
| 200 | // |
| 201 | // carlos.Tick is the guard instead: a constant-time comparison against |
| 202 | // this instance's own $CARLOS_ADMIN_TOKEN, false when there is no token |
| 203 | // in the environment, so it fails closed off-platform rather than open. |
| 204 | // |
| 205 | // TestTheTickRouteIsNotBehindCSRF stands in front of this. |
| 206 | func (a *App) mountTick(mux *http.ServeMux, h *handlers) { |
| 207 | mux.Handle("POST /jobs/reminders", http.HandlerFunc(h.drainReminders)) |
| 208 | } |
| 209 | |