feat: serve a subscribable ICS calendar feed

This commit is contained in:
Jakub Zych
2026-10-02 17:38:48 +02:00
parent bccb747171
commit 90247a460d
6 changed files with 766 additions and 1 deletions

235
classes/calendar.go Normal file
View File

@@ -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")
}

105
classes/ics.go Normal file
View File

@@ -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:<component>.
func (w *ICS) Begin(component string) { w.Line("BEGIN:" + component) }
// End writes END:<component>.
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:<t in UTC>, such as DTSTART:20261003T060000Z.
func (w *ICS) UTC(name string, t time.Time) { w.Prop(name, FormatUTC(t)) }
// Date writes name;VALUE=DATE:<YYYYMMDD> 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
}

View File

@@ -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: ""

View File

@@ -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
View 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))
}

View File

@@ -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
}