feat: serve a subscribable ICS calendar feed
This commit is contained in:
235
classes/calendar.go
Normal file
235
classes/calendar.go
Normal 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
105
classes/ics.go
Normal 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
|
||||
}
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
47
routes.go
47
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user