- 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
447 lines
14 KiB
Go
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)
|
|
}
|