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 }