Files
sm-bm-plugin/controllers/api/material_access.go
Jakub Zych ffd19342b2 feat: keep the materials index in sync and add bm:materials-reindex
- the materials admin syncs after create and update and removes after
  delete, once the save transaction commits (UseMaterialIndex, set at Boot)
- POST and PUT /api/v1/bm/materials sync after the transaction, DELETE
  removes the document (its statement has no primary key, so no callback)
- bm:materials-reindex [--fresh] [--if-configured] ensures the index and
  syncs every material; it refuses without an engine and runs nightly
  at 04:15 with --if-configured
- TestMaterialSearchAgainstDatabase (Postgres) is written for the final
  verification phase
2026-11-08 19:50:50 +01:00

345 lines
10 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/jakub/sm-bm-plugin/controllers"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// 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) {
principal, err := userclasses.PrincipalByID(ctx, db, userID)
if err != nil {
return false, err
}
return principal != nil && cabana.Allows(principal, []string{controllers.PermMaterials}), 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
}
// 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)
}