oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
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.
26func (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.
206func (a *App) mountTick(mux *http.ServeMux, h *handlers) {
207 mux.Handle("POST /jobs/reminders", http.HandlerFunc(h.drainReminders))
208}
209