| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 13 | func (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. |
| 24 | func (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. |
| 55 | func (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 | |
| 72 | func (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. |
| 88 | func (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. |
| 135 | func 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. |
| 141 | func (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 | |
| 162 | func (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. |
| 181 | func (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 | |
| 217 | func (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 | |
| 227 | func (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 | |
| 239 | func (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. |
| 253 | func (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 | |