Files
sm-bm-plugin/controllers/api/calendar.go
Jakub Zych 71b848a376 Adopt SummerCMS v0.1.1 admin features
Datepickers on native time.Time / lagoon.Date fields, file uploads on
materials, readings and class sessions managed from the course form
(course_id made nullable).
2026-10-03 06:03:34 +02:00

373 lines
9.9 KiB
Go

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 := 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))
}