- GET /_bm/api/v1/tags: the jz_bm_tags dictionary, to material managers
- POST /materials/{id}/unpublish: back to drafts, no revision bump, the
search document resynced
- POST /materials/{id}/images: one JPEG/PNG/GIF/WebP per request through
storeMaterialUpload, refusals with reason type, size or count
- storeMaterialUpload/fileRefusalFor: attach.ErrNotImage is a type
refusal, not a 500; storeMaterialFile keeps its texts
- the author's routes get their own per-user buckets (the inline
throttle form shares one counter per user)
- README: the author routes and revision on the student detail
466 lines
14 KiB
Go
466 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/wire"
|
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// The author's routes (D-07): the student app edits, unpublishes and adds
|
|
// editor images to a material through them. They run under jwt.auth only,
|
|
// with no token twin (tokens and MCP write through the management API), and
|
|
// each one goes through editableMaterialForCaller: a material the caller
|
|
// cannot see is a 404, one they see but may not manage a 403. None of them
|
|
// changes the author, the publication time (except unpublish), is_private or
|
|
// the shares (D-08, D-10).
|
|
|
|
// maxEditTags caps the tags the student editor may give a material.
|
|
const maxEditTags = 4
|
|
|
|
// materialEditKeys are the keys of PUT /materials/{id}, all required.
|
|
var materialEditKeys = []string{"revision", "title", "type", "body", "source", "url", "meeting_id", "tags"}
|
|
|
|
// materialEditInput is the body of the student PUT /materials/{id} (D-09).
|
|
// It has no course, author, publication or access keys, so none can be sent
|
|
// (unknown keys are refused); the course is the stored one.
|
|
type materialEditInput struct {
|
|
Revision *uint `json:"revision"`
|
|
Title *string `json:"title"`
|
|
Type *string `json:"type"`
|
|
Body *string `json:"body"`
|
|
Source *string `json:"source"`
|
|
URL *string `json:"url"`
|
|
MeetingID *uint `json:"meeting_id"`
|
|
Tags *[]string `json:"tags"`
|
|
}
|
|
|
|
// decodeMaterialEdit decodes the edit body, refusing unknown keys (the
|
|
// error, answered as "invalid JSON"). The returned messages name every key
|
|
// that is missing, and revision, title, type or tags sent as null; the
|
|
// caller adds its own rules to them before checkMaterial.
|
|
func decodeMaterialEdit(raw []byte) (materialEditInput, map[string][]string, error) {
|
|
var in materialEditInput
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&in); err != nil {
|
|
return in, nil, err
|
|
}
|
|
var keys map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &keys); err != nil {
|
|
return in, nil, err
|
|
}
|
|
errs := map[string][]string{}
|
|
for _, k := range materialEditKeys {
|
|
if _, ok := keys[k]; !ok {
|
|
errs[k] = []string{"The " + k + " field is required."}
|
|
}
|
|
}
|
|
for k, missing := range map[string]bool{
|
|
"revision": in.Revision == nil,
|
|
"title": in.Title == nil,
|
|
"type": in.Type == nil,
|
|
"tags": in.Tags == nil,
|
|
} {
|
|
if _, sent := keys[k]; sent && missing {
|
|
errs[k] = []string{"The " + k + " field is required."}
|
|
}
|
|
}
|
|
return in, errs, nil
|
|
}
|
|
|
|
// normalizeBody normalises a note body the way bm-notatka push does (A6):
|
|
// CRLF and CR become LF, surrounding whitespace goes, an empty body becomes
|
|
// null and any other ends with exactly one newline. So a web save, a pull
|
|
// and a push of the same text are byte-identical and never bump the
|
|
// revision.
|
|
func normalizeBody(b *string) *string {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
s := strings.ReplaceAll(*b, "\r\n", "\n")
|
|
s = strings.ReplaceAll(s, "\r", "\n")
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
s += "\n"
|
|
return &s
|
|
}
|
|
|
|
// hasWebScheme reports whether u starts with http:// or https://, in any
|
|
// case.
|
|
func hasWebScheme(u string) bool {
|
|
l := strings.ToLower(u)
|
|
return strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://")
|
|
}
|
|
|
|
// checkMaterialEdit adds the student-only rules of the edit body to errs:
|
|
// at most maxEditTags distinct tags, every tag from the jz_bm_tags
|
|
// dictionary (the app never creates a tag, A2), and a url that is a web
|
|
// address.
|
|
func checkMaterialEdit(db *gorm.DB, in materialEditInput, errs map[string][]string) error {
|
|
if in.URL != nil && strings.TrimSpace(*in.URL) != "" && !hasWebScheme(*in.URL) {
|
|
errs["url"] = []string{"The url must start with http:// or https://."}
|
|
}
|
|
if in.Tags == nil {
|
|
return nil
|
|
}
|
|
var slugs []string
|
|
for _, name := range *in.Tags {
|
|
slug := slugify(strings.TrimSpace(name))
|
|
if slug != "" && !slices.Contains(slugs, slug) {
|
|
slugs = append(slugs, slug)
|
|
}
|
|
}
|
|
if len(slugs) > maxEditTags {
|
|
errs["tags"] = []string{"A material may have at most " + strconv.Itoa(maxEditTags) + " tags."}
|
|
return nil
|
|
}
|
|
if len(slugs) == 0 {
|
|
return nil
|
|
}
|
|
var n int64
|
|
if err := db.Model(&models.Tag{}).Where("slug IN ?", slugs).Distinct("slug").Count(&n).Error; err != nil {
|
|
return err
|
|
}
|
|
if int(n) != len(slugs) {
|
|
errs["tags"] = []string{"Pick the tags from the dictionary."}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EditMaterial answers the student PUT /materials/{id} (D-09): the author
|
|
// (or any material manager while it has no author) saves the note's title,
|
|
// type, body, source, url, meeting and tags. Every key of
|
|
// materialEditInput is required and nothing else is accepted; the course,
|
|
// the author, the publication and the access stay as stored. revision is
|
|
// required: a stale one is a 409 with the current version
|
|
// (writeStudentConflict) and nothing is written (D-01, D-04). The row is
|
|
// locked FOR UPDATE and re-checked inside the transaction. The answer is
|
|
// the student detail, 200.
|
|
func (h *Handlers) EditMaterial(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, manager, ok := h.editableMaterialForCaller(w, r, db, userID)
|
|
if !ok {
|
|
return
|
|
}
|
|
raw, ok := body(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
in, errs, err := decodeMaterialEdit(raw)
|
|
if err != nil {
|
|
writeInvalid(w, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
if err := checkMaterialEdit(db, in, errs); err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
d := materialDraft{
|
|
in: materialInput{
|
|
CourseID: m.CourseID,
|
|
MeetingID: in.MeetingID,
|
|
Title: deref(in.Title),
|
|
Type: deref(in.Type),
|
|
Body: normalizeBody(in.Body),
|
|
Source: in.Source,
|
|
URL: in.URL,
|
|
Tags: in.Tags,
|
|
Revision: in.Revision,
|
|
},
|
|
hasMeetingID: true,
|
|
}
|
|
if !h.checkMaterial(w, db, &d, errs) {
|
|
return
|
|
}
|
|
outcome := editAllowed
|
|
conflict, taken := false, false
|
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
|
cur, why, err := h.lockEditableMaterial(tx, userID, m.ID, manager)
|
|
if err != nil || why != editAllowed {
|
|
outcome = why
|
|
return err
|
|
}
|
|
if staleRevision(d.in.Revision, cur.Revision) {
|
|
conflict = true
|
|
return nil
|
|
}
|
|
// The course is the locked row's (Pitfall 3): apply writes it as
|
|
// sent, and the edit body never carries one.
|
|
d.in.CourseID = cur.CourseID
|
|
before, err := models.MaterialContent(tx, cur.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var n int64
|
|
if err := d.identity(tx.Model(&models.Material{})).Where("id <> ?", cur.ID).Count(&n).Error; err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
taken = true
|
|
return nil
|
|
}
|
|
if err := d.apply(tx, &cur, false, h.now(), userID); err != nil {
|
|
return err
|
|
}
|
|
return h.revise(ctx, tx, cur.ID, before)
|
|
})
|
|
switch {
|
|
case err != nil:
|
|
writeOpaque500(w)
|
|
case outcome == editGone:
|
|
writeNotFound(w)
|
|
case outcome == editForbidden:
|
|
writeForbidden(w)
|
|
case conflict:
|
|
h.writeStudentConflict(w, r, db, userID, 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)
|
|
out, found, err := h.loadMaterialDetail(r.Context(), db, userID, m.ID)
|
|
switch {
|
|
case err != nil:
|
|
writeOpaque500(w)
|
|
case !found:
|
|
writeNotFound(w)
|
|
default:
|
|
writeData(w, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// writeStudentConflict answers 409 to a student edit that sent a stale
|
|
// revision: the current revision, its updated_at and the material in the
|
|
// student detail shape (with its body), so the editor can show the server
|
|
// version. A material that vanished meanwhile is a 404.
|
|
func (h *Handlers) writeStudentConflict(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID, id uint) {
|
|
out, found, err := h.loadMaterialDetail(r.Context(), db, userID, id)
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
if !found {
|
|
writeNotFound(w)
|
|
return
|
|
}
|
|
var row models.Material
|
|
if err := db.Select("id", "updated_at").Where("id = ?", id).Take(&row).Error; err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
wire.WriteJSON(w, http.StatusConflict, map[string]any{
|
|
"error": true,
|
|
"message": conflictMessage,
|
|
"revision": out.Revision,
|
|
"updated_at": models.FormatDateTime(row.UpdatedAt),
|
|
"data": out,
|
|
})
|
|
}
|
|
|
|
// imageLimits are the attach.Store rules of an editor image (D-16): image
|
|
// mode sniffs and decodes the bytes and admits only JPEG, PNG, GIF and
|
|
// WebP (never SVG), up to MaterialFileMaxBytes.
|
|
var imageLimits = attach.Limits{MaxBytes: MaterialFileMaxBytes, Image: true}
|
|
|
|
// Tags answers GET /tags: the tag dictionary the editor picks from (A2),
|
|
// every row of jz_bm_tags by name, to material managers; anyone else gets a
|
|
// 403.
|
|
func (h *Handlers) Tags(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.caller(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
manager, err := h.managesMaterials(r.Context(), db, userID)
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
if !manager {
|
|
writeForbidden(w)
|
|
return
|
|
}
|
|
var tags []models.Tag
|
|
if err := db.Order("name, id").Find(&tags).Error; err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
writeData(w, newTagsJSON(tags))
|
|
}
|
|
|
|
// UnpublishMaterial answers POST /materials/{id}/unpublish (D-11, "Usuń" in
|
|
// the app): the material becomes a draft (published_at NULL), which the
|
|
// student API no longer serves, so even its author gets a 404 afterwards.
|
|
// Nothing is deleted: a push without --draft or /bmadmin publishes it
|
|
// again. The revision stays (D-03). The search document is resynced, not
|
|
// removed, because the management search keeps drafts. 204.
|
|
func (h *Handlers) UnpublishMaterial(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, manager, ok := h.editableMaterialForCaller(w, r, db, userID)
|
|
if !ok {
|
|
return
|
|
}
|
|
outcome := editAllowed
|
|
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
|
|
cur, why, err := h.lockEditableMaterial(tx, userID, m.ID, manager)
|
|
if err != nil || why != editAllowed {
|
|
outcome = why
|
|
return err
|
|
}
|
|
return tx.Model(&models.Material{}).Where("id = ?", cur.ID).Update("published_at", nil).Error
|
|
})
|
|
switch {
|
|
case err != nil:
|
|
writeOpaque500(w)
|
|
case outcome == editGone:
|
|
writeNotFound(w)
|
|
case outcome == editForbidden:
|
|
writeForbidden(w)
|
|
default:
|
|
h.MaterialIndex.Sync(r.Context(), db, m.ID)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// writeFileRefusal answers 422 to a refused editor image, with the reason
|
|
// (type, size or count) for the editor's message.
|
|
func writeFileRefusal(w http.ResponseWriter, why fileRefusal) {
|
|
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
|
"message": why.Text,
|
|
"errors": map[string][]string{"file": {why.Text}},
|
|
"reason": why.Reason,
|
|
})
|
|
}
|
|
|
|
// UploadMaterialImage answers POST /materials/{id}/images (D-16): the first
|
|
// multipart part named "file" is stored as a public file of the material
|
|
// through storeMaterialUpload with imageLimits, and the file text job is
|
|
// queued in the same transaction. One image per request. The revision does
|
|
// not change: the body edit that links the image bumps it on save. 201
|
|
// {data: {id, url, name, size, mime}}; a refused file is a 422 with reason
|
|
// type, size or count, an over-limit body a 413.
|
|
func (h *Handlers) UploadMaterialImage(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.caller(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
bucket, ok := h.bucket(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
m, manager, ok := h.editableMaterialForCaller(w, r, db, userID)
|
|
if !ok {
|
|
return
|
|
}
|
|
mr, err := r.MultipartReader()
|
|
if err != nil {
|
|
writeInvalid(w, "Send the image as multipart/form-data in a part named \"file\".")
|
|
return
|
|
}
|
|
errRefused := errors.New("refused")
|
|
outcome := editAllowed
|
|
var stored *attach.File
|
|
var refusal fileRefusal
|
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
|
cur, why, err := h.lockEditableMaterial(tx, userID, m.ID, manager)
|
|
if err != nil || why != editAllowed {
|
|
outcome = why
|
|
return err
|
|
}
|
|
for {
|
|
part, err := mr.NextPart()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if part.FormName() != "file" || part.FileName() == "" {
|
|
part.Close()
|
|
continue
|
|
}
|
|
if stored != nil {
|
|
part.Close()
|
|
refusal = fileRefusal{Reason: "type", Text: "Send one image per request."}
|
|
return errRefused
|
|
}
|
|
f, why, err := storeMaterialUpload(ctx, tx, bucket, cur, part.FileName(), part, imageLimits)
|
|
part.Close()
|
|
if f != nil {
|
|
stored = f
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if why.Reason != "" {
|
|
refusal = why
|
|
return errRefused
|
|
}
|
|
}
|
|
if stored == nil {
|
|
refusal = fileRefusal{Reason: "type", Text: "No image: send one multipart part named \"file\"."}
|
|
return errRefused
|
|
}
|
|
// The text job joins the transaction: it exists only when the file
|
|
// commits, and it syncs the material's document.
|
|
h.MaterialIndex.QueueExtract(ctx, tx, cur.ID)
|
|
return nil
|
|
})
|
|
if err != nil && stored != nil {
|
|
// The blobs written are deleted again: the transaction did not commit.
|
|
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
|
|
}
|
|
var tooLarge *http.MaxBytesError
|
|
switch {
|
|
case errors.Is(err, errRefused):
|
|
writeFileRefusal(w, refusal)
|
|
case errors.As(err, &tooLarge):
|
|
wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": true, "message": "Request body too large.", "reason": "size"})
|
|
case err != nil:
|
|
writeOpaque500(w)
|
|
case outcome == editGone:
|
|
writeNotFound(w)
|
|
case outcome == editForbidden:
|
|
writeForbidden(w)
|
|
default:
|
|
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": fileJSON{
|
|
ID: stored.ID, URL: stored.URL(), Name: stored.FileName, Size: stored.FileSize, Mime: stored.ContentType,
|
|
}})
|
|
}
|
|
}
|