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

@@ -15,9 +15,11 @@ import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire"
"git.golem15.com/jakub/sm-bm-plugin/controllers"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Private materials: a material with is_private is visible only to its
@@ -229,6 +231,80 @@ func (h *Handlers) managedMaterialForCaller(w http.ResponseWriter, r *http.Reque
return m, true
}
// writeForbidden answers 403 to a caller who may see a resource but not
// change it.
func writeForbidden(w http.ResponseWriter) {
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden."})
}
// editableMaterialForCaller loads the material of the {id} parameter for the
// author's routes (edit, unpublish, image upload). Unlike
// managedMaterialForCaller it tells "not yours" from "not there" (D-07): a
// material the caller cannot see (private and not theirs, a draft, not
// published yet, unknown) is a 404, and one they see but may not manage (no
// jz.bm.access_materials, or another author) a 403. Both helpers stay: the
// access panel keeps its 404. It answers itself and returns ok false then;
// manager tells whether the caller holds jz.bm.access_materials, for the
// re-check on the locked row.
func (h *Handlers) editableMaterialForCaller(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID uint) (models.Material, bool, bool) {
var m models.Material
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return m, false, false
}
res := visibleMaterials(db, h.now(), userID).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
writeOpaque500(w)
return m, false, false
}
if res.RowsAffected == 0 {
writeNotFound(w)
return m, false, false
}
manager, err := h.managesMaterials(r.Context(), db, userID)
if err != nil {
writeOpaque500(w)
return m, false, false
}
if !canManageMaterial(m, userID, manager) {
writeForbidden(w)
return m, manager, false
}
return m, manager, true
}
// editOutcome is what lockEditableMaterial found on the locked row.
type editOutcome int
const (
editAllowed editOutcome = iota
// editGone: the material is no longer visible to the caller.
editGone
// editForbidden: the caller may no longer manage it.
editForbidden
)
// lockEditableMaterial re-loads material id inside tx, locked FOR UPDATE,
// and re-checks that the caller still sees it and may manage it, so a
// concurrent access or publication change cannot slip between the check
// of editableMaterialForCaller and the write.
func (h *Handlers) lockEditableMaterial(tx *gorm.DB, userID, id uint, manager bool) (models.Material, editOutcome, error) {
var m models.Material
res := visibleMaterials(tx.Clauses(clause.Locking{Strength: "UPDATE"}), h.now(), userID).
Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
return m, editGone, res.Error
}
if res.RowsAffected == 0 {
return m, editGone, nil
}
if !canManageMaterial(m, userID, manager) {
return m, editForbidden, nil
}
return m, editAllowed, nil
}
// writeMaterialAccess answers the access of m as the caller sees it.
func writeMaterialAccess(w http.ResponseWriter, db *gorm.DB, m models.Material, userID uint) {
shares, err := materialShareRefs(db, []uint{m.ID})