- ChatContext implements golem15.chat's MessageModerator: moderator
group members who are chat members delete in group conversations
only, never in DMs
- GET /chat/embeds?type=deadline|class|reading&q= (members only,
at most 20 rows, escaped ILIKE, q at most 100 characters)
- POST /chat/embeds/resolve {refs} (members only, at most 50 distinct
refs, 8 KiB body): current data or null per ref
290 lines
9.1 KiB
Go
290 lines
9.1 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// The entity types a chat message can embed (D-14). Materials and quizzes
|
|
// are deferred and are not accepted.
|
|
const (
|
|
embedTypeDeadline = "deadline"
|
|
embedTypeClass = "class"
|
|
embedTypeReading = "reading"
|
|
)
|
|
|
|
// embedSearchLimit is the number of rows one picker search answers.
|
|
const embedSearchLimit = 20
|
|
|
|
// embedResolveLimit is the number of distinct refs one resolve call takes.
|
|
const embedResolveLimit = 50
|
|
|
|
var embedTypes = []string{embedTypeDeadline, embedTypeClass, embedTypeReading}
|
|
|
|
var chatEmbedsRules = []lagoon.RequestRule{
|
|
{Field: "type", Rules: append(lagoon.ParseRules("required"), lagoon.In(embedTypes...))},
|
|
{Field: "q", Rules: lagoon.ParseRules("nullable|string|max:100")},
|
|
}
|
|
|
|
// embedRef is one entity reference a message stores in its metadata.
|
|
type embedRef struct {
|
|
Type string `json:"type"`
|
|
ID uint `json:"id"`
|
|
}
|
|
|
|
// embedJSON is one embeddable item: its type and id, and the same JSON the
|
|
// rest of the API serves for it in the field named after the type.
|
|
type embedJSON struct {
|
|
Type string `json:"type"`
|
|
ID uint `json:"id"`
|
|
Deadline *deadlineJSON `json:"deadline,omitempty"`
|
|
Session *sessionJSON `json:"session,omitempty"`
|
|
Reading *readingCardJSON `json:"reading,omitempty"`
|
|
}
|
|
|
|
// readingCardJSON is a reading as an embed card shows it: no per-user read
|
|
// or owned state.
|
|
type readingCardJSON struct {
|
|
ID uint `json:"id"`
|
|
Title string `json:"title"`
|
|
Author *string `json:"author"`
|
|
IsRequired bool `json:"is_required"`
|
|
CourseID uint `json:"course_id"`
|
|
Course *courseRef `json:"course"`
|
|
}
|
|
|
|
// resolveEmbedsRequest is the body of POST /chat/embeds/resolve.
|
|
type resolveEmbedsRequest struct {
|
|
Refs []embedRef `json:"refs"`
|
|
}
|
|
|
|
// embedKey is the key of a ref in the resolve answer, "{type}:{id}".
|
|
func embedKey(typ string, id uint) string {
|
|
return typ + ":" + strconv.FormatUint(uint64(id), 10)
|
|
}
|
|
|
|
// escapeLike escapes the ILIKE wildcards % and _ and the escape character
|
|
// itself, so user input matches literally.
|
|
func escapeLike(s string) string {
|
|
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
|
}
|
|
|
|
// parseEmbedRefs checks refs: a supported type, a positive id, at most
|
|
// embedResolveLimit distinct refs. Duplicates collapse and the first-seen
|
|
// order is kept. Any problem returns validation errors and no refs.
|
|
func parseEmbedRefs(refs []embedRef) ([]embedRef, map[string][]string) {
|
|
errs := map[string][]string{}
|
|
seen := map[embedRef]bool{}
|
|
out := make([]embedRef, 0, len(refs))
|
|
for i, r := range refs {
|
|
if !slices.Contains(embedTypes, r.Type) {
|
|
errs[fmt.Sprintf("refs.%d.type", i)] = []string{"The selected type is invalid."}
|
|
continue
|
|
}
|
|
if r.ID == 0 {
|
|
errs[fmt.Sprintf("refs.%d.id", i)] = []string{"The id must be at least 1."}
|
|
continue
|
|
}
|
|
if seen[r] {
|
|
continue
|
|
}
|
|
seen[r] = true
|
|
out = append(out, r)
|
|
}
|
|
if len(out) > embedResolveLimit {
|
|
errs["refs"] = []string{fmt.Sprintf("The refs may not have more than %d items.", embedResolveLimit)}
|
|
}
|
|
if len(errs) > 0 {
|
|
return nil, errs
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// chatMember answers 404 itself and returns false when userID is not in the
|
|
// chat group (500 when the check fails).
|
|
func (h *Handlers) chatMember(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID uint) bool {
|
|
member, err := classes.IsChatMember(r.Context(), db, userID, h.ChatGroup)
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return false
|
|
}
|
|
if !member {
|
|
writeNotFound(w)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ChatEmbeds answers GET /chat/embeds?type=deadline|class|reading&q=, the
|
|
// embed picker search, to chat-group members (404 otherwise). It lists at
|
|
// most embedSearchLimit items: upcoming deadlines soonest first, upcoming
|
|
// class sessions soonest first, readings required first then by title. q
|
|
// (at most 100 characters) narrows by deadline title, course name or
|
|
// reading title and author, case-insensitively.
|
|
func (h *Handlers) ChatEmbeds(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.caller(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
in := query(r, "type", "q")
|
|
if !h.validate(w, r, in, chatEmbedsRules) {
|
|
return
|
|
}
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !h.chatMember(w, r, db, userID) {
|
|
return
|
|
}
|
|
items, err := searchEmbeds(db, str(in, "type"), strings.TrimSpace(str(in, "q")), h.now().UTC())
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
writeData(w, items)
|
|
}
|
|
|
|
func searchEmbeds(db *gorm.DB, typ, q string, now time.Time) ([]embedJSON, error) {
|
|
pattern := "%" + escapeLike(q) + "%"
|
|
out := []embedJSON{}
|
|
switch typ {
|
|
case embedTypeDeadline:
|
|
var rows []models.Deadline
|
|
find := db.Preload("Course").Where("jz_bm_deadlines.due_at >= ?", now)
|
|
if q != "" {
|
|
find = find.Where("jz_bm_deadlines.title ILIKE ?", pattern)
|
|
}
|
|
if err := find.Order("jz_bm_deadlines.due_at, jz_bm_deadlines.id").Limit(embedSearchLimit).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, d := range newDeadlinesJSON(rows) {
|
|
out = append(out, embedJSON{Type: embedTypeDeadline, ID: d.ID, Deadline: &d})
|
|
}
|
|
case embedTypeClass:
|
|
var rows []models.ClassSession
|
|
find := preloadSession(db).Where("jz_bm_class_sessions.ends_at > ?", now)
|
|
if q != "" {
|
|
find = find.Joins("JOIN jz_bm_courses ON jz_bm_courses.id = jz_bm_class_sessions.course_id").
|
|
Where("jz_bm_courses.name ILIKE ?", pattern)
|
|
}
|
|
if err := find.Order("jz_bm_class_sessions.starts_at, jz_bm_class_sessions.id").Limit(embedSearchLimit).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, s := range newSessionsJSON(rows) {
|
|
out = append(out, embedJSON{Type: embedTypeClass, ID: s.ID, Session: &s})
|
|
}
|
|
case embedTypeReading:
|
|
var rows []models.Reading
|
|
find := db.Preload("Course").Where("jz_bm_readings.course_id IS NOT NULL")
|
|
if q != "" {
|
|
find = find.Where("(jz_bm_readings.title ILIKE ? OR jz_bm_readings.author ILIKE ?)", pattern, pattern)
|
|
}
|
|
if err := find.Order("jz_bm_readings.is_required DESC, jz_bm_readings.title, jz_bm_readings.id").Limit(embedSearchLimit).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, rd := range rows {
|
|
card := newReadingCardJSON(rd)
|
|
out = append(out, embedJSON{Type: embedTypeReading, ID: rd.ID, Reading: &card})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func newReadingCardJSON(r models.Reading) readingCardJSON {
|
|
return readingCardJSON{
|
|
ID: r.ID,
|
|
Title: r.Title,
|
|
Author: r.Author,
|
|
IsRequired: r.IsRequired,
|
|
CourseID: idOf(r.CourseID),
|
|
Course: newCourseRef(r.Course),
|
|
}
|
|
}
|
|
|
|
// ResolveChatEmbeds answers POST /chat/embeds/resolve {"refs": [{type, id}]}
|
|
// for chat-group members (404 otherwise): {"data": {"{type}:{id}": item or
|
|
// null}} for every requested ref. Items are read on every call, so a
|
|
// renamed or moved deadline shows its current title and date. A missing
|
|
// row, or a class session without a course, is null: the neutral
|
|
// unavailable card (D-04).
|
|
func (h *Handlers) ResolveChatEmbeds(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.caller(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body resolveEmbedsRequest
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&body); err != nil {
|
|
writeValidation(w, map[string][]string{"refs": {"The request body must be a JSON object with a refs list."}})
|
|
return
|
|
}
|
|
refs, errs := parseEmbedRefs(body.Refs)
|
|
if len(errs) > 0 {
|
|
writeValidation(w, errs)
|
|
return
|
|
}
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !h.chatMember(w, r, db, userID) {
|
|
return
|
|
}
|
|
out, err := resolveEmbeds(db, refs)
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
writeData(w, out)
|
|
}
|
|
|
|
func resolveEmbeds(db *gorm.DB, refs []embedRef) (map[string]*embedJSON, error) {
|
|
out := make(map[string]*embedJSON, len(refs))
|
|
ids := map[string][]uint{}
|
|
for _, ref := range refs {
|
|
out[embedKey(ref.Type, ref.ID)] = nil
|
|
ids[ref.Type] = append(ids[ref.Type], ref.ID)
|
|
}
|
|
if list := ids[embedTypeDeadline]; len(list) > 0 {
|
|
var rows []models.Deadline
|
|
if err := db.Preload("Course").Where("jz_bm_deadlines.id IN ?", list).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, d := range newDeadlinesJSON(rows) {
|
|
out[embedKey(embedTypeDeadline, d.ID)] = &embedJSON{Type: embedTypeDeadline, ID: d.ID, Deadline: &d}
|
|
}
|
|
}
|
|
if list := ids[embedTypeClass]; len(list) > 0 {
|
|
var rows []models.ClassSession
|
|
if err := preloadSession(db).Where("jz_bm_class_sessions.id IN ?", list).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, s := range newSessionsJSON(rows) {
|
|
out[embedKey(embedTypeClass, s.ID)] = &embedJSON{Type: embedTypeClass, ID: s.ID, Session: &s}
|
|
}
|
|
}
|
|
if list := ids[embedTypeReading]; len(list) > 0 {
|
|
var rows []models.Reading
|
|
if err := db.Preload("Course").Where("jz_bm_readings.id IN ? AND jz_bm_readings.course_id IS NOT NULL", list).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, rd := range rows {
|
|
card := newReadingCardJSON(rd)
|
|
out[embedKey(embedTypeReading, rd.ID)] = &embedJSON{Type: embedTypeReading, ID: rd.ID, Reading: &card}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|