package api import ( "net/http" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/modules/beachcomber" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/jakub/sm-bm-plugin/classes" "git.golem15.com/jakub/sm-bm-plugin/models" "gorm.io/gorm" ) // published scopes a material query to what students may see: a // publication time that has passed. A NULL published_at is a draft. func published(q *gorm.DB, now time.Time) *gorm.DB { return q.Where("jz_bm_materials.published_at IS NOT NULL AND jz_bm_materials.published_at <= ?", now.UTC()) } // materialFilter holds the validated filters of /materials. type materialFilter struct { CourseID uint MeetingID uint Tag string Type string } func (f materialFilter) apply(q *gorm.DB) *gorm.DB { if f.CourseID != 0 { q = q.Where("jz_bm_materials.course_id = ?", f.CourseID) } if f.MeetingID != 0 { q = q.Where("jz_bm_materials.meeting_id = ?", f.MeetingID) } if f.Type != "" { q = q.Where("jz_bm_materials.type = ?", f.Type) } if f.Tag != "" { q = q.Where(`EXISTS (SELECT 1 FROM jz_bm_material_tag mt JOIN jz_bm_tags t ON t.id = mt.tag_id WHERE mt.material_id = jz_bm_materials.id AND t.slug = ?)`, f.Tag) } return q } func preloadMaterial(q *gorm.DB) *gorm.DB { return q.Preload("Course").Preload("Tags", func(db *gorm.DB) *gorm.DB { return db.Order("jz_bm_tags.name") }) } // listMaterials returns one page of the published materials userID may // see. With ids nil the page is newest first. With ids non-nil (search // candidates in the engine's rank order) both the count and the page are // also limited to ids and the page follows their order; the ids never widen // what visibleMaterials and the filter allow. An empty ids is an empty page. func listMaterials(db *gorm.DB, now time.Time, userID uint, f materialFilter, ids []uint, page, perPage int) ([]materialListJSON, []models.Material, int64, error) { if ids != nil && len(ids) == 0 { return []materialListJSON{}, nil, 0, nil } q := f.apply(visibleMaterials(db.Model(&models.Material{}), now, userID)) if ids != nil { q = q.Where("jz_bm_materials.id IN ?", ids) } var total int64 if err := q.Count(&total).Error; err != nil { return nil, nil, 0, err } rowsQ := preloadMaterial(f.apply(visibleMaterials(db, now, userID))) if ids != nil { rowsQ = rowsQ.Where("jz_bm_materials.id IN ?", ids).Order(rankOrder(ids)) } else { rowsQ = rowsQ.Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC") } var rows []models.Material err := rowsQ.Offset((page - 1) * perPage).Limit(perPage).Find(&rows).Error if err != nil { return nil, nil, 0, err } out, err := materialsJSON(db, rows) return out, rows, total, err } // materialsJSON builds list rows with their file counts. func materialsJSON(db *gorm.DB, rows []models.Material) ([]materialListJSON, error) { ids := make([]string, 0, len(rows)) for _, m := range rows { ids = append(ids, strconv.FormatUint(uint64(m.ID), 10)) } counts := map[string]int64{} if len(ids) > 0 { var found []struct { AttachmentID string Count int64 } err := db.Model(&attach.File{}). Select("attachment_id, COUNT(*) AS count"). Where("attachment_type = ? AND field = ? AND is_public AND attachment_id IN ?", models.Material{}.MorphName(), models.MaterialFilesField, ids). Group("attachment_id"). Scan(&found).Error if err != nil { return nil, err } for _, row := range found { counts[row.AttachmentID] = row.Count } } out := make([]materialListJSON, 0, len(rows)) for _, m := range rows { out = append(out, newMaterialListJSON(m, counts[strconv.FormatUint(uint64(m.ID), 10)])) } return out, nil } // Materials answers GET /materials?q=&course=&meeting=&tag=&type=&page=&per_page=: // a page of the published materials the caller may see, in the {data, meta} // shape of lagoon.Paginate. With q (2 to 100 characters) the engine answers // candidates, which SQL re-gates, counts and pages in rank order, each row // with at most one snippet; when the engine cannot answer the request is a // 503. Without q the list is newest first and never calls the engine. func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) { userID, ok := h.caller(w, r) if !ok { return } in := query(r, "q", "course", "meeting", "tag", "type", "page", "per_page") if !h.validate(w, r, in, materialsRules) { return } filter := materialFilter{CourseID: uintParam(in, "course"), MeetingID: uintParam(in, "meeting"), Tag: strings.TrimSpace(str(in, "tag")), Type: str(in, "type")} var cands *classes.MaterialCandidates if q := searchQuery(in); q != "" { c, err := h.MaterialIndex.Search(r.Context(), classes.MaterialSearch{Q: q, CourseID: filter.CourseID, MeetingID: filter.MeetingID, Type: filter.Type, Tag: filter.Tag}) if err != nil { writeSearchUnavailable(w) return } cands = &c } db, ok := h.db(w, r) if !ok { return } page := min(intParam(in, "page", 1), 100000) perPage := intParam(in, "per_page", h.perPage()) var ids []uint if cands != nil { ids = cands.IDs if ids == nil { ids = []uint{} } } out, rows, total, err := listMaterials(db, h.now(), userID, filter, ids, page, perPage) if err != nil { writeOpaque500(w) return } if cands != nil { attachSnippets(out, rows, cands.Hits, viaMCP(r)) } writeJSONPage(w, lagoon.Paginate(out, page, perPage, total)) } // attachSnippets sets the snippet of each list row from its Hit. rows are // the rows SQL returned (they passed the re-gate), in the order of out; the // Hit of an id SQL did not return is never read. func attachSnippets(out []materialListJSON, rows []models.Material, hits map[uint]beachcomber.Hit, mcp bool) { for i := range out { if i >= len(rows) || rows[i].ID != out[i].ID { continue } hit, ok := hits[rows[i].ID] if !ok { continue } out[i].Snippet = snippetValue(pickSnippet(hit, rows[i]), mcp) } } // Material answers GET /materials/{id}: one published material the caller // may see, with its body, tags and files. A draft, a future publication or // another user's private material is a 404. can_manage tells the author (a // material manager) that the access panel applies. An MCP call gets the body // only with include_body (see materialView). func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) { userID, ok := h.caller(w, r) if !ok { return } db, ok := h.db(w, r) if !ok { return } id, ok := surf.IntParam(r, "id") if !ok { writeNotFound(w) return } var m models.Material res := preloadMaterial(visibleMaterials(db, h.now(), userID)).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m) if res.Error != nil { writeOpaque500(w) return } if res.RowsAffected == 0 { writeNotFound(w) return } var files []attach.File err := db.Where("attachment_type = ? AND attachment_id = ? AND field = ?", m.MorphName(), strconv.FormatUint(uint64(m.ID), 10), models.MaterialFilesField). Order("sort_order, id"). Find(&files).Error if err != nil { writeOpaque500(w) return } out := materialDetailJSON{ materialListJSON: newMaterialListJSON(m, int64(len(files))), Body: m.Body, Files: make([]fileJSON, 0, len(files)), } for i := range files { f := &files[i] if !f.Public() { continue } out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType}) } out.FilesCount = int64(len(out.Files)) if m.AuthorUserID == nil || *m.AuthorUserID == userID { manager, err := h.managesMaterials(r.Context(), db, userID) if err != nil { writeOpaque500(w) return } out.CanManage = canManageMaterial(m, userID, manager) } writeData(w, materialView(out, viaMCP(r), includeBody(r))) } // publishedMaterialsOfCourse lists a course's published materials that // userID may see, newest first, for /courses/{id}. func publishedMaterialsOfCourse(db *gorm.DB, now time.Time, userID uint, courseID uint) ([]materialListJSON, error) { var rows []models.Material err := preloadMaterial(visibleMaterials(db, now, userID)). Where("jz_bm_materials.course_id = ?", courseID). Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC"). Find(&rows).Error if err != nil { return nil, err } return materialsJSON(db, rows) }