backend: expand bookmark API with bulk ops and metadata fetcher
- bulk create/delete/move, reorder, rename-category endpoints - /bookmarks/meta with SSRF-safe fetcher (blocks private/loopback IPs, 8s timeout, 1 MiB body cap) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -24,27 +25,42 @@ func getUserID(c *gin.Context) uint {
|
||||
return uint(id)
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) Create(c *gin.Context) {
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Category string `json:"category"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
type bookmarkPayload struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Category string `json:"category"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
func (p bookmarkPayload) toInput() service.BookmarkInput {
|
||||
return service.BookmarkInput{
|
||||
Title: p.Title,
|
||||
URL: p.URL,
|
||||
Description: p.Description,
|
||||
Icon: p.Icon,
|
||||
Category: p.Category,
|
||||
SortOrder: p.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) Create(c *gin.Context) {
|
||||
var req bookmarkPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Title == "" || req.URL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "title and url are required"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
bm, err := h.bookmarkService.Create(userID, req.Title, req.URL, req.Description, req.Icon, req.Category, req.SortOrder)
|
||||
bm, err := h.bookmarkService.Create(getUserID(c), req.toInput())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, bm)
|
||||
}
|
||||
|
||||
@@ -54,7 +70,6 @@ func (h *BookmarkHandler) List(c *gin.Context) {
|
||||
|
||||
var bms []domain.Bookmark
|
||||
var err error
|
||||
|
||||
if category != "" {
|
||||
bms, err = h.bookmarkService.ListByUserAndCategory(userID, category)
|
||||
} else {
|
||||
@@ -66,7 +81,6 @@ func (h *BookmarkHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
categories, _ := h.bookmarkService.ListCategoriesByUser(userID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"bookmarks": bms,
|
||||
"categories": categories,
|
||||
@@ -96,7 +110,6 @@ func (h *BookmarkHandler) PublicList(c *gin.Context) {
|
||||
}
|
||||
|
||||
categories, _ := h.bookmarkService.ListCategoriesByUser(admin.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"bookmarks": bms,
|
||||
"categories": categories,
|
||||
@@ -104,28 +117,19 @@ func (h *BookmarkHandler) PublicList(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) Update(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Category string `json:"category"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
var req bookmarkPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
bm, err := h.bookmarkService.Update(userID, uint(id), req.Title, req.URL, req.Description, req.Icon, req.Category, req.SortOrder)
|
||||
bm, err := h.bookmarkService.Update(getUserID(c), uint(id), req.toInput())
|
||||
if err != nil {
|
||||
if err.Error() == "forbidden" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
@@ -134,20 +138,17 @@ func (h *BookmarkHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, bm)
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) Delete(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
if err := h.bookmarkService.Delete(userID, uint(id)); err != nil {
|
||||
if err := h.bookmarkService.Delete(getUserID(c), uint(id)); err != nil {
|
||||
if err.Error() == "forbidden" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -155,6 +156,107 @@ func (h *BookmarkHandler) Delete(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) FetchMeta(c *gin.Context) {
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
meta, err := service.FetchURLMeta(c.Request.Context(), req.URL)
|
||||
if err != nil {
|
||||
log.Printf("FetchMeta failed: url=%q err=%v", req.URL, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, meta)
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) BulkCreate(c *gin.Context) {
|
||||
var req struct {
|
||||
Bookmarks []bookmarkPayload `json:"bookmarks" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]service.BookmarkInput, len(req.Bookmarks))
|
||||
for i, p := range req.Bookmarks {
|
||||
items[i] = p.toInput()
|
||||
}
|
||||
created, err := h.bookmarkService.BulkCreate(getUserID(c), items)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"created": created})
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) BulkDelete(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
deleted, err := h.bookmarkService.BulkDelete(getUserID(c), req.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) BulkMove(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := h.bookmarkService.BulkUpdateCategory(getUserID(c), req.IDs, req.Category)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": updated})
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) RenameCategory(c *gin.Context) {
|
||||
var req struct {
|
||||
From string `json:"from" binding:"required"`
|
||||
To string `json:"to" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := h.bookmarkService.RenameCategory(getUserID(c), req.From, req.To)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": updated})
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) Reorder(c *gin.Context) {
|
||||
var req struct {
|
||||
Order []uint `json:"order" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.bookmarkService.Reorder(getUserID(c), req.Order); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user