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 := models.FormatDateTime(*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)) }