diff --git a/classes/chat_context.go b/classes/chat_context.go index 5e43996..3755c2f 100644 --- a/classes/chat_context.go +++ b/classes/chat_context.go @@ -10,8 +10,9 @@ import ( ) var ( - _ chatcontracts.Context = (*ChatContext)(nil) - _ chancontracts.AccessResolver = (*ChatAccess)(nil) + _ chatcontracts.Context = (*ChatContext)(nil) + _ chatcontracts.MessageModerator = (*ChatContext)(nil) + _ chancontracts.AccessResolver = (*ChatAccess)(nil) ) // The BM chat context: one context of type bm with id 1, whose members are @@ -75,7 +76,8 @@ func ChatMemberIDs(ctx context.Context, db *gorm.DB, group string) ([]uint, erro // ChatContext is the golem15.chat context of the BM chat. Membership is the // user group named by group; nobody oversees other people's chats, so a -// moderator reads no DM and edits no foreign message. +// moderator reads no DM and edits no foreign message. Moderators delete +// through CanModerateMessage, in group conversations only. type ChatContext struct { db *gorm.DB group string @@ -113,6 +115,25 @@ func (c *ChatContext) CanOverseeChats(context.Context, uint) (bool, error) { return false, nil } +// CanModerateMessage is golem15.chat's delete-only moderation hook (D-10): +// a member of the moderator group who is also a chat-group member may +// delete other people's messages in group conversations (# ogólny). In a +// DM the answer is always false, so moderators never act on private +// conversations (D-24). It grants no viewing or editing. +func (c *ChatContext) CanModerateMessage(ctx context.Context, userID uint, conversationType string) (bool, error) { + if conversationType != "group" || userID == 0 || c.moderatorGroup == "" { + return false, nil + } + member, err := c.HasMember(ctx, userID) + if err != nil || !member { + return false, err + } + if c.isMember != nil { + return c.isMember(ctx, c.db, userID, c.moderatorGroup) + } + return IsChatModerator(ctx, c.db, userID, c.moderatorGroup) +} + // HasMember reports whether userID is in the chat group. func (c *ChatContext) HasMember(ctx context.Context, userID uint) (bool, error) { if userID == 0 || c.group == "" { diff --git a/controllers/api/chat_embeds.go b/controllers/api/chat_embeds.go new file mode 100644 index 0000000..0699084 --- /dev/null +++ b/controllers/api/chat_embeds.go @@ -0,0 +1,289 @@ +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 +} diff --git a/routes.go b/routes.go index bad3c09..b1ce478 100644 --- a/routes.go +++ b/routes.go @@ -132,6 +132,8 @@ func (p *Plugin) Routes(r pact.Router) error { if p.app != nil && p.app.HasPlugin("golem15.chat") { g.Get("/chat", h.Chat, bucketChat) g.Get("/chat/members", h.ChatMembers, bucketChat) + g.Get("/chat/embeds", h.ChatEmbeds, bucketChat) + g.Post("/chat/embeds/resolve", h.ResolveChatEmbeds, bucketChat, "body.limit:8192") } }) // The management API: what bm:seed does, plus materials,