feat: private materials with per-user shares

A material may be private: only its author and the users it is shared
with see it. Every student read (list, detail, course detail and
counters, the checklist linked material, the token twins) goes through
one visibility fragment. Existing materials stay public with no author.

GET/PUT /_bm/api/v1/materials/{id}/access let the author, a material
manager, toggle the flag and pick the users; the first change of an
author-less material makes the caller its author.
This commit is contained in:
Jakub Zych
2026-11-08 11:52:47 +01:00
parent 5ed02ed9f7
commit 418f8240a4
14 changed files with 688 additions and 65 deletions

View File

@@ -0,0 +1,343 @@
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.
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)
}