package api import ( "net/http" "strconv" "time" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/jakub/sm-bm-plugin/models" "gorm.io/gorm" ) // pinnedFirstOrder orders the plain material list for userID: the caller's // own pins first, newest pin first, then the plain list's order (newest // publication first). id breaks every tie, so pages are stable. userID is // the authenticated caller's id, never request input, and is inlined as a // parsed integer like rankOrder. func pinnedFirstOrder(userID uint) string { return "(SELECT p.pinned_at FROM jz_bm_material_pins p WHERE p.material_id = jz_bm_materials.id AND p.user_id = " + strconv.FormatUint(uint64(userID), 10) + ") DESC NULLS LAST, jz_bm_materials.published_at DESC, jz_bm_materials.id DESC" } // pinnedMaterialIDs tells which of ids userID pinned. No ids is an empty // answer without a query. func pinnedMaterialIDs(db *gorm.DB, userID uint, ids []uint) (map[uint]bool, error) { out := map[uint]bool{} if len(ids) == 0 { return out, nil } var pinned []uint err := db.Model(&models.MaterialPin{}). Where("user_id = ? AND material_id IN ?", userID, ids). Pluck("material_id", &pinned).Error if err != nil { return nil, err } for _, id := range pinned { out[id] = true } return out, nil } // pinTarget resolves {id} to a material userID may see and answers 404 // itself otherwise: the same 404 as the detail for an unknown id, a draft, a // future publication or another user's private material, so nothing hints // that a hidden material exists. func pinTarget(w http.ResponseWriter, r *http.Request, db *gorm.DB, now time.Time, userID uint) (uint, bool) { id, ok := surf.IntParam(r, "id") if !ok || id <= 0 { writeNotFound(w) return 0, false } var m models.Material res := visibleMaterials(db.Model(&models.Material{}), now, userID). Where("jz_bm_materials.id = ?", id). Select("jz_bm_materials.id"). Limit(1). Find(&m) if res.Error != nil { writeOpaque500(w) return 0, false } if res.RowsAffected == 0 { writeNotFound(w) return 0, false } return m.ID, true } // PinMaterial answers PUT /materials/{id}/pin: it pins a material the // caller may see, for the caller only. Pinning again keeps the first // pinned_at. A pin is personal and is not content: it writes only // jz_bm_material_pins and leaves the material row as it is, so the // material's version, what search finds and what an open editor sees stay // the same. It answers 204. func (h *Handlers) PinMaterial(w http.ResponseWriter, r *http.Request) { userID, ok := h.caller(w, r) if !ok { return } db, ok := h.db(w, r) if !ok { return } materialID, ok := pinTarget(w, r, db, h.now(), userID) if !ok { return } err := db.Exec(`INSERT INTO jz_bm_material_pins (material_id, user_id, pinned_at) VALUES (?, ?, ?) ON CONFLICT (material_id, user_id) DO NOTHING`, materialID, userID, h.now().UTC()).Error if err != nil { writeOpaque500(w) return } w.WriteHeader(http.StatusNoContent) } // UnpinMaterial answers DELETE /materials/{id}/pin: it removes the caller's // own pin from a material the caller may see. Clearing a material that is // not pinned is not an error. Like PinMaterial it writes only // jz_bm_material_pins and never the material itself. It answers 204. func (h *Handlers) UnpinMaterial(w http.ResponseWriter, r *http.Request) { userID, ok := h.caller(w, r) if !ok { return } db, ok := h.db(w, r) if !ok { return } materialID, ok := pinTarget(w, r, db, h.now(), userID) if !ok { return } if err := db.Where("material_id = ? AND user_id = ?", materialID, userID).Delete(&models.MaterialPin{}).Error; err != nil { writeOpaque500(w) return } w.WriteHeader(http.StatusNoContent) }