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).
373 lines
9.9 KiB
Go
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))
|
|
}
|