- POST /materials locks the matched row and answers 409 to a stale revision when it updates; a create ignores revision - bm_manage_save_material and bm_manage_update_material take revision - /bmadmin saves bump the revision on content changes (not checked) - document revisions and the 409 in the README
935 lines
28 KiB
Go
935 lines
28 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"
|
|
"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. revision is checked only when
|
|
// the upsert updates an existing material, on its row locked for the write:
|
|
// a stale one is a 409 with the current version (writeConflict) and nothing
|
|
// is written. A create ignores it and starts at revision 1. The revision
|
|
// goes up only when the content changed (models.ReviseMaterial).
|
|
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, conflict := false, false
|
|
err := lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
|
res := d.identity(tx.Clauses(clause.Locking{Strength: "UPDATE"})).Order("id").Limit(1).Find(&m)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
created = res.RowsAffected == 0
|
|
if created {
|
|
return d.apply(tx, &m, true, h.now(), callerID)
|
|
}
|
|
if staleRevision(d.in.Revision, m.Revision) {
|
|
conflict = true
|
|
return nil
|
|
}
|
|
before, err := models.MaterialContent(tx, m.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := d.apply(tx, &m, false, h.now(), callerID); err != nil {
|
|
return err
|
|
}
|
|
return h.revise(ctx, tx, m.ID, before)
|
|
})
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
if conflict {
|
|
writeConflict(w, r, db, m.ID)
|
|
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)
|
|
}
|