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