package api import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "slices" "strconv" "strings" "time" "unicode" usermodels "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/surf" "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" ) // The management API (/api/v1/bm) is what the console commands do, over // HTTP: it runs behind golem15.user's shared API-token, scope, and permission // middleware, not the students' jwt.auth. // flag reads a boolean query parameter: 1 and true are on. func flag(r *http.Request, name string) bool { v := r.URL.Query().Get(name) return v == "1" || v == "true" } func writeInvalid(w http.ResponseWriter, message string) { wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": message}) } // body reads the request body and answers 413 or 400 itself when it cannot. func body(w http.ResponseWriter, r *http.Request) ([]byte, bool) { raw, err := io.ReadAll(r.Body) var tooLarge *http.MaxBytesError switch { case errors.As(err, &tooLarge): wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": true, "message": "Request body too large."}) return nil, false case err != nil: wire.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": true, "message": "Unreadable request body."}) return nil, false } return raw, true } type tokenJSON struct { ID uint `json:"id"` Name string `json:"name"` Scopes []string `json:"scopes"` ExpiresAt *string `json:"expires_at"` } // TokenInfo answers GET /me: the user and the token of the request, to // check a token without changing anything. func (h *Handlers) TokenInfo(w http.ResponseWriter, r *http.Request) { userID, ok := h.caller(w, r) if !ok { return } out := map[string]any{"user_id": userID} cred, _ := bouncer.Credential(r.Context()) if t, ok := cred.(*usermodels.APIToken); ok { name := "" if t.Name != nil { name = *t.Name } tj := tokenJSON{ID: t.ID, Name: name, Scopes: wire.Slice(t.Scopes.Get())} if t.ExpiresAt != nil { s := models.FormatDateTime(*t.ExpiresAt) tj.ExpiresAt = &s } out["token"] = tj } writeData(w, out) } type seedCountsJSON struct { Created int `json:"created"` Updated int `json:"updated"` Unchanged int `json:"unchanged"` Deleted int `json:"deleted"` } func newSeedCountsJSON(c classes.SeedCounts) seedCountsJSON { return seedCountsJSON{Created: c.Created, Updated: c.Updated, Unchanged: c.Unchanged, Deleted: c.Deleted} } // ApplySeed answers POST /seed?dry_run=&prune=: bm:seed over HTTP. The body // is the seed document; the answer is the summary the command prints. A // document that does not parse or validate is a 422 and writes nothing. func (h *Handlers) ApplySeed(w http.ResponseWriter, r *http.Request) { raw, ok := body(w, r) if !ok { return } file, err := classes.ParseSeed(bytes.NewReader(raw)) if err == nil { err = file.Validate() } if err != nil { writeInvalid(w, err.Error()) return } db, ok := h.db(w, r) if !ok { return } opts := classes.SeedOptions{DryRun: flag(r, "dry_run"), Prune: flag(r, "prune")} sum, err := classes.SeedWith(r.Context(), db, file, opts) if err != nil { writeOpaque500(w) return } writeData(w, map[string]any{ "dry_run": opts.DryRun, "prune": opts.Prune, "summary": map[string]seedCountsJSON{ "meetings": newSeedCountsJSON(sum.Meetings), "courses": newSeedCountsJSON(sum.Courses), "class_sessions": newSeedCountsJSON(sum.ClassSessions), "deadlines": newSeedCountsJSON(sum.Deadlines), "readings": newSeedCountsJSON(sum.Readings), }, "removed": wire.Slice(sum.Removed), }) } // managedMaterialJSON is a material as the management API shows it: drafts // too, so published_at may be null. type managedMaterialJSON struct { ID uint `json:"id"` CourseID *uint `json:"course_id"` Course *courseRef `json:"course"` Title string `json:"title"` Type string `json:"type"` Body *string `json:"body"` Source *string `json:"source"` URL *string `json:"url"` PublishedAt *string `json:"published_at"` Tags []tagJSON `json:"tags"` } func newManagedMaterialJSON(m models.Material) managedMaterialJSON { out := managedMaterialJSON{ ID: m.ID, CourseID: m.CourseID, Course: newCourseRef(m.Course), Title: m.Title, Type: m.Type, Body: m.Body, Source: m.Source, URL: m.URL, Tags: newTagsJSON(m.Tags), } if m.PublishedAt != nil { s := models.FormatDateTime(*m.PublishedAt) out.PublishedAt = &s } return out } // ManagedCourses answers GET /courses: every course, for the course_id of // a material. func (h *Handlers) ManagedCourses(w http.ResponseWriter, r *http.Request) { db, ok := h.db(w, r) if !ok { return } var rows []models.Course if err := db.Order("semester, sort_order, name, id").Find(&rows).Error; err != nil { writeOpaque500(w) return } out := make([]courseJSON, 0, len(rows)) for _, c := range rows { out = append(out, newCourseJSON(c)) } writeData(w, out) } // ManagedMaterials answers GET /materials: every material, drafts // included, newest first. func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) { db, ok := h.db(w, r) if !ok { return } var rows []models.Material if err := preloadMaterial(db).Order("jz_bm_materials.id DESC").Find(&rows).Error; err != nil { writeOpaque500(w) return } out := make([]managedMaterialJSON, 0, len(rows)) for _, m := range rows { out = append(out, newManagedMaterialJSON(m)) } writeData(w, out) } // materialInput is the body of POST /materials and PUT /materials/{id}. type materialInput struct { CourseID *uint `json:"course_id"` Title string `json:"title"` Type string `json:"type"` Body *string `json:"body"` Source *string `json:"source"` URL *string `json:"url"` PublishedAt *string `json:"published_at"` Tags *[]string `json:"tags"` } // materialDraft is a decoded and validated material body, ready to apply. type materialDraft struct { in materialInput // hasPublishedAt tells a sent published_at (null included) from an // absent one. hasPublishedAt bool publishedAt *time.Time tags []models.Tag } // readMaterial decodes and validates the body of POST /materials and // PUT /materials/{id}. It answers 400, 413, 422 or 500 itself and returns // false then. func (h *Handlers) readMaterial(w http.ResponseWriter, r *http.Request, db *gorm.DB) (materialDraft, bool) { var d materialDraft raw, ok := body(w, r) if !ok { return d, false } dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&d.in); err != nil { writeInvalid(w, "invalid JSON: "+err.Error()) return d, false } var keys map[string]json.RawMessage _ = json.Unmarshal(raw, &keys) _, d.hasPublishedAt = keys["published_at"] in := &d.in in.Title = strings.TrimSpace(in.Title) errs := map[string][]string{} if in.Title == "" || len(in.Title) > 255 { errs["title"] = []string{"The title is required and may not be longer than 255 characters."} } if !slices.Contains(models.MaterialTypes, in.Type) { errs["type"] = []string{"The type must be one of: " + strings.Join(models.MaterialTypes, ", ") + "."} } if in.Source != nil && len(*in.Source) > 500 { errs["source"] = []string{"The source may not be longer than 500 characters."} } if in.URL != nil && len(*in.URL) > 2048 { errs["url"] = []string{"The url may not be longer than 2048 characters."} } if in.PublishedAt != nil { t, err := models.ParseDateTime(*in.PublishedAt) if err != nil { errs["published_at"] = []string{"The published_at is not a valid date and time."} } d.publishedAt = &t } if in.Tags != nil { for _, name := range *in.Tags { name = strings.TrimSpace(name) slug := slugify(name) if slug == "" || len(name) > 100 || len(slug) > 100 { errs["tags"] = []string{"Each tag must be a name of 1 to 100 characters."} break } if !slices.ContainsFunc(d.tags, func(t models.Tag) bool { return t.Slug == slug }) { d.tags = append(d.tags, models.Tag{Name: name, Slug: slug}) } } } if in.CourseID != nil { var n int64 if err := db.Model(&models.Course{}).Where("id = ?", *in.CourseID).Count(&n).Error; err != nil { writeOpaque500(w) return d, false } if n == 0 { errs["course_id"] = []string{"The selected course_id is invalid."} } } if len(errs) > 0 { writeValidation(w, errs) return d, false } return d, true } // identity scopes a query to the materials with the draft's title and // course: the identity POST /materials upserts on. func (d materialDraft) identity(q *gorm.DB) *gorm.DB { q = q.Where("title = ?", d.in.Title) if d.in.CourseID != nil { return q.Where("course_id = ?", *d.in.CourseID) } return q.Where("course_id IS NULL") } // apply writes the draft onto m inside tx. published_at: absent publishes // a new material now and keeps an existing one as it is; null makes a // draft; otherwise the sent time. tags: absent keeps them; otherwise the // list replaces them, and unknown tags are created. func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now time.Time) error { in := d.in m.CourseID, m.Title, m.Type = in.CourseID, in.Title, in.Type m.Body, m.Source, m.URL = in.Body, in.Source, in.URL switch { case d.hasPublishedAt: m.PublishedAt = d.publishedAt case created: m.PublishedAt = &now } if err := tx.Omit("Course", "Tags").Save(m).Error; err != nil { return err } if in.Tags == nil { return nil } if err := tx.Where("material_id = ?", m.ID).Delete(&models.MaterialTag{}).Error; err != nil { return err } for _, t := range d.tags { var tag models.Tag res := tx.Where("slug = ?", t.Slug).Limit(1).Find(&tag) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { tag = t if err := tx.Create(&tag).Error; err != nil { return err } } if err := tx.Create(&models.MaterialTag{MaterialID: m.ID, TagID: tag.ID}).Error; err != nil { return err } } return nil } // writeManaged answers with the material id, reloaded with its course and // tags, plus any extra top-level keys. func writeManaged(w http.ResponseWriter, db *gorm.DB, id uint, status int, extra map[string]any) { var out models.Material if err := preloadMaterial(db).First(&out, id).Error; err != nil { writeOpaque500(w) return } payload := map[string]any{"data": newManagedMaterialJSON(out)} for k, v := range extra { payload[k] = v } wire.WriteJSON(w, status, payload) } // UpsertMaterial answers POST /materials: it creates the material, or // updates the one with the same title and course, so sending a document // twice changes nothing. body, source and url are replaced as sent; see // materialDraft.apply for published_at and tags. A renamed material is a // new one here: PUT /materials/{id} renames. func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) { db, ok := h.db(w, r) if !ok { return } d, ok := h.readMaterial(w, r, db) if !ok { return } var m models.Material created := false err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error { res := d.identity(tx).Order("id").Limit(1).Find(&m) if res.Error != nil { return res.Error } created = res.RowsAffected == 0 return d.apply(tx, &m, created, h.now()) }) if err != nil { writeOpaque500(w) return } status := http.StatusOK if created { status = http.StatusCreated } writeManaged(w, db, m.ID, status, map[string]any{"created": created}) } // UpdateMaterial answers PUT /materials/{id}: the same body as POST, applied // to that material, so a title or course can change. A title and course // another material already has is a 422, because POST would then be // ambiguous. An unknown id is a 404. func (h *Handlers) UpdateMaterial(w http.ResponseWriter, r *http.Request) { db, ok := h.db(w, r) if !ok { return } id, ok := surf.IntParam(r, "id") if !ok { writeNotFound(w) return } d, ok := h.readMaterial(w, r, db) if !ok { return } var m models.Material found, taken := false, false err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error { res := tx.Where("id = ?", id).Limit(1).Find(&m) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return nil } found = true var n int64 if err := d.identity(tx.Model(&models.Material{})).Where("id <> ?", m.ID).Count(&n).Error; err != nil { return err } if n > 0 { taken = true return nil } return d.apply(tx, &m, false, h.now()) }) switch { case err != nil: writeOpaque500(w) case !found: writeNotFound(w) case taken: writeValidation(w, map[string][]string{"title": {"Another material already has this title and course."}}) default: writeManaged(w, db, m.ID, http.StatusOK, nil) } } // managedMaterialDetailJSON is GET /materials/{id}: the material with its // public files. type managedMaterialDetailJSON struct { managedMaterialJSON Files []fileJSON `json:"files"` } // ManagedMaterial answers GET /materials/{id}: one material, draft or not, // with its public files. func (h *Handlers) ManagedMaterial(w http.ResponseWriter, r *http.Request) { db, ok := h.db(w, r) if !ok { return } id, ok := surf.IntParam(r, "id") if !ok { writeNotFound(w) return } var m models.Material res := preloadMaterial(db).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m) if res.Error != nil { writeOpaque500(w) return } if res.RowsAffected == 0 { writeNotFound(w) return } var files []attach.File err := db.Where("attachment_type = ? AND attachment_id = ? AND field = ?", m.MorphName(), strconv.FormatUint(uint64(m.ID), 10), models.MaterialFilesField). Order("sort_order, id"). Find(&files).Error if err != nil { writeOpaque500(w) return } out := managedMaterialDetailJSON{managedMaterialJSON: newManagedMaterialJSON(m), Files: make([]fileJSON, 0, len(files))} for i := range files { f := &files[i] if !f.Public() { continue } out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType}) } writeData(w, out) } // DeleteMaterial answers DELETE /materials/{id}. func (h *Handlers) DeleteMaterial(w http.ResponseWriter, r *http.Request) { db, ok := h.db(w, r) if !ok { return } id, ok := surf.IntParam(r, "id") if !ok { writeNotFound(w) return } res := db.Where("id = ?", id).Delete(&models.Material{}) if res.Error != nil { writeOpaque500(w) return } if res.RowsAffected == 0 { writeNotFound(w) return } w.WriteHeader(http.StatusNoContent) } // polish maps the Polish letters slugify cannot fold by case alone. var polish = strings.NewReplacer("ą", "a", "ć", "c", "ę", "e", "ł", "l", "ń", "n", "ó", "o", "ś", "s", "ź", "z", "ż", "z") // slugify turns a tag name into its slug: lower-case ASCII letters and // digits, with a hyphen for every run of anything else. func slugify(name string) string { var b strings.Builder hyphen := false for _, r := range polish.Replace(strings.ToLower(name)) { if r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) { if hyphen && b.Len() > 0 { b.WriteByte('-') } hyphen = false b.WriteRune(r) continue } hyphen = true } return b.String() }