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 "fmt"
5 "net/http"
6 "strconv"
7 "time"
8
9 "github.com/go-chi/chi/v5"
10)
11
12// calendars lists what the viewer can see.
13func (h *handlers) calendars(w http.ResponseWriter, r *http.Request) {
14 p, _ := CurrentPerson(r)
15 cals, err := h.store.Calendars(r.Context(), p.ID)
16 if err != nil {
17 h.fail(w, r, "list calendars", err)
18 return
19 }
20 render(w, "calendars", h.page(r, "Calendars", map[string]any{"Calendars": cals}))
21}
22
23// share shows a calendar's subscription feeds, and what they cost.
24func (h *handlers) share(w http.ResponseWriter, r *http.Request) {
25 p, cal, ok := h.calendarParam(w, r)
26 if !ok {
27 return
28 }
29 feeds, err := h.store.FeedsFor(r.Context(), p.ID, cal.ID)
30 if err != nil {
31 h.fail(w, r, "list feeds", err)
32 return
33 }
34 // The database hands every time back in UTC (rastrillo's
35 // _timezone=UTC); the page prints them in the viewer's own zone, as
36 // every other surface does.
37 if loc, err := Zone(personZone(p)); err == nil {
38 for i := range feeds {
39 feeds[i].CreatedAt = feeds[i].CreatedAt.In(loc)
40 if at := feeds[i].LastPolledAt; at != nil {
41 local := at.In(loc)
42 feeds[i].LastPolledAt = &local
43 }
44 }
45 }
46 render(w, "share", h.page(r, "Share "+cal.Name, map[string]any{
47 "Calendar": cal,
48 "Feeds": feeds,
49 "Origin": h.origin,
50 "NewToken": r.URL.Query().Get("token"),
51 }))
52}
53
54// mintFeed creates a subscription URL and shows it once.
55func (h *handlers) mintFeed(w http.ResponseWriter, r *http.Request) {
56 p, cal, ok := h.calendarParam(w, r)
57 if !ok {
58 return
59 }
60 token, err := h.store.MintFeed(r.Context(), p.ID, cal.ID)
61 if err != nil {
62 h.fail(w, r, "mint feed", err)
63 return
64 }
65 // The token rides back in the query because this is the only moment
66 // it can ever be shown: only its hash is stored.
67 http.Redirect(w, r,
68 fmt.Sprintf("/calendars/%d/share?token=%s", cal.ID, token),
69 http.StatusSeeOther)
70}
71
72func (h *handlers) revokeFeed(w http.ResponseWriter, r *http.Request) {
73 p, cal, ok := h.calendarParam(w, r)
74 if !ok {
75 return
76 }
77 _ = r.ParseForm()
78 feedID, _ := strconv.ParseInt(r.FormValue("feed_id"), 10, 64)
79 if err := h.store.RevokeFeed(r.Context(), p.ID, feedID); err != nil && !NotFound(err) {
80 h.fail(w, r, "revoke feed", err)
81 return
82 }
83 http.Redirect(w, r, fmt.Sprintf("/calendars/%d/share", cal.ID), http.StatusSeeOther)
84}
85
86// feed serves a subscription. No session: the token IS the
87// authorisation, because Google and Apple will not sign in.
88func (h *handlers) feed(w http.ResponseWriter, r *http.Request) {
89 token := chi.URLParam(r, "token")
90 f, err := h.store.FeedByToken(r.Context(), token)
91 if err != nil {
92 // A bad token is a 404, never a 403: a distinguishable refusal
93 // would let somebody test tokens for existence.
94 http.NotFound(w, r)
95 return
96 }
97 cal, err := h.store.Calendar(r.Context(), f.PersonID, f.CalendarID)
98 if err != nil {
99 http.NotFound(w, r)
100 return
101 }
102 from, to := feedWindow()
103 events, err := h.store.EventsForFeed(r.Context(), f.PersonID, f.CalendarID, from, to)
104 if err != nil {
105 h.logger.Error("calendar: feed events", "err", err)
106 http.Error(w, "unavailable", http.StatusInternalServerError)
107 return
108 }
109 // Attendees are carried in the export and nowhere else in a view,
110 // so they are loaded here rather than by the query.
111 if err := h.store.AttachAttendees(r.Context(), events); err != nil {
112 h.logger.Error("calendar: feed attendees", "stage", "feed.attendees",
113 "err", scrubErr(err))
114 }
115 body := WriteICS(cal.Name, events, r.URL.Query().Get("alarms") == "1")
116
117 // An ETag so a subscriber that polls every fifteen minutes usually
118 // gets a 304. On a platform billed by residency, a 304 is the
119 // difference between a feed that costs a little and one that costs
120 // a lot.
121 etag := etagFor(body)
122 w.Header().Set("ETag", etag)
123 w.Header().Set("Cache-Control", "private, max-age=300")
124 if match := r.Header.Get("If-None-Match"); match == etag {
125 w.WriteHeader(http.StatusNotModified)
126 return
127 }
128 w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
129 _, _ = w.Write([]byte(body))
130}
131
132// feedWindow is how much of a calendar a subscription carries: a year
133// back and two forward. Unbounded would mean re-sending a decade of
134// history every poll.
135func feedWindow() (int64, int64) {
136 now := time.Now()
137 return now.AddDate(-1, 0, 0).Unix(), now.AddDate(2, 0, 0).Unix()
138}
139
140// exportCalendar downloads one calendar as a file.
141func (h *handlers) exportCalendar(w http.ResponseWriter, r *http.Request) {
142 p, cal, ok := h.calendarParam(w, r)
143 if !ok {
144 return
145 }
146 from, to := feedWindow()
147 events, err := h.store.EventsForFeed(r.Context(), p.ID, cal.ID, from, to)
148 if err != nil {
149 h.fail(w, r, "export calendar", err)
150 return
151 }
152 if err := h.store.AttachAttendees(r.Context(), events); err != nil {
153 h.fail(w, r, "export attendees", err)
154 return
155 }
156 w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
157 w.Header().Set("Content-Disposition",
158 fmt.Sprintf("attachment; filename=%q", safeFilename(cal.Name)+".ics"))
159 _, _ = w.Write([]byte(WriteICS(cal.Name, events, true)))
160}
161
162func (h *handlers) importForm(w http.ResponseWriter, r *http.Request) {
163 p, _ := CurrentPerson(r)
164 cals, err := h.store.Calendars(r.Context(), p.ID)
165 if err != nil {
166 h.fail(w, r, "list calendars", err)
167 return
168 }
169 render(w, "import", h.page(r, "Import", map[string]any{
170 "Calendars": writable(cals),
171 }))
172}
173
174// importICS reads an uploaded file synchronously.
175//
176// Synchronous, not a background job: a job needs a status page, a
177// polling shim and a story for what happens when the instance
178// hibernates mid-import. A file small enough to upload is small enough
179// to parse in the request, and the deferred work has a written trigger
180// — an import that exceeds the request timeout.
181func (h *handlers) importICS(w http.ResponseWriter, r *http.Request) {
182 p, _ := CurrentPerson(r)
183 if err := r.ParseMultipartForm(MaxImportBytes); err != nil {
184 h.importFailed(w, r, "That file could not be read.")
185 return
186 }
187 calID, _ := strconv.ParseInt(r.FormValue("calendar_id"), 10, 64)
188 if _, err := h.store.Calendar(r.Context(), p.ID, calID); err != nil {
189 h.errorPage(w, r, http.StatusNotFound, "")
190 return
191 }
192 file, _, err := r.FormFile("file")
193 if err != nil {
194 h.importFailed(w, r, "Choose a .ics file to import.")
195 return
196 }
197 defer file.Close()
198
199 inputs, res, err := ParseICS(file, p.Zone)
200 if err != nil {
201 h.importFailed(w, r, "That file could not be read.")
202 return
203 }
204 for _, in := range inputs {
205 in.CalendarID = calID
206 if _, err := h.store.CreateEvent(r.Context(), p.ID, in); err != nil {
207 // One unusable event does not fail the file. It is counted
208 // and the page says how many.
209 res.Skipped++
210 continue
211 }
212 res.Created++
213 }
214 render(w, "import_done", h.page(r, "Imported", map[string]any{"Result": res}))
215}
216
217func (h *handlers) importFailed(w http.ResponseWriter, r *http.Request, msg string) {
218 p, _ := CurrentPerson(r)
219 cals, _ := h.store.Calendars(r.Context(), p.ID)
220 w.WriteHeader(http.StatusUnprocessableEntity)
221 render(w, "import", h.page(r, "Import", map[string]any{
222 "Calendars": writable(cals),
223 "Error": msg,
224 }))
225}
226
227func (h *handlers) addReminder(w http.ResponseWriter, r *http.Request) {
228 p, _ := CurrentPerson(r)
229 id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
230 _ = r.ParseForm()
231 offset, _ := strconv.ParseInt(r.FormValue("offset_min"), 10, 64)
232 if err := h.store.AddReminder(r.Context(), p.ID, id, offset); err != nil && !NotFound(err) {
233 h.fail(w, r, "add reminder", err)
234 return
235 }
236 http.Redirect(w, r, "/events/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
237}
238
239func (h *handlers) removeReminder(w http.ResponseWriter, r *http.Request) {
240 p, _ := CurrentPerson(r)
241 id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
242 _ = r.ParseForm()
243 remID, _ := strconv.ParseInt(r.FormValue("reminder_id"), 10, 64)
244 if err := h.store.RemoveReminder(r.Context(), p.ID, remID); err != nil && !NotFound(err) {
245 h.fail(w, r, "remove reminder", err)
246 return
247 }
248 http.Redirect(w, r, "/events/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
249}
250
251// calendarParam resolves {id}, answering 404 for anything the viewer
252// may not see.
253func (h *handlers) calendarParam(w http.ResponseWriter, r *http.Request) (Person, CalendarView, bool) {
254 p, _ := CurrentPerson(r)
255 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
256 if err != nil {
257 h.errorPage(w, r, http.StatusNotFound, "")
258 return p, CalendarView{}, false
259 }
260 cal, err := h.store.Calendar(r.Context(), p.ID, id)
261 if NotFound(err) {
262 h.errorPage(w, r, http.StatusNotFound, "")
263 return p, CalendarView{}, false
264 }
265 if err != nil {
266 h.fail(w, r, "read calendar", err)
267 return p, CalendarView{}, false
268 }
269 return p, cal, true
270}
271