golem15.quizzes now serves its own import at /api/v1/quizzes/import behind the same shared API token.
401 lines
11 KiB
Go
401 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"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/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.
|
|
type managedMaterialJSON struct {
|
|
ID uint `json:"id"`
|
|
CourseID *uint `json:"course_id"`
|
|
Course *courseRef `json:"course"`
|
|
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"`
|
|
}
|
|
|
|
func newManagedMaterialJSON(m models.Material) managedMaterialJSON {
|
|
out := managedMaterialJSON{
|
|
ID: m.ID, CourseID: m.CourseID, Course: newCourseRef(m.Course), Title: m.Title, Type: m.Type,
|
|
Body: m.Body, Source: m.Source, URL: m.URL, Tags: newTagsJSON(m.Tags),
|
|
}
|
|
if m.PublishedAt != nil {
|
|
s := models.FormatDateTime(*m.PublishedAt)
|
|
out.PublishedAt = &s
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ManagedCourses answers GET /courses: every course, for the course_id of
|
|
// a material.
|
|
func (h *Handlers) ManagedCourses(w http.ResponseWriter, r *http.Request) {
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var rows []models.Course
|
|
if err := db.Order("semester, sort_order, name, id").Find(&rows).Error; err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
out := make([]courseJSON, 0, len(rows))
|
|
for _, c := range rows {
|
|
out = append(out, newCourseJSON(c))
|
|
}
|
|
writeData(w, out)
|
|
}
|
|
|
|
// ManagedMaterials answers GET /materials: every material, drafts
|
|
// included, newest first.
|
|
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
|
|
}
|
|
out := make([]managedMaterialJSON, 0, len(rows))
|
|
for _, m := range rows {
|
|
out = append(out, newManagedMaterialJSON(m))
|
|
}
|
|
writeData(w, out)
|
|
}
|
|
|
|
// materialInput is the body of POST /materials.
|
|
type materialInput struct {
|
|
CourseID *uint `json:"course_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"`
|
|
}
|
|
|
|
// 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.
|
|
// published_at: absent publishes a new material now and keeps an existing
|
|
// one as it is; null makes a draft; otherwise a timestamp as in the seed
|
|
// file. tags: absent keeps them; otherwise the list of tag names replaces
|
|
// them, and unknown tags are created.
|
|
func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
|
|
raw, ok := body(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var in materialInput
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&in); err != nil {
|
|
writeInvalid(w, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
var keys map[string]json.RawMessage
|
|
_ = json.Unmarshal(raw, &keys)
|
|
_, hasPublishedAt := keys["published_at"]
|
|
|
|
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."}
|
|
}
|
|
var publishedAt *time.Time
|
|
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."}
|
|
}
|
|
publishedAt = &t
|
|
}
|
|
var tags []models.Tag
|
|
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(tags, func(t models.Tag) bool { return t.Slug == slug }) {
|
|
tags = append(tags, models.Tag{Name: name, Slug: slug})
|
|
}
|
|
}
|
|
}
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
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
|
|
}
|
|
if n == 0 {
|
|
errs["course_id"] = []string{"The selected course_id is invalid."}
|
|
}
|
|
}
|
|
if len(errs) > 0 {
|
|
writeValidation(w, errs)
|
|
return
|
|
}
|
|
|
|
var m models.Material
|
|
created := false
|
|
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
|
|
q := tx.Where("title = ?", in.Title)
|
|
if in.CourseID != nil {
|
|
q = q.Where("course_id = ?", *in.CourseID)
|
|
} else {
|
|
q = q.Where("course_id IS NULL")
|
|
}
|
|
res := q.Order("id").Limit(1).Find(&m)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
created = res.RowsAffected == 0
|
|
m.CourseID, m.Title, m.Type = in.CourseID, in.Title, in.Type
|
|
m.Body, m.Source, m.URL = in.Body, in.Source, in.URL
|
|
switch {
|
|
case hasPublishedAt:
|
|
m.PublishedAt = publishedAt
|
|
case created:
|
|
now := h.now()
|
|
m.PublishedAt = &now
|
|
}
|
|
if err := tx.Omit("Course", "Tags").Save(&m).Error; 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 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
|
|
})
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
var out models.Material
|
|
if err := preloadMaterial(db).First(&out, m.ID).Error; err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
status := http.StatusOK
|
|
if created {
|
|
status = http.StatusCreated
|
|
}
|
|
wire.WriteJSON(w, status, map[string]any{"data": newManagedMaterialJSON(out), "created": created})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
res := db.Where("id = ?", id).Delete(&models.Material{})
|
|
if res.Error != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
writeNotFound(w)
|
|
return
|
|
}
|
|
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()
|
|
}
|