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" "gorm.io/gorm/clause" ) // 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"` // Revision is the material's content revision, which a write may send // back to be refused when the material changed since; UpdatedAt is // when the row was last written. Revision uint `json:"revision"` UpdatedAt string `json:"updated_at"` // Snippet is the matched passage of a search result, as on the // student list; nil, and the key absent, without q. Snippet any `json:"snippet,omitempty"` } // 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], Revision: m.Revision, UpdatedAt: models.FormatDateTime(m.UpdatedAt), } 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?q=&include_body=: every material, // drafts and private ones included, newest first. With q (2 to 100 // characters) the engine answers candidates, which are loaded in rank order // with at most one snippet each; when the engine cannot answer the request // is a 503. An MCP call gets the bodies only with include_body (see // managedMaterialView) and the snippets as plain text. func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) { in := query(r, "q", "include_body") if !h.validate(w, r, in, manageMaterialsRules) { return } var cands *classes.MaterialCandidates if q := searchQuery(in); q != "" { c, err := h.MaterialIndex.Search(r.Context(), classes.MaterialSearch{Q: q}) if err != nil { writeSearchUnavailable(w) return } cands = &c } db, ok := h.db(w, r) if !ok { return } var rows []models.Material if cands == nil || len(cands.IDs) > 0 { q := preloadMaterial(db) if cands != nil { q = q.Where("jz_bm_materials.id IN ?", cands.IDs).Order(rankOrder(cands.IDs)) } else { q = q.Order("jz_bm_materials.id DESC") } if err := q.Find(&rows).Error; err != nil { writeOpaque500(w) return } } people, err := loadMaterialPeople(db, rows...) if err != nil { writeOpaque500(w) return } var files map[uint][]models.IndexedFile if cands != nil { files, err = snippetFiles(db, materialIDs(rows), cands.Hits) if err != nil { writeOpaque500(w) return } } mcp := viaMCP(r) out := make([]any, 0, len(rows)) for _, m := range rows { row := newManagedMaterialJSON(m, people) if cands != nil { if hit, ok := cands.Hits[m.ID]; ok { row.Snippet = snippetValue(pickSnippet(hit, m, files[m.ID]), mcp) } } out = append(out, managedMaterialView(row, mcp, includeBody(r))) } writeData(w, out) } // materialInput is the body of POST /materials and PUT /materials/{id}. // Revision is the revision the writer last read: when sent and not the // stored one the write is a 409 and nothing is written; absent means // unchecked, so the write overwrites as callers that never send it expect // (D-04). 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"` Revision *uint `json:"revision"` } // 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) { raw, ok := body(w, r) if !ok { return materialDraft{}, false } d, err := decodeMaterial(raw) if err != nil { writeInvalid(w, "invalid JSON: "+err.Error()) return d, false } if !h.checkMaterial(w, db, &d, nil) { return d, false } return d, true } // decodeMaterial decodes a material body, refusing unknown keys, and notes // which of published_at, meeting_id and author_user_id were sent. It does // not validate the values; checkMaterial does. func decodeMaterial(raw []byte) (materialDraft, error) { var d materialDraft dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&d.in); err != nil { return d, err } 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"] return d, nil } // checkMaterial validates a decoded material body against db and fills in // its published_at, tags and shares. Its messages join errs, which a caller // may pre-fill with its own rules (nil means none); when there are any it // answers one 422 with all of them, and on a database error a 500, and // returns false then. func (h *Handlers) checkMaterial(w http.ResponseWriter, db *gorm.DB, d *materialDraft, errs map[string][]string) bool { if errs == nil { errs = map[string][]string{} } in := &d.in in.Title = strings.TrimSpace(in.Title) 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 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 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 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 false } if msg != "" { errs["share_user_ids"] = []string{msg} } d.shares = clean } if len(errs) > 0 { writeValidation(w, errs) return false } return 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 } h.MaterialIndex.Sync(r.Context(), db, m.ID) 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. The row is locked for the write: a sent // revision that is not the stored one is a 409 with the current version // (writeConflict) and nothing is written; without revision the write // overwrites. The revision goes up only when the content changed // (models.ReviseMaterial). 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, conflict := false, false, false err := lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { res := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).Limit(1).Find(&m) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return nil } found = true if staleRevision(d.in.Revision, m.Revision) { conflict = true return nil } before, err := models.MaterialContent(tx, m.ID) if err != nil { return err } 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 } if err := d.apply(tx, &m, false, h.now(), callerID); err != nil { return err } return h.revise(ctx, tx, m.ID, before) }) switch { case err != nil: writeOpaque500(w) case !found: writeNotFound(w) case conflict: writeConflict(w, r, db, m.ID) case taken: writeValidation(w, map[string][]string{"title": {"Another material already has this title and course."}}) default: h.MaterialIndex.Sync(r.Context(), db, m.ID) 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 delete statement has no primary key on its model, so no callback // removes the document: remove it now that the transaction committed. h.MaterialIndex.Remove(r.Context(), db, uint(id)) // 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 } // An access change touches only visibility (and the author), which the // search index does not hold, so it does not sync. 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) }