feat: serve a subscribable ICS calendar feed
This commit is contained in:
@@ -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 {
|
||||
|
||||
372
controllers/api/calendar.go
Normal file
372
controllers/api/calendar.go
Normal file
@@ -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/<token>.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))
|
||||
}
|
||||
Reference in New Issue
Block a user