From 90247a460d4ba4cb143ad04023bef9d7ef684492 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 17:38:48 +0200 Subject: [PATCH] feat: serve a subscribable ICS calendar feed --- classes/calendar.go | 235 +++++++++++++++++++++++ classes/ics.go | 105 ++++++++++ config/config.yaml | 4 + controllers/api/api.go | 4 + controllers/api/calendar.go | 372 ++++++++++++++++++++++++++++++++++++ routes.go | 47 ++++- 6 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 classes/calendar.go create mode 100644 classes/ics.go create mode 100644 controllers/api/calendar.go diff --git a/classes/calendar.go b/classes/calendar.go new file mode 100644 index 0000000..35094e8 --- /dev/null +++ b/classes/calendar.go @@ -0,0 +1,235 @@ +package classes + +import ( + "strconv" + "strings" + "time" + + "git.golem15.com/jakub/sm-bm-plugin/models" +) + +// CalendarUIDDomain is the right-hand side of every UID in the feed. It +// stays fixed, whatever host serves the feed, so a calendar that has +// subscribed keeps matching its events. +const CalendarUIDDomain = "bm.jakubzych.com" + +// CalendarName is the X-WR-CALNAME of the feed. +const CalendarName = "BM UWr – plan zajęć" + +// CalendarOptions selects what a feed lists. +type CalendarOptions struct { + // ExcludedCourseIDs are left out, with their sessions and deadlines. + ExcludedCourseIDs []uint + // IncludeMeetings adds the meetings as all-day events. + IncludeMeetings bool + // IncludeDeadlines adds the deadlines. + IncludeDeadlines bool + // SiteURL is the student app's base URL (no trailing slash), used for the + // course links in the descriptions. Empty leaves the links out. + SiteURL string +} + +// CalendarData is what a feed is built from. Sessions need their Course and +// Meeting loaded, deadlines their Course. +type CalendarData struct { + Sessions []models.ClassSession + Meetings []models.Meeting + Deadlines []models.Deadline +} + +// formLabels are the Polish names of the course forms in a summary. "inne" +// has none: the course name says enough. +var formLabels = map[string]string{ + models.FormWyklad: "wykład", + models.FormCwiczenia: "ćwiczenia", + models.FormKonwersatorium: "konwersatorium", + models.FormSeminarium: "seminarium", +} + +// deadlineLead is how long before its due time a deadline event starts: +// most calendars show a zero-length event badly. +const deadlineLead = 30 * time.Minute + +// BuildCalendar writes the feed: a VCALENDAR with the class sessions of the +// courses not excluded, then the meetings and the deadlines when included. +// Times are written in UTC, so no VTIMEZONE is needed. +func BuildCalendar(data CalendarData, opt CalendarOptions) []byte { + excluded := make(map[uint]bool, len(opt.ExcludedCourseIDs)) + for _, id := range opt.ExcludedCourseIDs { + excluded[id] = true + } + var w ICS + w.Begin("VCALENDAR") + w.Prop("PRODID", "-//jakubzych//BM UWr//PL") + w.Prop("VERSION", "2.0") + w.Prop("CALSCALE", "GREGORIAN") + w.Prop("METHOD", "PUBLISH") + w.Text("X-WR-CALNAME", CalendarName) + w.Prop("X-WR-TIMEZONE", "Europe/Warsaw") + w.Prop("REFRESH-INTERVAL;VALUE=DURATION", "PT6H") + w.Prop("X-PUBLISHED-TTL", "PT6H") + for _, s := range data.Sessions { + if excluded[s.CourseID] { + continue + } + writeSession(&w, s, opt.SiteURL) + } + if opt.IncludeMeetings { + for _, m := range data.Meetings { + writeMeeting(&w, m) + } + } + if opt.IncludeDeadlines { + for _, d := range data.Deadlines { + if d.CourseID != nil && excluded[*d.CourseID] { + continue + } + writeDeadline(&w, d) + } + } + w.End("VCALENDAR") + return w.Bytes() +} + +func uid(kind string, id uint) string { + return kind + "-" + strconv.FormatUint(uint64(id), 10) + "@" + CalendarUIDDomain +} + +// stamp writes DTSTAMP and LAST-MODIFIED from the row's update time, so an +// unchanged row gives the same bytes on every fetch. +func stamp(w *ICS, updated time.Time) { + if updated.IsZero() { + updated = time.Unix(0, 0) + } + w.UTC("DTSTAMP", updated) + w.UTC("LAST-MODIFIED", updated) +} + +// SessionSummary is the title of a class session: the course name, the form +// in Polish (none for "inne") and " (online)" for an online session. +func SessionSummary(s models.ClassSession) string { + var b strings.Builder + if s.Course != nil { + b.WriteString(s.Course.Name) + if label := formLabels[s.Course.Form]; label != "" { + b.WriteString(" – ") + b.WriteString(label) + } + } else { + b.WriteString("Zajęcia") + } + if s.IsOnline { + b.WriteString(" (online)") + } + return b.String() +} + +func writeSession(w *ICS, s models.ClassSession, siteURL string) { + w.Begin("VEVENT") + w.Prop("UID", uid("session", s.ID)) + stamp(w, s.UpdatedAt) + w.Prop("SEQUENCE", "0") + w.UTC("DTSTART", s.StartsAt.MustTime()) + w.UTC("DTEND", s.EndsAt.MustTime()) + w.Text("SUMMARY", SessionSummary(s)) + switch { + case s.IsOnline: + w.Text("LOCATION", "online") + case s.Room != nil && strings.TrimSpace(*s.Room) != "": + w.Text("LOCATION", "Koszarowa 3, s. "+strings.TrimSpace(*s.Room)+" (bud. 21 ISMiB)") + } + var lines []string + if s.Course != nil && s.Course.Lecturer != nil && *s.Course.Lecturer != "" { + lines = append(lines, "Prowadzący: "+*s.Course.Lecturer) + } + if s.Meeting != nil { + lines = append(lines, "Zjazd "+strconv.Itoa(s.Meeting.Number)) + } + if s.Notes != nil && strings.TrimSpace(*s.Notes) != "" { + lines = append(lines, strings.TrimSpace(*s.Notes)) + } + if s.Course != nil && s.Course.ElectiveGroup != nil { + lines = append(lines, "Przedmiot do wyboru") + } + if siteURL != "" { + link := siteURL + "/przedmioty/" + strconv.FormatUint(uint64(s.CourseID), 10) + lines = append(lines, link) + w.Prop("URL", link) + } + if len(lines) > 0 { + w.Text("DESCRIPTION", strings.Join(lines, "\n")) + } + w.Prop("STATUS", "CONFIRMED") + w.End("VEVENT") +} + +// MeetingSummary is the title of a meeting: "3. zjazd (stacjonarny)", with +// the mode read from the start of the notes. +func MeetingSummary(m models.Meeting) string { + out := strconv.Itoa(m.Number) + ". zjazd" + if m.Notes != nil { + notes := strings.ToLower(strings.TrimSpace(*m.Notes)) + switch { + case strings.HasPrefix(notes, "online"): + out += " (online)" + case strings.HasPrefix(notes, "stacjonarn"): + out += " (stacjonarny)" + } + } + return out +} + +func writeMeeting(w *ICS, m models.Meeting) { + start, err := m.StartsOn.Time() + if err != nil { + return + } + end, err := m.EndsOn.Time() + if err != nil || end.Before(start) { + end = start + } + w.Begin("VEVENT") + w.Prop("UID", uid("meeting", m.ID)) + stamp(w, m.UpdatedAt) + w.Prop("SEQUENCE", "0") + w.Date("DTSTART", start) + // DTEND of an all-day event is exclusive: the day after the last day. + w.Date("DTEND", end.AddDate(0, 0, 1)) + w.Text("SUMMARY", MeetingSummary(m)) + if m.Notes != nil && strings.TrimSpace(*m.Notes) != "" { + w.Text("DESCRIPTION", strings.TrimSpace(*m.Notes)) + } + w.Prop("TRANSP", "TRANSPARENT") + w.End("VEVENT") +} + +func writeDeadline(w *ICS, d models.Deadline) { + due, err := d.DueAt.Time() + if err != nil { + return + } + summary := "Termin: " + d.Title + w.Begin("VEVENT") + w.Prop("UID", uid("deadline", d.ID)) + stamp(w, d.UpdatedAt) + w.Prop("SEQUENCE", "0") + w.UTC("DTSTART", due.Add(-deadlineLead)) + w.UTC("DTEND", due) + w.Text("SUMMARY", summary) + var lines []string + if d.Course != nil { + lines = append(lines, "Przedmiot: "+d.Course.Name) + } + if d.Description != nil && strings.TrimSpace(*d.Description) != "" { + lines = append(lines, strings.TrimSpace(*d.Description)) + } + if len(lines) > 0 { + w.Text("DESCRIPTION", strings.Join(lines, "\n")) + } + w.Begin("VALARM") + w.Prop("ACTION", "DISPLAY") + w.Text("DESCRIPTION", summary) + w.Prop("TRIGGER", "-P1D") + w.End("VALARM") + w.End("VEVENT") +} diff --git a/classes/ics.go b/classes/ics.go new file mode 100644 index 0000000..3341941 --- /dev/null +++ b/classes/ics.go @@ -0,0 +1,105 @@ +package classes + +import ( + "bytes" + "strings" + "time" + "unicode/utf8" +) + +// ICS writes an iCalendar (RFC 5545) stream: CRLF line endings, content +// lines folded at 75 octets without splitting a UTF-8 sequence, and TEXT +// values escaped. The zero value is ready to use. +type ICS struct { + buf bytes.Buffer +} + +// icsLineOctets is the longest content line RFC 5545 allows, not counting +// the CRLF. +const icsLineOctets = 75 + +// Begin writes BEGIN:. +func (w *ICS) Begin(component string) { w.Line("BEGIN:" + component) } + +// End writes END:. +func (w *ICS) End(component string) { w.Line("END:" + component) } + +// Prop writes name:value with the value as it is (a value of a type that +// needs no escaping, such as a date, a duration or a URI). +func (w *ICS) Prop(name, value string) { w.Line(name + ":" + value) } + +// Text writes name:value with the value escaped as TEXT. +func (w *ICS) Text(name, value string) { w.Line(name + ":" + EscapeText(value)) } + +// UTC writes name:, such as DTSTART:20261003T060000Z. +func (w *ICS) UTC(name string, t time.Time) { w.Prop(name, FormatUTC(t)) } + +// Date writes name;VALUE=DATE: for an all-day value. +func (w *ICS) Date(name string, t time.Time) { w.Prop(name+";VALUE=DATE", t.Format("20060102")) } + +// Line writes one content line, folded. +func (w *ICS) Line(line string) { + line = strings.NewReplacer("\r", "", "\n", "").Replace(line) + w.buf.WriteString(FoldLine(line)) + w.buf.WriteString("\r\n") +} + +// Bytes returns the stream written so far. +func (w *ICS) Bytes() []byte { return w.buf.Bytes() } + +// FormatUTC formats t as an RFC 5545 UTC date-time (20261003T060000Z). +func FormatUTC(t time.Time) string { return t.UTC().Format("20060102T150405Z") } + +// EscapeText escapes a TEXT value: backslash, semicolon, comma and line +// breaks (as \n). A lone CR is dropped. +func EscapeText(s string) string { + var b strings.Builder + b.Grow(len(s)) + s = strings.ReplaceAll(s, "\r\n", "\n") + for _, r := range s { + switch r { + case '\\': + b.WriteString(`\\`) + case ';': + b.WriteString(`\;`) + case ',': + b.WriteString(`\,`) + case '\n': + b.WriteString(`\n`) + case '\r': + default: + b.WriteRune(r) + } + } + return b.String() +} + +// FoldLine folds a content line (without its CRLF) so that no physical line +// is longer than 75 octets. Continuation lines start with one space, which +// counts towards their 75 octets; a multi-octet UTF-8 character is never +// split. +func FoldLine(line string) string { + if len(line) <= icsLineOctets { + return line + } + var b strings.Builder + n := 0 + for i := 0; i < len(line); { + size := runeBytes(line[i:]) + if n+size > icsLineOctets { + b.WriteString("\r\n ") + n = 1 + } + b.WriteString(line[i : i+size]) + n += size + i += size + } + return b.String() +} + +// runeBytes is the length in bytes of the first character of s, also for an +// invalid sequence (one byte). +func runeBytes(s string) int { + _, size := utf8.DecodeRuneInString(s) + return size +} diff --git a/config/config.yaml b/config/config.yaml index a0c3d51..c01d367 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -5,3 +5,7 @@ materials_per_page: 20 # Number of class sessions and deadlines on GET /_bm/api/v1/dashboard. dashboard_limit: 5 +# Absolute base URL of the application (https://bm.example.org), used for +# the calendar feed URL and the course links inside the feed. Empty means +# the scheme and Host of the request. +public_url: "" diff --git a/controllers/api/api.go b/controllers/api/api.go index 2f0ef8e..5ff0e4c 100644 --- a/controllers/api/api.go +++ b/controllers/api/api.go @@ -33,6 +33,10 @@ type Handlers struct { PerPage int // DashboardLimit is the number of sessions and deadlines on /dashboard. DashboardLimit int + // PublicURL is the absolute base URL of the application + // (jz.bm.public_url), used for the calendar feed URL and the links in + // it. Empty means the scheme and Host of the request. + PublicURL string } func (h *Handlers) now() time.Time { diff --git a/controllers/api/calendar.go b/controllers/api/calendar.go new file mode 100644 index 0000000..e4893c5 --- /dev/null +++ b/controllers/api/calendar.go @@ -0,0 +1,372 @@ +package api + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "math" + "net/http" + "slices" + "strings" + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/wire" + "git.golem15.com/jakub/sm-bm-plugin/classes" + "git.golem15.com/jakub/sm-bm-plugin/models" + "gorm.io/gorm" +) + +// CalendarPath is the path prefix of the public ICS feed; the feed of a +// token is at CalendarPath/.ics. +const CalendarPath = "/_bm/calendar" + +// calendarCacheControl lets a calendar client reuse a fetch for 15 minutes. +const calendarCacheControl = "private, max-age=900" + +// fetchStampEvery limits the last_fetched_at writes of a feed. +const fetchStampEvery = time.Minute + +// tokenBytes is the size of a feed token before hex encoding. +const tokenBytes = 32 + +var calendarFeedRules = []lagoon.RequestRule{ + {Field: "excluded_course_ids", Rules: lagoon.ParseRules("sometimes|array|max:500")}, + {Field: "excluded_course_ids.*", Rules: lagoon.ParseRules("integer|min:1|exists:jz_bm_courses,id")}, + {Field: "include_meetings", Rules: lagoon.ParseRules("sometimes|boolean")}, + {Field: "include_deadlines", Rules: lagoon.ParseRules("sometimes|boolean")}, +} + +// calendarFeedJSON is the caller's feed as the API shows it. +type calendarFeedJSON struct { + URL string `json:"url"` + ExcludedCourseIDs []uint `json:"excluded_course_ids"` + IncludeMeetings bool `json:"include_meetings"` + IncludeDeadlines bool `json:"include_deadlines"` + LastFetchedAt *string `json:"last_fetched_at"` +} + +// publicBase is the absolute base URL of the application: jz.bm.public_url +// when set, otherwise the scheme and Host of the request. +func (h *Handlers) publicBase(r *http.Request) string { + if base := strings.TrimRight(strings.TrimSpace(h.PublicURL), "/"); base != "" { + return base + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return scheme + "://" + r.Host +} + +func (h *Handlers) newFeedJSON(r *http.Request, f *models.CalendarFeed) calendarFeedJSON { + ids := []uint(f.ExcludedCourseIDs) + if ids == nil { + ids = []uint{} + } + out := calendarFeedJSON{ + URL: h.publicBase(r) + CalendarPath + "/" + f.Token + ".ics", + ExcludedCourseIDs: ids, + IncludeMeetings: f.IncludeMeetings, + IncludeDeadlines: f.IncludeDeadlines, + } + if f.LastFetchedAt != nil { + s := string(models.NewDateTime(*f.LastFetchedAt)) + out.LastFetchedAt = &s + } + return out +} + +// newToken returns 32 random bytes in hex. +func newToken() (string, error) { + b := make([]byte, tokenBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// findFeed loads the caller's feed; nil when there is none. +func findFeed(db *gorm.DB, userID uint) (*models.CalendarFeed, error) { + var feed models.CalendarFeed + res := db.Where("user_id = ?", userID).Limit(1).Find(&feed) + if res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return nil, nil + } + return &feed, nil +} + +// CalendarFeed answers GET /calendar-feed: the caller's feed, or null. +func (h *Handlers) CalendarFeed(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + feed, err := findFeed(db, userID) + if err != nil { + writeOpaque500(w) + return + } + if feed == nil { + writeData(w, nil) + return + } + writeData(w, h.newFeedJSON(r, feed)) +} + +// readJSONObject decodes a JSON object body; an empty body is an empty +// object. +func readJSONObject(r *http.Request) (map[string]any, error) { + var in map[string]any + if err := json.NewDecoder(r.Body).Decode(&in); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if in == nil { + in = map[string]any{} + } + // JSON numbers decode as float64; whole ones become int64, so the + // exists rule queries with an integer. + if raw, ok := in["excluded_course_ids"].([]any); ok { + for i, v := range raw { + if f, ok := v.(float64); ok && f == math.Trunc(f) && math.Abs(f) < 1<<53 { + raw[i] = int64(f) + } + } + } + return in, nil +} + +// excludedIDs returns the validated excluded_course_ids, sorted and without +// duplicates. +func excludedIDs(v any) models.IDList { + raw, _ := v.([]any) + out := models.IDList{} + for _, item := range raw { + if n, ok := item.(int64); ok && n > 0 { + out = append(out, uint(n)) + } + } + slices.Sort(out) + return slices.Compact(out) +} + +// UpdateCalendarFeed answers PUT /calendar-feed: it creates the caller's +// feed with a new token when there is none, and applies the settings the +// body gives. +func (h *Handlers) UpdateCalendarFeed(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + in, err := readJSONObject(r) + if err != nil { + wire.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": true, "message": "The request body is not a JSON object."}) + return + } + db, ok := h.db(w, r) + if !ok { + return + } + errs, err := lagoon.ValidateRequest(r.Context(), db, in, calendarFeedRules, h.translator()) + if err != nil { + writeOpaque500(w) + return + } + if len(errs) > 0 { + writeValidation(w, errs) + return + } + token, err := newToken() + if err != nil { + writeOpaque500(w) + return + } + now := h.now().UTC() + var feed *models.CalendarFeed + err = db.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec(`INSERT INTO jz_bm_calendar_feeds (user_id, token, created_at, updated_at) VALUES (?, ?, ?, ?) +ON CONFLICT (user_id) DO NOTHING`, userID, token, now, now).Error; err != nil { + return err + } + set := map[string]any{} + if v, ok := in["excluded_course_ids"]; ok { + set["excluded_course_ids"] = excludedIDs(v) + } + if v, ok := in["include_meetings"]; ok { + set["include_meetings"] = truthy(v) + } + if v, ok := in["include_deadlines"]; ok { + set["include_deadlines"] = truthy(v) + } + if len(set) > 0 { + set["updated_at"] = now + if err := tx.Model(&models.CalendarFeed{}).Where("user_id = ?", userID).UpdateColumns(set).Error; err != nil { + return err + } + } + var err error + feed, err = findFeed(tx, userID) + return err + }) + if err != nil || feed == nil { + writeOpaque500(w) + return + } + writeData(w, h.newFeedJSON(r, feed)) +} + +// truthy reads a value the boolean rule accepted: true, false, 1, 0, "1", +// "0", "true", "false". +func truthy(v any) bool { + switch b := v.(type) { + case bool: + return b + case int64: + return b == 1 + case float64: + return b == 1 + case string: + return b == "1" || b == "true" + } + return false +} + +// RotateCalendarFeed answers POST /calendar-feed/rotate: the caller's feed +// gets a new token, so the old URL stops working. 404 without a feed. +func (h *Handlers) RotateCalendarFeed(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + token, err := newToken() + if err != nil { + writeOpaque500(w) + return + } + res := db.Model(&models.CalendarFeed{}).Where("user_id = ?", userID). + UpdateColumns(map[string]any{"token": token, "updated_at": h.now().UTC()}) + if res.Error != nil { + writeOpaque500(w) + return + } + if res.RowsAffected == 0 { + writeNotFound(w) + return + } + feed, err := findFeed(db, userID) + if err != nil || feed == nil { + writeOpaque500(w) + return + } + writeData(w, h.newFeedJSON(r, feed)) +} + +// DeleteCalendarFeed answers DELETE /calendar-feed: it deletes the caller's +// feed (204, also when there was none). +func (h *Handlers) DeleteCalendarFeed(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + if err := db.Where("user_id = ?", userID).Delete(&models.CalendarFeed{}).Error; err != nil { + writeOpaque500(w) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// feedToken reads {token} with an optional .ics suffix and reports whether +// it has the shape of a token (64 lower-case hex digits). +func feedToken(r *http.Request) (string, bool) { + token := strings.TrimSuffix(r.PathValue("token"), ".ics") + if len(token) != 2*tokenBytes { + return "", false + } + for _, c := range token { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return "", false + } + } + return token, true +} + +// CalendarICS answers GET /_bm/calendar/{token}.ics without authentication: +// the token is the credential. An unknown token is a 404. +func (h *Handlers) CalendarICS(w http.ResponseWriter, r *http.Request) { + token, ok := feedToken(r) + if !ok { + writeNotFound(w) + return + } + db, ok := h.db(w, r) + if !ok { + return + } + var feed models.CalendarFeed + res := db.Where("token = ?", token).Limit(1).Find(&feed) + if res.Error != nil { + writeOpaque500(w) + return + } + if res.RowsAffected == 0 { + writeNotFound(w) + return + } + var data classes.CalendarData + if err := preloadSession(db).Order("starts_at, id").Find(&data.Sessions).Error; err != nil { + writeOpaque500(w) + return + } + if feed.IncludeMeetings { + if err := db.Order("number, id").Find(&data.Meetings).Error; err != nil { + writeOpaque500(w) + return + } + } + if feed.IncludeDeadlines { + if err := db.Preload("Course").Order("due_at, id").Find(&data.Deadlines).Error; err != nil { + writeOpaque500(w) + return + } + } + body := classes.BuildCalendar(data, classes.CalendarOptions{ + ExcludedCourseIDs: feed.ExcludedCourseIDs, + IncludeMeetings: feed.IncludeMeetings, + IncludeDeadlines: feed.IncludeDeadlines, + SiteURL: h.publicBase(r), + }) + // One cheap write per feed and minute at most; a failure does not cost + // the client its calendar. + now := h.now().UTC() + _ = db.Exec(`UPDATE jz_bm_calendar_feeds SET last_fetched_at = ? +WHERE id = ? AND (last_fetched_at IS NULL OR last_fetched_at <= ?)`, now, feed.ID, now.Add(-fetchStampEvery)).Error + + sum := sha256.Sum256(body) + hdr := w.Header() + hdr.Set("Content-Type", "text/calendar; charset=utf-8") + hdr.Set("Content-Disposition", `inline; filename="bm.ics"`) + hdr.Set("Cache-Control", calendarCacheControl) + hdr.Set("ETag", `"`+hex.EncodeToString(sum[:])[:32]+`"`) + hdr.Set("X-Content-Type-Options", "nosniff") + hdr.Set("Referrer-Policy", "no-referrer") + http.ServeContent(w, r, "bm.ics", time.Time{}, bytes.NewReader(body)) +} diff --git a/routes.go b/routes.go index 4daa3b4..54ee47c 100644 --- a/routes.go +++ b/routes.go @@ -1,6 +1,9 @@ package bm import ( + "net/http" + "time" + "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/surf" @@ -8,7 +11,37 @@ import ( "gorm.io/gorm" ) -var _ pact.HasRoutes = (*Plugin)(nil) +var ( + _ pact.HasRoutes = (*Plugin)(nil) + _ surf.BucketProvider = (*Plugin)(nil) +) + +// The throttles of the public calendar feed: per client IP, and per token +// so that one leaked URL cannot be hammered from many addresses. +const ( + bucketCalendarIP = "throttle:jz.bm.calendar-ip" + bucketCalendarToken = "throttle:jz.bm.calendar-token" +) + +// Buckets declares the rate limits of the public calendar feed. +func (p *Plugin) Buckets() map[string]surf.Bucket { + trusted := surf.TrustedProxies(nil) + if p.app != nil { + trusted = surf.TrustedProxies(p.app.Config) + } + return map[string]surf.Bucket{ + "jz.bm.calendar-ip": { + Max: 60, + Decay: time.Minute, + Key: func(r *http.Request) string { return "jz.bm.calendar.ip|" + surf.ClientIP(r, trusted) }, + }, + "jz.bm.calendar-token": { + Max: 20, + Decay: time.Minute, + Key: func(r *http.Request) string { return "jz.bm.calendar.token|" + r.PathValue("token") }, + }, + } +} // APIPrefix is the path prefix of the student API. const APIPrefix = "/_bm/api/v1" @@ -33,6 +66,17 @@ func (p *Plugin) Routes(r pact.Router) error { g.Get("/materials", h.Materials) g.Get("/materials/{id}", h.Material) g.Where("id", "[0-9]+") + g.Get("/calendar-feed", h.CalendarFeed) + g.Put("/calendar-feed", h.UpdateCalendarFeed) + g.Post("/calendar-feed/rotate", h.RotateCalendarFeed, "throttle:10,1") + g.Delete("/calendar-feed", h.DeleteCalendarFeed) + }) + // The calendar feed: public, because calendar clients fetch it without + // credentials; the token in the path is the credential. ServeMux cannot + // match a suffix inside a segment, so {token} carries the ".ics" and the + // handler strips it. + r.Group(api.CalendarPath, surf.Use(bucketCalendarIP, bucketCalendarToken), func(g pact.Router) { + g.Get("/{token}", h.CalendarICS) }) // The student app: the least specific pattern, so every route above and // the admin's keep precedence. It also answers (404) the reserved paths. @@ -69,6 +113,7 @@ func (p *Plugin) handlers() *api.Handlers { if p.app != nil && p.app.Config != nil { h.PerPage = p.app.Config.Int("jz.bm.materials_per_page") h.DashboardLimit = p.app.Config.Int("jz.bm.dashboard_limit") + h.PublicURL = p.app.Config.String("jz.bm.public_url") } return h }