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) }