The management API could only upsert a material on its title and course,
so a renamed note became a second material. PUT /materials/{id} applies the
same body to one material (422 when the new title and course belong to
another one), GET /materials/{id} shows a draft or published material with
its public files, and the upsert, update and validation share one path.
The README documents the management API.
541 lines
15 KiB
Go
541 lines
15 KiB
Go
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()
|
|
}
|