Files
evanpage/backend/internal/handler/bookmark.go
root 832512469a 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>
2026-05-02 22:52:43 +00:00

263 lines
6.9 KiB
Go

package handler
import (
"log"
"net/http"
"strconv"
"evanpage-backend/internal/domain"
"evanpage-backend/internal/service"
"github.com/gin-gonic/gin"
)
type BookmarkHandler struct {
bookmarkService *service.BookmarkService
userService *service.UserService
}
func NewBookmarkHandler(bookmarkService *service.BookmarkService, userService *service.UserService) *BookmarkHandler {
return &BookmarkHandler{bookmarkService: bookmarkService, userService: userService}
}
func getUserID(c *gin.Context) uint {
uid, _ := c.Get("userID")
id, _ := strconv.ParseUint(uid.(string), 10, 64)
return uint(id)
}
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
}
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)
}
func (h *BookmarkHandler) List(c *gin.Context) {
userID := getUserID(c)
category := c.Query("category")
var bms []domain.Bookmark
var err error
if category != "" {
bms, err = h.bookmarkService.ListByUserAndCategory(userID, category)
} else {
bms, err = h.bookmarkService.ListByUser(userID)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
categories, _ := h.bookmarkService.ListCategoriesByUser(userID)
c.JSON(http.StatusOK, gin.H{
"bookmarks": bms,
"categories": categories,
})
}
func (h *BookmarkHandler) PublicList(c *gin.Context) {
admin, err := h.userService.FindByRole("admin")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"bookmarks": []domain.Bookmark{},
"categories": []string{},
})
return
}
category := c.Query("category")
var bms []domain.Bookmark
if category != "" {
bms, err = h.bookmarkService.ListByUserAndCategory(admin.ID, category)
} else {
bms, err = h.bookmarkService.ListByUser(admin.ID)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
categories, _ := h.bookmarkService.ListCategoriesByUser(admin.ID)
c.JSON(http.StatusOK, gin.H{
"bookmarks": bms,
"categories": categories,
})
}
func (h *BookmarkHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req bookmarkPayload
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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()})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, bm)
}
func (h *BookmarkHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
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
}
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})
}