feat: the author edits a material in the app

- PUT /_bm/api/v1/materials/{id} (jwt.auth only, no token twin): every
  key of the edit body required, revision checked on the row locked FOR
  UPDATE, 409 with the student detail of the server version
- editableMaterialForCaller: 404 for what the caller cannot see, 403 for
  what they see but may not manage
- tags from the dictionary only (at most 4), http(s) urls, the body
  normalised as bm-notatka push does; course, author and publication kept
- loadMaterialDetail shared by GET /materials/{id} and the edit answers
This commit is contained in:
Jakub Zych
2026-11-09 01:01:25 +01:00
parent 263c9c3926
commit 7bf18de987
9 changed files with 725 additions and 11 deletions

View File

@@ -0,0 +1,276 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"slices"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/wire"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// The author's routes (D-07): the student app edits, unpublishes and adds
// editor images to a material through them. They run under jwt.auth only,
// with no token twin (tokens and MCP write through the management API), and
// each one goes through editableMaterialForCaller: a material the caller
// cannot see is a 404, one they see but may not manage a 403. None of them
// changes the author, the publication time (except unpublish), is_private or
// the shares (D-08, D-10).
// maxEditTags caps the tags the student editor may give a material.
const maxEditTags = 4
// materialEditKeys are the keys of PUT /materials/{id}, all required.
var materialEditKeys = []string{"revision", "title", "type", "body", "source", "url", "meeting_id", "tags"}
// materialEditInput is the body of the student PUT /materials/{id} (D-09).
// It has no course, author, publication or access keys, so none can be sent
// (unknown keys are refused); the course is the stored one.
type materialEditInput struct {
Revision *uint `json:"revision"`
Title *string `json:"title"`
Type *string `json:"type"`
Body *string `json:"body"`
Source *string `json:"source"`
URL *string `json:"url"`
MeetingID *uint `json:"meeting_id"`
Tags *[]string `json:"tags"`
}
// decodeMaterialEdit decodes the edit body, refusing unknown keys (the
// error, answered as "invalid JSON"). The returned messages name every key
// that is missing, and revision, title, type or tags sent as null; the
// caller adds its own rules to them before checkMaterial.
func decodeMaterialEdit(raw []byte) (materialEditInput, map[string][]string, error) {
var in materialEditInput
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
return in, nil, err
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return in, nil, err
}
errs := map[string][]string{}
for _, k := range materialEditKeys {
if _, ok := keys[k]; !ok {
errs[k] = []string{"The " + k + " field is required."}
}
}
for k, missing := range map[string]bool{
"revision": in.Revision == nil,
"title": in.Title == nil,
"type": in.Type == nil,
"tags": in.Tags == nil,
} {
if _, sent := keys[k]; sent && missing {
errs[k] = []string{"The " + k + " field is required."}
}
}
return in, errs, nil
}
// normalizeBody normalises a note body the way bm-notatka push does (A6):
// CRLF and CR become LF, surrounding whitespace goes, an empty body becomes
// null and any other ends with exactly one newline. So a web save, a pull
// and a push of the same text are byte-identical and never bump the
// revision.
func normalizeBody(b *string) *string {
if b == nil {
return nil
}
s := strings.ReplaceAll(*b, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = strings.TrimSpace(s)
if s == "" {
return nil
}
s += "\n"
return &s
}
// hasWebScheme reports whether u starts with http:// or https://, in any
// case.
func hasWebScheme(u string) bool {
l := strings.ToLower(u)
return strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://")
}
// checkMaterialEdit adds the student-only rules of the edit body to errs:
// at most maxEditTags distinct tags, every tag from the jz_bm_tags
// dictionary (the app never creates a tag, A2), and a url that is a web
// address.
func checkMaterialEdit(db *gorm.DB, in materialEditInput, errs map[string][]string) error {
if in.URL != nil && strings.TrimSpace(*in.URL) != "" && !hasWebScheme(*in.URL) {
errs["url"] = []string{"The url must start with http:// or https://."}
}
if in.Tags == nil {
return nil
}
var slugs []string
for _, name := range *in.Tags {
slug := slugify(strings.TrimSpace(name))
if slug != "" && !slices.Contains(slugs, slug) {
slugs = append(slugs, slug)
}
}
if len(slugs) > maxEditTags {
errs["tags"] = []string{"A material may have at most " + strconv.Itoa(maxEditTags) + " tags."}
return nil
}
if len(slugs) == 0 {
return nil
}
var n int64
if err := db.Model(&models.Tag{}).Where("slug IN ?", slugs).Distinct("slug").Count(&n).Error; err != nil {
return err
}
if int(n) != len(slugs) {
errs["tags"] = []string{"Pick the tags from the dictionary."}
}
return nil
}
// EditMaterial answers the student PUT /materials/{id} (D-09): the author
// (or any material manager while it has no author) saves the note's title,
// type, body, source, url, meeting and tags. Every key of
// materialEditInput is required and nothing else is accepted; the course,
// the author, the publication and the access stay as stored. revision is
// required: a stale one is a 409 with the current version
// (writeStudentConflict) and nothing is written (D-01, D-04). The row is
// locked FOR UPDATE and re-checked inside the transaction. The answer is
// the student detail, 200.
func (h *Handlers) EditMaterial(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
m, manager, ok := h.editableMaterialForCaller(w, r, db, userID)
if !ok {
return
}
raw, ok := body(w, r)
if !ok {
return
}
in, errs, err := decodeMaterialEdit(raw)
if err != nil {
writeInvalid(w, "invalid JSON: "+err.Error())
return
}
if err := checkMaterialEdit(db, in, errs); err != nil {
writeOpaque500(w)
return
}
d := materialDraft{
in: materialInput{
CourseID: m.CourseID,
MeetingID: in.MeetingID,
Title: deref(in.Title),
Type: deref(in.Type),
Body: normalizeBody(in.Body),
Source: in.Source,
URL: in.URL,
Tags: in.Tags,
Revision: in.Revision,
},
hasMeetingID: true,
}
if !h.checkMaterial(w, db, &d, errs) {
return
}
outcome := editAllowed
conflict, taken := false, false
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
cur, why, err := h.lockEditableMaterial(tx, userID, m.ID, manager)
if err != nil || why != editAllowed {
outcome = why
return err
}
if staleRevision(d.in.Revision, cur.Revision) {
conflict = true
return nil
}
// The course is the locked row's (Pitfall 3): apply writes it as
// sent, and the edit body never carries one.
d.in.CourseID = cur.CourseID
before, err := models.MaterialContent(tx, cur.ID)
if err != nil {
return err
}
var n int64
if err := d.identity(tx.Model(&models.Material{})).Where("id <> ?", cur.ID).Count(&n).Error; err != nil {
return err
}
if n > 0 {
taken = true
return nil
}
if err := d.apply(tx, &cur, false, h.now(), userID); err != nil {
return err
}
return h.revise(ctx, tx, cur.ID, before)
})
switch {
case err != nil:
writeOpaque500(w)
case outcome == editGone:
writeNotFound(w)
case outcome == editForbidden:
writeForbidden(w)
case conflict:
h.writeStudentConflict(w, r, db, userID, m.ID)
case taken:
writeValidation(w, map[string][]string{"title": {"Another material already has this title and course."}})
default:
h.MaterialIndex.Sync(r.Context(), db, m.ID)
out, found, err := h.loadMaterialDetail(r.Context(), db, userID, m.ID)
switch {
case err != nil:
writeOpaque500(w)
case !found:
writeNotFound(w)
default:
writeData(w, out)
}
}
}
// writeStudentConflict answers 409 to a student edit that sent a stale
// revision: the current revision, its updated_at and the material in the
// student detail shape (with its body), so the editor can show the server
// version. A material that vanished meanwhile is a 404.
func (h *Handlers) writeStudentConflict(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID, id uint) {
out, found, err := h.loadMaterialDetail(r.Context(), db, userID, id)
if err != nil {
writeOpaque500(w)
return
}
if !found {
writeNotFound(w)
return
}
var row models.Material
if err := db.Select("id", "updated_at").Where("id = ?", id).Take(&row).Error; err != nil {
writeOpaque500(w)
return
}
wire.WriteJSON(w, http.StatusConflict, map[string]any{
"error": true,
"message": conflictMessage,
"revision": out.Revision,
"updated_at": models.FormatDateTime(row.UpdatedAt),
"data": out,
})
}