Files
sm-bm-plugin/controllers/api/manage.go
Jakub Zych 3346cb6ff6 feat: manage material visibility and shares
The management save/update body takes is_private, author_user_id and
share_user_ids (absent keeps each; a new material gets the caller as
author), and the managed material shows them. New routes PATCH
/materials/{id}/visibility, PUT /materials/{id}/shares and GET /users,
with the MCP tools bm_manage_set_material_visibility,
bm_manage_set_material_shares and bm_manage_list_users. The /bmadmin
form gets the private switch, the author picker and shared_with.
2026-11-08 11:56:14 +01:00

810 lines
24 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"slices"
"strconv"
"strings"
"time"
"unicode"
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire"
"git.golem15.com/jakub/sm-bm-plugin/classes"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// The management API (/api/v1/bm) is what the console commands do, over
// HTTP: it runs behind golem15.user's shared API-token, scope, and permission
// middleware, not the students' jwt.auth.
// flag reads a boolean query parameter: 1 and true are on.
func flag(r *http.Request, name string) bool {
v := r.URL.Query().Get(name)
return v == "1" || v == "true"
}
func writeInvalid(w http.ResponseWriter, message string) {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": message})
}
// body reads the request body and answers 413 or 400 itself when it cannot.
func body(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
raw, err := io.ReadAll(r.Body)
var tooLarge *http.MaxBytesError
switch {
case errors.As(err, &tooLarge):
wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": true, "message": "Request body too large."})
return nil, false
case err != nil:
wire.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": true, "message": "Unreadable request body."})
return nil, false
}
return raw, true
}
type tokenJSON struct {
ID uint `json:"id"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresAt *string `json:"expires_at"`
}
// TokenInfo answers GET /me: the user and the token of the request, to
// check a token without changing anything.
func (h *Handlers) TokenInfo(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
out := map[string]any{"user_id": userID}
cred, _ := bouncer.Credential(r.Context())
if t, ok := cred.(*usermodels.APIToken); ok {
name := ""
if t.Name != nil {
name = *t.Name
}
tj := tokenJSON{ID: t.ID, Name: name, Scopes: wire.Slice(t.Scopes.Get())}
if t.ExpiresAt != nil {
s := models.FormatDateTime(*t.ExpiresAt)
tj.ExpiresAt = &s
}
out["token"] = tj
}
writeData(w, out)
}
type seedCountsJSON struct {
Created int `json:"created"`
Updated int `json:"updated"`
Unchanged int `json:"unchanged"`
Deleted int `json:"deleted"`
}
func newSeedCountsJSON(c classes.SeedCounts) seedCountsJSON {
return seedCountsJSON{Created: c.Created, Updated: c.Updated, Unchanged: c.Unchanged, Deleted: c.Deleted}
}
// ApplySeed answers POST /seed?dry_run=&prune=: bm:seed over HTTP. The body
// is the seed document; the answer is the summary the command prints. A
// document that does not parse or validate is a 422 and writes nothing.
func (h *Handlers) ApplySeed(w http.ResponseWriter, r *http.Request) {
raw, ok := body(w, r)
if !ok {
return
}
file, err := classes.ParseSeed(bytes.NewReader(raw))
if err == nil {
err = file.Validate()
}
if err != nil {
writeInvalid(w, err.Error())
return
}
db, ok := h.db(w, r)
if !ok {
return
}
opts := classes.SeedOptions{DryRun: flag(r, "dry_run"), Prune: flag(r, "prune")}
sum, err := classes.SeedWith(r.Context(), db, file, opts)
if err != nil {
writeOpaque500(w)
return
}
writeData(w, map[string]any{
"dry_run": opts.DryRun,
"prune": opts.Prune,
"summary": map[string]seedCountsJSON{
"meetings": newSeedCountsJSON(sum.Meetings),
"courses": newSeedCountsJSON(sum.Courses),
"class_sessions": newSeedCountsJSON(sum.ClassSessions),
"deadlines": newSeedCountsJSON(sum.Deadlines),
"readings": newSeedCountsJSON(sum.Readings),
},
"removed": wire.Slice(sum.Removed),
})
}
// managedMaterialJSON is a material as the management API shows it: drafts
// too, so published_at may be null, and private ones with their author and
// the users they are shared with.
type managedMaterialJSON struct {
ID uint `json:"id"`
CourseID *uint `json:"course_id"`
Course *courseRef `json:"course"`
MeetingID *uint `json:"meeting_id"`
Title string `json:"title"`
Type string `json:"type"`
Body *string `json:"body"`
Source *string `json:"source"`
URL *string `json:"url"`
PublishedAt *string `json:"published_at"`
Tags []tagJSON `json:"tags"`
IsPrivate bool `json:"is_private"`
AuthorUserID *uint `json:"author_user_id"`
Author *userRefJSON `json:"author"`
Shares []userRefJSON `json:"shares"`
}
// materialPeople are the authors and shares of a batch of materials, as
// loadMaterialPeople reads them.
type materialPeople struct {
authors map[uint]userRefJSON
shares map[uint][]userRefJSON
}
// loadMaterialPeople reads the authors and the shares of rows in two
// queries plus one for the shared users.
func loadMaterialPeople(db *gorm.DB, rows ...models.Material) (materialPeople, error) {
var authorIDs, materialIDs []uint
for _, m := range rows {
materialIDs = append(materialIDs, m.ID)
if m.AuthorUserID != nil {
authorIDs = append(authorIDs, *m.AuthorUserID)
}
}
authors, err := userRefsByID(db, authorIDs)
if err != nil {
return materialPeople{}, err
}
shares, err := materialShareRefs(db, materialIDs)
if err != nil {
return materialPeople{}, err
}
return materialPeople{authors: authors, shares: shares}, nil
}
func newManagedMaterialJSON(m models.Material, people materialPeople) managedMaterialJSON {
out := managedMaterialJSON{
ID: m.ID, CourseID: m.CourseID, Course: newCourseRef(m.Course), MeetingID: m.MeetingID, Title: m.Title, Type: m.Type,
Body: m.Body, Source: m.Source, URL: m.URL, Tags: newTagsJSON(m.Tags),
IsPrivate: m.IsPrivate, AuthorUserID: m.AuthorUserID, Shares: people.shares[m.ID],
}
if m.AuthorUserID != nil {
if a, ok := people.authors[*m.AuthorUserID]; ok {
out.Author = &a
}
}
if out.Shares == nil {
out.Shares = []userRefJSON{}
}
if m.PublishedAt != nil {
s := models.FormatDateTime(*m.PublishedAt)
out.PublishedAt = &s
}
return out
}
// ManagedMaterials answers GET /materials: every material, drafts
// included, newest first. An MCP call gets the bodies only with
// include_body (see managedMaterialView).
func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
var rows []models.Material
if err := preloadMaterial(db).Order("jz_bm_materials.id DESC").Find(&rows).Error; err != nil {
writeOpaque500(w)
return
}
people, err := loadMaterialPeople(db, rows...)
if err != nil {
writeOpaque500(w)
return
}
out := make([]any, 0, len(rows))
for _, m := range rows {
out = append(out, managedMaterialView(newManagedMaterialJSON(m, people), viaMCP(r), includeBody(r)))
}
writeData(w, out)
}
// materialInput is the body of POST /materials and PUT /materials/{id}.
type materialInput struct {
CourseID *uint `json:"course_id"`
MeetingID *uint `json:"meeting_id"`
Title string `json:"title"`
Type string `json:"type"`
Body *string `json:"body"`
Source *string `json:"source"`
URL *string `json:"url"`
PublishedAt *string `json:"published_at"`
Tags *[]string `json:"tags"`
IsPrivate *bool `json:"is_private"`
AuthorUserID *uint `json:"author_user_id"`
ShareUserIDs *[]uint `json:"share_user_ids"`
}
// materialDraft is a decoded and validated material body, ready to apply.
type materialDraft struct {
in materialInput
// hasPublishedAt tells a sent published_at (null included) from an
// absent one.
hasPublishedAt bool
// hasMeetingID tells a sent meeting_id (null included) from an absent
// one: absent keeps the stored meeting, so scripts that never send it
// keep working.
hasMeetingID bool
// hasAuthor tells a sent author_user_id (null included) from an absent
// one.
hasAuthor bool
publishedAt *time.Time
tags []models.Tag
// shares is the checked share_user_ids, when sent.
shares []uint
}
// readMaterial decodes and validates the body of POST /materials and
// PUT /materials/{id}. It answers 400, 413, 422 or 500 itself and returns
// false then.
func (h *Handlers) readMaterial(w http.ResponseWriter, r *http.Request, db *gorm.DB) (materialDraft, bool) {
var d materialDraft
raw, ok := body(w, r)
if !ok {
return d, false
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&d.in); err != nil {
writeInvalid(w, "invalid JSON: "+err.Error())
return d, false
}
var keys map[string]json.RawMessage
_ = json.Unmarshal(raw, &keys)
_, d.hasPublishedAt = keys["published_at"]
_, d.hasMeetingID = keys["meeting_id"]
_, d.hasAuthor = keys["author_user_id"]
in := &d.in
in.Title = strings.TrimSpace(in.Title)
errs := map[string][]string{}
if in.Title == "" || len(in.Title) > 255 {
errs["title"] = []string{"The title is required and may not be longer than 255 characters."}
}
if !slices.Contains(models.MaterialTypes, in.Type) {
errs["type"] = []string{"The type must be one of: " + strings.Join(models.MaterialTypes, ", ") + "."}
}
if in.Source != nil && len(*in.Source) > 500 {
errs["source"] = []string{"The source may not be longer than 500 characters."}
}
if in.URL != nil && len(*in.URL) > 2048 {
errs["url"] = []string{"The url may not be longer than 2048 characters."}
}
if in.PublishedAt != nil {
t, err := models.ParseDateTime(*in.PublishedAt)
if err != nil {
errs["published_at"] = []string{"The published_at is not a valid date and time."}
}
d.publishedAt = &t
}
if in.Tags != nil {
for _, name := range *in.Tags {
name = strings.TrimSpace(name)
slug := slugify(name)
if slug == "" || len(name) > 100 || len(slug) > 100 {
errs["tags"] = []string{"Each tag must be a name of 1 to 100 characters."}
break
}
if !slices.ContainsFunc(d.tags, func(t models.Tag) bool { return t.Slug == slug }) {
d.tags = append(d.tags, models.Tag{Name: name, Slug: slug})
}
}
}
if in.CourseID != nil {
var n int64
if err := db.Model(&models.Course{}).Where("id = ?", *in.CourseID).Count(&n).Error; err != nil {
writeOpaque500(w)
return d, false
}
if n == 0 {
errs["course_id"] = []string{"The selected course_id is invalid."}
}
}
if in.MeetingID != nil {
var n int64
if err := db.Model(&models.Meeting{}).Where("id = ?", *in.MeetingID).Count(&n).Error; err != nil {
writeOpaque500(w)
return d, false
}
if n == 0 {
errs["meeting_id"] = []string{"The selected meeting_id is invalid."}
}
}
if in.AuthorUserID != nil {
found, err := userRefsByID(db, []uint{*in.AuthorUserID})
if err != nil {
writeOpaque500(w)
return d, false
}
if _, ok := found[*in.AuthorUserID]; !ok {
errs["author_user_id"] = []string{"The selected author_user_id is invalid."}
}
}
if in.ShareUserIDs != nil {
clean, msg, err := cleanUserIDs(db, *in.ShareUserIDs)
if err != nil {
writeOpaque500(w)
return d, false
}
if msg != "" {
errs["share_user_ids"] = []string{msg}
}
d.shares = clean
}
if len(errs) > 0 {
writeValidation(w, errs)
return d, false
}
return d, true
}
// identity scopes a query to the materials with the draft's title and
// course: the identity POST /materials upserts on.
func (d materialDraft) identity(q *gorm.DB) *gorm.DB {
q = q.Where("title = ?", d.in.Title)
if d.in.CourseID != nil {
return q.Where("course_id = ?", *d.in.CourseID)
}
return q.Where("course_id IS NULL")
}
// apply writes the draft onto m inside tx. published_at: absent publishes
// a new material now and keeps an existing one as it is; null makes a
// draft; otherwise the sent time. meeting_id: absent keeps it; null clears
// it. tags: absent keeps them; otherwise the list replaces them, and unknown
// tags are created. is_private: absent keeps it (a new material is public).
// author_user_id: absent keeps it (a new material gets callerID); null
// clears it. share_user_ids: absent keeps the shares; otherwise the list
// replaces them.
func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now time.Time, callerID uint) error {
in := d.in
m.CourseID, m.Title, m.Type = in.CourseID, in.Title, in.Type
m.Body, m.Source, m.URL = in.Body, in.Source, in.URL
if d.hasMeetingID {
m.MeetingID = in.MeetingID
}
if in.IsPrivate != nil {
m.IsPrivate = *in.IsPrivate
}
switch {
case d.hasAuthor:
m.AuthorUserID = in.AuthorUserID
case created && callerID != 0:
id := callerID
m.AuthorUserID = &id
}
switch {
case d.hasPublishedAt:
m.PublishedAt = d.publishedAt
case created:
m.PublishedAt = &now
}
if err := tx.Omit("Course", "Meeting", "Tags").Save(m).Error; err != nil {
return err
}
if in.ShareUserIDs != nil {
if err := replaceMaterialShares(tx, m.ID, d.shares); err != nil {
return err
}
}
if in.Tags == nil {
return nil
}
if err := tx.Where("material_id = ?", m.ID).Delete(&models.MaterialTag{}).Error; err != nil {
return err
}
for _, t := range d.tags {
var tag models.Tag
res := tx.Where("slug = ?", t.Slug).Limit(1).Find(&tag)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
tag = t
if err := tx.Create(&tag).Error; err != nil {
return err
}
}
if err := tx.Create(&models.MaterialTag{MaterialID: m.ID, TagID: tag.ID}).Error; err != nil {
return err
}
}
return nil
}
// writeManaged answers a write of r with the material id, reloaded with its
// course and tags, plus any extra top-level keys. Under MCP the answer never
// echoes the body.
func writeManaged(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint, status int, extra map[string]any) {
var out models.Material
if err := preloadMaterial(db).First(&out, id).Error; err != nil {
writeOpaque500(w)
return
}
people, err := loadMaterialPeople(db, out)
if err != nil {
writeOpaque500(w)
return
}
payload := map[string]any{"data": managedMaterialView(newManagedMaterialJSON(out, people), viaMCP(r), false)}
for k, v := range extra {
payload[k] = v
}
wire.WriteJSON(w, status, payload)
}
// UpsertMaterial answers POST /materials: it creates the material, or
// updates the one with the same title and course, so sending a document
// twice changes nothing. body, source and url are replaced as sent; see
// materialDraft.apply for published_at and tags. A renamed material is a
// new one here: PUT /materials/{id} renames.
func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
callerID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
d, ok := h.readMaterial(w, r, db)
if !ok {
return
}
var m models.Material
created := false
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
res := d.identity(tx).Order("id").Limit(1).Find(&m)
if res.Error != nil {
return res.Error
}
created = res.RowsAffected == 0
return d.apply(tx, &m, created, h.now(), callerID)
})
if err != nil {
writeOpaque500(w)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeManaged(w, r, db, m.ID, status, map[string]any{"created": created})
}
// UpdateMaterial answers PUT /materials/{id}: the same body as POST, applied
// to that material, so a title or course can change. A title and course
// another material already has is a 422, because POST would then be
// ambiguous. An unknown id is a 404.
func (h *Handlers) UpdateMaterial(w http.ResponseWriter, r *http.Request) {
callerID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return
}
d, ok := h.readMaterial(w, r, db)
if !ok {
return
}
var m models.Material
found, taken := false, false
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
res := tx.Where("id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil
}
found = true
var n int64
if err := d.identity(tx.Model(&models.Material{})).Where("id <> ?", m.ID).Count(&n).Error; err != nil {
return err
}
if n > 0 {
taken = true
return nil
}
return d.apply(tx, &m, false, h.now(), callerID)
})
switch {
case err != nil:
writeOpaque500(w)
case !found:
writeNotFound(w)
case taken:
writeValidation(w, map[string][]string{"title": {"Another material already has this title and course."}})
default:
writeManaged(w, r, db, m.ID, http.StatusOK, nil)
}
}
// managedMaterialDetailJSON is GET /materials/{id}: the material with its
// public files.
type managedMaterialDetailJSON struct {
managedMaterialJSON
Files []fileJSON `json:"files"`
}
// ManagedMaterial answers GET /materials/{id}: one material, draft or not,
// with its public files.
func (h *Handlers) ManagedMaterial(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return
}
h.writeMaterialDetail(w, r, db, uint(id), http.StatusOK)
}
// writeMaterialDetail answers r with the material id as GET /materials/{id}
// shows it, or 404 when there is none. Under MCP a GET carries the body only
// with include_body, and any other method's answer never does.
func (h *Handlers) writeMaterialDetail(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint, status int) {
h.writeMaterialDetailWith(w, r, db, id, status, nil)
}
// writeMaterialDetailWith is writeMaterialDetail with extra top-level keys
// beside data (the file warning of the inline upload).
func (h *Handlers) writeMaterialDetailWith(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint, status int, extra map[string]any) {
var m models.Material
res := preloadMaterial(db).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
writeOpaque500(w)
return
}
if res.RowsAffected == 0 {
writeNotFound(w)
return
}
var files []attach.File
if err := materialFiles(db, m).Order("sort_order, id").Find(&files).Error; err != nil {
writeOpaque500(w)
return
}
people, err := loadMaterialPeople(db, m)
if err != nil {
writeOpaque500(w)
return
}
out := managedMaterialDetailJSON{managedMaterialJSON: newManagedMaterialJSON(m, people), Files: make([]fileJSON, 0, len(files))}
for i := range files {
f := &files[i]
if !f.Public() {
continue
}
out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType})
}
answer := map[string]any{"data": managedMaterialDetailView(out, viaMCP(r), r.Method == http.MethodGet && includeBody(r))}
for k, v := range extra {
answer[k] = v
}
wire.WriteJSON(w, status, answer)
}
// DeleteMaterial answers DELETE /materials/{id}.
func (h *Handlers) DeleteMaterial(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return
}
var keys []string
found := false
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
res := tx.Where("id = ?", id).Delete(&models.Material{})
if res.Error != nil || res.RowsAffected == 0 {
return res.Error
}
found = true
return attach.DeleteForOwner(tx, models.Material{}, strconv.FormatInt(id, 10), func(k []string) error {
keys = k
return nil
})
})
switch {
case err != nil:
writeOpaque500(w)
return
case !found:
writeNotFound(w)
return
}
// The files go with the material: rows in the transaction, blobs after it.
if b := h.Bucket; b != nil && len(keys) > 0 {
if bucket := b(); bucket != nil {
_ = attach.DeleteKeys(r.Context(), bucket, keys)
}
}
w.WriteHeader(http.StatusNoContent)
}
// polish maps the Polish letters slugify cannot fold by case alone.
var polish = strings.NewReplacer("ą", "a", "ć", "c", "ę", "e", "ł", "l", "ń", "n", "ó", "o", "ś", "s", "ź", "z", "ż", "z")
// slugify turns a tag name into its slug: lower-case ASCII letters and
// digits, with a hyphen for every run of anything else.
func slugify(name string) string {
var b strings.Builder
hyphen := false
for _, r := range polish.Replace(strings.ToLower(name)) {
if r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
if hyphen && b.Len() > 0 {
b.WriteByte('-')
}
hyphen = false
b.WriteRune(r)
continue
}
hyphen = true
}
return b.String()
}
// managedAccessChange loads the material of r by id and, unless decode
// refuses the body, applies the access change it returns inside a
// transaction with the claim rule (a material with no author gets the
// caller). It answers 404, 422 or 500 itself, and on success the material as
// writeManaged shows it, so under MCP the body is never echoed.
func (h *Handlers) managedAccessChange(w http.ResponseWriter, r *http.Request, decode func(db *gorm.DB, raw []byte) (isPrivate *bool, setShares bool, shares []uint, ok bool)) {
callerID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return
}
var m models.Material
res := db.Where("id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
writeOpaque500(w)
return
}
if res.RowsAffected == 0 {
writeNotFound(w)
return
}
raw, ok := body(w, r)
if !ok {
return
}
isPrivate, setShares, shares, ok := decode(db, raw)
if !ok {
return
}
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
return applyMaterialAccess(tx, &m, callerID, isPrivate, setShares, shares)
})
if err != nil {
writeOpaque500(w)
return
}
writeManaged(w, r, db, m.ID, http.StatusOK, nil)
}
// SetMaterialVisibility answers PATCH /materials/{id}/visibility, body
// {"is_private": bool}: private means only the author and the users it is
// shared with see the material. A material with no author gets the caller
// as author. An unknown id is a 404.
func (h *Handlers) SetMaterialVisibility(w http.ResponseWriter, r *http.Request) {
h.managedAccessChange(w, r, func(_ *gorm.DB, raw []byte) (*bool, bool, []uint, bool) {
var in struct {
IsPrivate *bool `json:"is_private"`
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeInvalid(w, "invalid JSON: "+err.Error())
return nil, false, nil, false
}
if in.IsPrivate == nil {
writeValidation(w, map[string][]string{"is_private": {"The is_private field is required and must be true or false."}})
return nil, false, nil, false
}
return in.IsPrivate, false, nil, true
})
}
// SetMaterialShares answers PUT /materials/{id}/shares, body
// {"user_ids": [id]}: the list (null meaning none) replaces the users the
// material is shared with. The claim rule is as for SetMaterialVisibility.
func (h *Handlers) SetMaterialShares(w http.ResponseWriter, r *http.Request) {
h.managedAccessChange(w, r, func(db *gorm.DB, raw []byte) (*bool, bool, []uint, bool) {
var in struct {
UserIDs []uint `json:"user_ids"`
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeInvalid(w, "invalid JSON: "+err.Error())
return nil, false, nil, false
}
var keys map[string]json.RawMessage
_ = json.Unmarshal(raw, &keys)
if _, ok := keys["user_ids"]; !ok {
writeValidation(w, map[string][]string{"user_ids": {"The user_ids field is required: a list of user ids, empty for none."}})
return nil, false, nil, false
}
clean, msg, err := cleanUserIDs(db, in.UserIDs)
if err != nil {
writeOpaque500(w)
return nil, false, nil, false
}
if msg != "" {
writeValidation(w, map[string][]string{"user_ids": {msg}})
return nil, false, nil, false
}
return nil, true, clean, true
})
}
// ManagedUsers answers GET /users: the id, display name and email of every
// user, to pick a material's author or shares.
func (h *Handlers) ManagedUsers(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
users, err := listUserRefs(db, 0)
if err != nil {
writeOpaque500(w)
return
}
writeData(w, users)
}