diff --git a/README.md b/README.md index 41739e8..81945e7 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,35 @@ The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with tim `DTSTAMP` and `LAST-MODIFIED` are the row's `updated_at`, so an unchanged timetable gives the same bytes. +## Management API + +`/api/v1/bm` is what `bm:seed` and the materials screen do, over HTTP, for scripts: it sits behind the application-wide API token of `golem15.user` (`Authorization: Bearer `, middleware `user.api_token`), not the students' `jwt.auth`. Every route needs a token scope (`user.scope:read` or `user.scope:write`) and, except `/me`, a permission of the token owner's groups (`jz.bm.access_courses`, `jz.bm.access_schedule`, `jz.bm.access_materials`, the codes of the admin screens); a missing one is a 403. The group is throttled per token (`throttle:jz.bm.manage`, 120 a minute). Responses are `{"data": …}`; validation errors are a 422 in Laravel's shape (`{"message", "errors": {field: [...]}}`), a bad or oversized body a 400 or 413. + +| Method | Route | Scope, permission | Answer | +|--------|-------|-------------------|--------| +| GET | `/api/v1/bm/me` | read | The token owner's `user_id` and the token (`id`, `name`, `scopes`, `expires_at`). | +| GET | `/api/v1/bm/courses` | read, courses | Every course (`courseJSON`), for the `course_id` of a material. | +| GET | `/api/v1/bm/materials` | read, materials | Every material, drafts included, newest first. | +| GET | `/api/v1/bm/materials/{id}` | read, materials | One material, draft or not, with its public `files`. 404 when unknown. | +| POST | `/api/v1/bm/materials` | write, materials | Upsert on `title` + `course_id`: 201 `{"data", "created": true}` for a new material, 200 `{"data", "created": false}` for an updated one. | +| PUT | `/api/v1/bm/materials/{id}` | write, materials | The same body applied to that material, so the title or course can change. 404 when unknown; 422 on `title` when another material already has the new title and course. | +| DELETE | `/api/v1/bm/materials/{id}` | write, materials | 204; 404 when unknown. | +| POST | `/api/v1/bm/seed?dry_run=&prune=` | write, courses + schedule + materials | `bm:seed` over HTTP: the body is the seed document, the answer the summary table. | + +The material body (POST and PUT), unknown keys are a 422: + +```json +{"course_id": 7, "title": "Definicje globalizacji", "type": "notatka", + "body": "# Robertson\n\n> …", "source": null, "url": null, + "published_at": "2026-10-16T20:00:00+02:00", "tags": ["Globalizacja", "Zjazd 2"]} +``` + +- `type`: `pdf`, `notatka`, `link`, `inne`. `body` is Markdown, rendered by the student app (headings start at h2 there; the title is the page's h1). +- `course_id`: null or an existing course. `body`, `source` and `url` are replaced as sent (absent means null). +- `published_at`: absent publishes a new material now and keeps an existing one as it is; `null` makes it a draft; otherwise a time as in the seed file. +- `tags`: absent keeps the tags; otherwise the list of names replaces them, and unknown tags are created (slug from the name, Polish letters folded). +- Files are not uploaded through this API; attach them in the admin. + ## Student app The built single-page app (Vue, source in the application repository's `frontend/`) is embedded from `public/` and served at `/`. Existing files are served as they are: `assets/*` (content-hashed) with `Cache-Control: public, max-age=31536000, immutable`, everything else with `no-cache` and an ETag (304 on revalidation). A GET for any other extension-less path answers `index.html` (client routes); a missing path with an extension is a 404. `/_user`, `/_bm`, `/_quizzes`, `/storage` and the admin path (`backend.uri`, default `/bmadmin`) are never answered by the app. Routes fails when `public/index.html` is missing. The output of `npm run build` in `frontend/` is committed in `public/`. diff --git a/controllers/api/manage.go b/controllers/api/manage.go index e91454e..844453e 100644 --- a/controllers/api/manage.go +++ b/controllers/api/manage.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "slices" + "strconv" "strings" "time" "unicode" @@ -15,6 +16,7 @@ import ( 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" @@ -198,7 +200,7 @@ func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) { writeData(w, out) } -// materialInput is the body of POST /materials. +// materialInput is the body of POST /materials and PUT /materials/{id}. type materialInput struct { CourseID *uint `json:"course_id"` Title string `json:"title"` @@ -210,29 +212,36 @@ type materialInput struct { Tags *[]string `json:"tags"` } -// 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. -// published_at: absent publishes a new material now and keeps an existing -// one as it is; null makes a draft; otherwise a timestamp as in the seed -// file. tags: absent keeps them; otherwise the list of tag names replaces -// them, and unknown tags are created. -func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) { +// 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 + return d, false } - var in materialInput dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() - if err := dec.Decode(&in); err != nil { + if err := dec.Decode(&d.in); err != nil { writeInvalid(w, "invalid JSON: "+err.Error()) - return + return d, false } var keys map[string]json.RawMessage _ = json.Unmarshal(raw, &keys) - _, hasPublishedAt := keys["published_at"] + _, 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 { @@ -247,15 +256,13 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) { if in.URL != nil && len(*in.URL) > 2048 { errs["url"] = []string{"The url may not be longer than 2048 characters."} } - var publishedAt *time.Time 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."} } - publishedAt = &t + d.publishedAt = &t } - var tags []models.Tag if in.Tags != nil { for _, name := range *in.Tags { name = strings.TrimSpace(name) @@ -264,20 +271,16 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) { errs["tags"] = []string{"Each tag must be a name of 1 to 100 characters."} break } - if !slices.ContainsFunc(tags, func(t models.Tag) bool { return t.Slug == slug }) { - tags = append(tags, models.Tag{Name: name, Slug: slug}) + 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}) } } } - db, ok := h.db(w, r) - if !ok { - return - } 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 + return d, false } if n == 0 { errs["course_id"] = []string{"The selected course_id is invalid."} @@ -285,73 +288,210 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) { } 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 { - q := tx.Where("title = ?", in.Title) - if in.CourseID != nil { - q = q.Where("course_id = ?", *in.CourseID) - } else { - q = q.Where("course_id IS NULL") - } - res := q.Order("id").Limit(1).Find(&m) + res := d.identity(tx).Order("id").Limit(1).Find(&m) if res.Error != nil { return res.Error } created = res.RowsAffected == 0 - 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 hasPublishedAt: - m.PublishedAt = publishedAt - case created: - now := h.now() - 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 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 + return d.apply(tx, &m, created, h.now()) }) if err != nil { writeOpaque500(w) return } - var out models.Material - if err := preloadMaterial(db).First(&out, m.ID).Error; err != nil { - writeOpaque500(w) - return - } status := http.StatusOK if created { status = http.StatusCreated } - wire.WriteJSON(w, status, map[string]any{"data": newManagedMaterialJSON(out), "created": created}) + 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}. diff --git a/management_token_test.go b/management_token_test.go index 4599a90..a16125a 100644 --- a/management_token_test.go +++ b/management_token_test.go @@ -1,8 +1,12 @@ package bm_test import ( + "bytes" + "encoding/json" + "io" "net/http" "net/http/httptest" + "strconv" "strings" "testing" @@ -63,3 +67,139 @@ RETURNING id`).Scan(&groupID).Error; err != nil { t.Fatalf("request after permission revocation = %d %s", rec.Code, rec.Body.String()) } } + +// TestManagementMaterialsUpsertAndUpdate drives the material routes of the +// management API with a write token: the upsert identity (title and course), +// the update by id with a rename and a draft, the identity conflict, and +// the 404s. +func TestManagementMaterialsUpsertAndUpdate(t *testing.T) { + app, plugins, db := migrated(t) + + var userID uint + if err := db.Raw(` +INSERT INTO users (email, password, must_change_password) +VALUES ('bm-materials-token@example.test', '', false) +RETURNING id`).Scan(&userID).Error; err != nil { + t.Fatal(err) + } + var groupID uint + if err := db.Raw(` +INSERT INTO user_groups (name, code, permissions, created_at, updated_at) +VALUES ('BM materials', 'bm-materials', '{"jz.bm.access_materials":1}', NOW(), NOW()) +RETURNING id`).Scan(&groupID).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, userID, groupID).Error; err != nil { + t.Fatal(err) + } + secret, token, err := userclasses.MintAPIToken("usr_", userID, "BM notes", []string{"read", "write"}, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := db.Create(token).Error; err != nil { + t.Fatal(err) + } + handler, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatal(err) + } + send := func(method, path string, payload any) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var rd io.Reader + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + rd = bytes.NewReader(raw) + } + req := httptest.NewRequest(method, path, rd) + req.Header.Set("Authorization", "Bearer "+secret) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + var out map[string]any + if rec.Body.Len() > 0 { + _ = json.Unmarshal(rec.Body.Bytes(), &out) + } + return rec, out + } + data := func(out map[string]any) map[string]any { + t.Helper() + d, _ := out["data"].(map[string]any) + if d == nil { + t.Fatalf("no data object in %v", out) + } + return d + } + idPath := func(id any) string { return "/api/v1/bm/materials/" + strconv.Itoa(int(id.(float64))) } + + note := map[string]any{"title": "Definicje globalizacji", "type": "notatka", "body": "# Robertson\n\n> single place", "tags": []string{"Globalizacja", "Zjazd 2"}} + rec, out := send(http.MethodPost, "/api/v1/bm/materials", note) + if rec.Code != http.StatusCreated || out["created"] != true { + t.Fatalf("create = %d %s", rec.Code, rec.Body.String()) + } + first := data(out) + if first["published_at"] == nil { + t.Error("a new material without published_at is not published") + } + if tags, _ := first["tags"].([]any); len(tags) != 2 || tags[1].(map[string]any)["slug"] != "zjazd-2" { + t.Errorf("tags = %v", first["tags"]) + } + + rec, out = send(http.MethodPost, "/api/v1/bm/materials", note) + if rec.Code != http.StatusOK || out["created"] != false || data(out)["id"] != first["id"] { + t.Fatalf("repeated upsert = %d %s", rec.Code, rec.Body.String()) + } + + rec, out = send(http.MethodGet, idPath(first["id"]), nil) + if rec.Code != http.StatusOK || data(out)["title"] != "Definicje globalizacji" { + t.Fatalf("show = %d %s", rec.Code, rec.Body.String()) + } + if files, ok := data(out)["files"].([]any); !ok || len(files) != 0 { + t.Errorf("files = %v", data(out)["files"]) + } + + renamed := map[string]any{"title": "Definicje globalizacji (zjazd 2)", "type": "notatka", "body": "zmienione", "published_at": nil} + rec, out = send(http.MethodPut, idPath(first["id"]), renamed) + if rec.Code != http.StatusOK { + t.Fatalf("update = %d %s", rec.Code, rec.Body.String()) + } + updated := data(out) + if updated["title"] != renamed["title"] || updated["body"] != "zmienione" || updated["published_at"] != nil { + t.Errorf("updated = %v", updated) + } + if tags, _ := updated["tags"].([]any); len(tags) != 2 { + t.Errorf("an update without tags changed them: %v", updated["tags"]) + } + + rec, out = send(http.MethodPost, "/api/v1/bm/materials", note) + if rec.Code != http.StatusCreated || data(out)["id"] == first["id"] { + t.Fatalf("upsert after the rename = %d %s", rec.Code, rec.Body.String()) + } + second := data(out) + + rec, out = send(http.MethodPut, idPath(second["id"]), renamed) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"title"`) { + t.Fatalf("update onto a taken identity = %d %s", rec.Code, rec.Body.String()) + } + + for _, method := range []string{http.MethodGet, http.MethodPut} { + var payload any + if method == http.MethodPut { + payload = renamed + } + if rec, _ := send(method, "/api/v1/bm/materials/999999", payload); rec.Code != http.StatusNotFound { + t.Errorf("%s of an unknown id = %d %s", method, rec.Code, rec.Body.String()) + } + } + + if rec, _ := send(http.MethodDelete, idPath(first["id"]), nil); rec.Code != http.StatusNoContent { + t.Fatalf("delete = %d %s", rec.Code, rec.Body.String()) + } + if rec, _ := send(http.MethodGet, idPath(first["id"]), nil); rec.Code != http.StatusNotFound { + t.Errorf("show after delete = %d", rec.Code) + } +} diff --git a/plugin_test.go b/plugin_test.go index a34d844..f48c2aa 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -120,7 +120,9 @@ func TestRoutes(t *testing.T) { "GET /api/v1/bm/me", "GET /api/v1/bm/courses", "GET /api/v1/bm/materials", + "GET /api/v1/bm/materials/{id}", "POST /api/v1/bm/materials", + "PUT /api/v1/bm/materials/{id}", "DELETE /api/v1/bm/materials/{id}", "POST /api/v1/bm/seed", } diff --git a/routes.go b/routes.go index a546c30..fb61f18 100644 --- a/routes.go +++ b/routes.go @@ -116,7 +116,11 @@ func (p *Plugin) Routes(r pact.Router) error { g.Get("/me", h.TokenInfo, "user.scope:read") g.Get("/courses", h.ManagedCourses, "user.scope:read", "user.permission:"+controllers.PermCourses) g.Get("/materials", h.ManagedMaterials, "user.scope:read", "user.permission:"+controllers.PermMaterials) + g.Get("/materials/{id}", h.ManagedMaterial, "user.scope:read", "user.permission:"+controllers.PermMaterials) + g.Where("id", "[0-9]+") g.Post("/materials", h.UpsertMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials) + g.Put("/materials/{id}", h.UpdateMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials) + g.Where("id", "[0-9]+") g.Delete("/materials/{id}", h.DeleteMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials) g.Where("id", "[0-9]+") g.Post("/seed", h.ApplySeed, "user.scope:write",