Files
sm-bm-plugin/controllers/api/material_access.go
Jakub Zych 2a47186a7a feat: announce material revisions on materials:{id}
- every revision bump (API writes and /bmadmin saves) enqueues
  updated.jz.bm.material with {material_id, revision} in a savepoint
- the materials namespace authorizer allows a subscribe only to a
  visible material the user may manage
- api.ManagesMaterials and api.CanWatchMaterial
2026-11-09 01:12:49 +01:00

447 lines
14 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"slices"
"strconv"
"strings"
"time"
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
"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
// author and to the users it is shared with (jz_bm_material_shares). Every
// student read goes through visibleMaterials or, in raw SQL, through
// visibleMaterialSQL, so a private material never shows up elsewhere.
// visibleMaterialSQL is the visibility condition of the materials table
// under alias: public, written by the caller, or shared with the caller. It
// takes the args (userID, userID).
func visibleMaterialSQL(alias string) string {
return "(" + alias + ".is_private = FALSE OR " + alias + ".author_user_id = ? OR EXISTS (SELECT 1 FROM jz_bm_material_shares ms WHERE ms.material_id = " + alias + ".id AND ms.user_id = ?))"
}
// visibleMaterials scopes a material query to what the caller may see: a
// publication time that has passed and the visibility rule. It is the only
// scope student material reads may use.
func visibleMaterials(q *gorm.DB, now time.Time, userID uint) *gorm.DB {
return published(q, now).Where(visibleMaterialSQL("jz_bm_materials"), userID, userID)
}
// userRefJSON is a user as the material access lists show it.
type userRefJSON struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// newUserRef labels a user by "name surname", or by the email when both are
// empty.
func newUserRef(u usermodels.User) userRefJSON {
name := strings.TrimSpace(deref(u.Name) + " " + deref(u.Surname))
if name == "" {
name = u.Email
}
return userRefJSON{ID: u.ID, Name: name, Email: u.Email}
}
func selectUserRefs(db *gorm.DB) *gorm.DB {
return db.Model(&usermodels.User{}).Select("id", "name", "surname", "email")
}
// listUserRefs lists every user but excludeID (none when 0), by name.
func listUserRefs(db *gorm.DB, excludeID uint) ([]userRefJSON, error) {
q := selectUserRefs(db)
if excludeID != 0 {
q = q.Where("id <> ?", excludeID)
}
var users []usermodels.User
if err := q.Order("name, surname, email, id").Find(&users).Error; err != nil {
return nil, err
}
out := make([]userRefJSON, 0, len(users))
for _, u := range users {
out = append(out, newUserRef(u))
}
return out, nil
}
// userRefsByID returns the existing users among ids, keyed by id.
func userRefsByID(db *gorm.DB, ids []uint) (map[uint]userRefJSON, error) {
out := map[uint]userRefJSON{}
if len(ids) == 0 {
return out, nil
}
var users []usermodels.User
if err := selectUserRefs(db).Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, err
}
for _, u := range users {
out[u.ID] = newUserRef(u)
}
return out, nil
}
// materialShareRefs returns the users each material is shared with, by user
// id. Deleted users drop out.
func materialShareRefs(db *gorm.DB, materialIDs []uint) (map[uint][]userRefJSON, error) {
out := map[uint][]userRefJSON{}
if len(materialIDs) == 0 {
return out, nil
}
var rows []models.MaterialShare
if err := db.Where("material_id IN ?", materialIDs).Order("material_id, user_id").Find(&rows).Error; err != nil {
return nil, err
}
userIDs := make([]uint, 0, len(rows))
for _, row := range rows {
userIDs = append(userIDs, row.UserID)
}
users, err := userRefsByID(db, userIDs)
if err != nil {
return nil, err
}
for _, row := range rows {
if u, ok := users[row.UserID]; ok {
out[row.MaterialID] = append(out[row.MaterialID], u)
}
}
return out, nil
}
// maxShareUsers caps the users one material may be shared with.
const maxShareUsers = 200
// cleanUserIDs dedupes a share list and checks it: no zero ids, at most
// maxShareUsers, and every id an existing user. msg is the validation
// message when the list is refused.
func cleanUserIDs(db *gorm.DB, ids []uint) ([]uint, string, error) {
clean := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
return nil, "Each user id must be a positive number.", nil
}
if !slices.Contains(clean, id) {
clean = append(clean, id)
}
}
if len(clean) > maxShareUsers {
return nil, "A material may be shared with at most " + strconv.Itoa(maxShareUsers) + " users.", nil
}
slices.Sort(clean)
if len(clean) == 0 {
return clean, "", nil
}
var n int64
if err := db.Model(&usermodels.User{}).Where("id IN ?", clean).Count(&n).Error; err != nil {
return nil, "", err
}
if int(n) != len(clean) {
return nil, "The selected user_ids are invalid.", nil
}
return clean, "", nil
}
// replaceMaterialShares makes userIDs the users the material is shared with.
func replaceMaterialShares(tx *gorm.DB, materialID uint, userIDs []uint) error {
if err := tx.Where("material_id = ?", materialID).Delete(&models.MaterialShare{}).Error; err != nil {
return err
}
for _, id := range userIDs {
if err := tx.Create(&models.MaterialShare{MaterialID: materialID, UserID: id}).Error; err != nil {
return err
}
}
return nil
}
// managesMaterials tells whether the user holds jz.bm.access_materials.
func (h *Handlers) managesMaterials(ctx context.Context, db *gorm.DB, userID uint) (bool, error) {
return ManagesMaterials(ctx, db, userID)
}
// ManagesMaterials tells whether the user holds jz.bm.access_materials. The
// realtime connection-token guard admits such users (D-20).
func ManagesMaterials(ctx context.Context, db *gorm.DB, userID uint) (bool, error) {
principal, err := userclasses.PrincipalByID(ctx, db, userID)
if err != nil {
return false, err
}
return principal != nil && cabana.Allows(principal, []string{controllers.PermMaterials}), nil
}
// CanWatchMaterial tells whether the user may follow material id's live
// revision channel (D-20): the material is visible to them at now and they
// may manage it (canManageMaterial). It reads current state on every call;
// a material they cannot see is false without an error.
func CanWatchMaterial(ctx context.Context, db *gorm.DB, now time.Time, userID, id uint) (bool, error) {
var m models.Material
res := visibleMaterials(db.WithContext(ctx), now, userID).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
return false, res.Error
}
if res.RowsAffected == 0 {
return false, nil
}
manager, err := ManagesMaterials(ctx, db, userID)
if err != nil {
return false, err
}
return canManageMaterial(m, userID, manager), nil
}
// canManageMaterial tells whether a material manager may change the
// material's access: it is theirs, or it has no author yet (the first change
// makes them its author).
func canManageMaterial(m models.Material, userID uint, manager bool) bool {
return manager && (m.AuthorUserID == nil || *m.AuthorUserID == userID)
}
// materialAccessJSON is GET /materials/{id}/access: the material's
// visibility, the users it is shared with and the users to pick from.
type materialAccessJSON struct {
MaterialID uint `json:"material_id"`
IsPrivate bool `json:"is_private"`
AuthorUserID *uint `json:"author_user_id"`
Shares []userRefJSON `json:"shares"`
Users []userRefJSON `json:"users"`
}
// materialAccessInput is the body of PUT /materials/{id}/access.
type materialAccessInput struct {
IsPrivate *bool `json:"is_private"`
UserIDs *[]uint `json:"user_ids"`
}
// managedMaterialForCaller loads a material the caller sees and may manage.
// It answers 404 or 500 itself and returns false then: a material the caller
// may not manage is a 404, so nothing hints that a private one exists.
func (h *Handlers) managedMaterialForCaller(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID uint) (models.Material, bool) {
var m models.Material
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return m, 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
}
if res.RowsAffected == 0 {
writeNotFound(w)
return m, false
}
manager, err := h.managesMaterials(r.Context(), db, userID)
if err != nil {
writeOpaque500(w)
return m, false
}
if !canManageMaterial(m, userID, manager) {
writeNotFound(w)
return m, false
}
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})
if err != nil {
writeOpaque500(w)
return
}
users, err := listUserRefs(db, userID)
if err != nil {
writeOpaque500(w)
return
}
out := materialAccessJSON{MaterialID: m.ID, IsPrivate: m.IsPrivate, AuthorUserID: m.AuthorUserID, Shares: shares[m.ID], Users: users}
if out.Shares == nil {
out.Shares = []userRefJSON{}
}
writeData(w, out)
}
// MaterialAccess answers GET /materials/{id}/access for the material's
// author (or any material manager while it has no author): its visibility,
// its shares and the users it can be shared with. Anyone else gets a 404.
func (h *Handlers) MaterialAccess(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, ok := h.managedMaterialForCaller(w, r, db, userID)
if !ok {
return
}
writeMaterialAccess(w, db, m, userID)
}
// UpdateMaterialAccess answers PUT /materials/{id}/access, body
// {"is_private": bool?, "user_ids": [id]?}: absent keys keep the stored
// value, and user_ids (null meaning none) replaces the shares. The first
// change of a material with no author makes the caller its author. Access is
// as for MaterialAccess.
func (h *Handlers) UpdateMaterialAccess(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, ok := h.managedMaterialForCaller(w, r, db, userID)
if !ok {
return
}
raw, ok := body(w, r)
if !ok {
return
}
var in materialAccessInput
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeInvalid(w, "invalid JSON: "+err.Error())
return
}
var shares []uint
if in.UserIDs != nil {
clean, msg, err := cleanUserIDs(db, *in.UserIDs)
if err != nil {
writeOpaque500(w)
return
}
if msg != "" {
writeValidation(w, map[string][]string{"user_ids": {msg}})
return
}
shares = clean
}
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
return applyMaterialAccess(tx, &m, userID, in.IsPrivate, in.UserIDs != nil, shares)
})
if err != nil {
writeOpaque500(w)
return
}
writeMaterialAccess(w, db, m, userID)
}
// applyMaterialAccess writes a visibility and share change onto m inside
// tx. A material with no author gets userID as author (the claim rule).
// Only is_private and author_user_id are updated; the shares are replaced
// when setShares. The search index holds none of these, so no caller syncs
// after it.
func applyMaterialAccess(tx *gorm.DB, m *models.Material, userID uint, isPrivate *bool, setShares bool, shares []uint) error {
if m.AuthorUserID == nil {
id := userID
m.AuthorUserID = &id
}
if isPrivate != nil {
m.IsPrivate = *isPrivate
}
err := tx.Model(&models.Material{}).Where("id = ?", m.ID).
Updates(map[string]any{"is_private": m.IsPrivate, "author_user_id": m.AuthorUserID}).Error
if err != nil {
return err
}
if !setShares {
return nil
}
return replaceMaterialShares(tx, m.ID, shares)
}