diff --git a/backend/internal/handler/bookmark.go b/backend/internal/handler/bookmark.go index cc589f4..e84ec92 100644 --- a/backend/internal/handler/bookmark.go +++ b/backend/internal/handler/bookmark.go @@ -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}) +} diff --git a/backend/internal/repository/bookmark.go b/backend/internal/repository/bookmark.go index 0483852..586fc45 100644 --- a/backend/internal/repository/bookmark.go +++ b/backend/internal/repository/bookmark.go @@ -18,6 +18,13 @@ func (r *BookmarkRepository) Create(bm *domain.Bookmark) error { return r.db.Create(bm).Error } +func (r *BookmarkRepository) BulkCreate(bms []*domain.Bookmark) error { + if len(bms) == 0 { + return nil + } + return r.db.Create(&bms).Error +} + func (r *BookmarkRepository) FindByID(id uint) (*domain.Bookmark, error) { var bm domain.Bookmark err := r.db.First(&bm, id).Error @@ -49,3 +56,40 @@ func (r *BookmarkRepository) Update(bm *domain.Bookmark) error { func (r *BookmarkRepository) Delete(id uint) error { return r.db.Delete(&domain.Bookmark{}, id).Error } + +func (r *BookmarkRepository) BulkDeleteByUser(userID uint, ids []uint) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + res := r.db.Where("user_id = ? AND id IN ?", userID, ids).Delete(&domain.Bookmark{}) + return res.RowsAffected, res.Error +} + +func (r *BookmarkRepository) BulkUpdateCategoryByUser(userID uint, ids []uint, category string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + res := r.db.Model(&domain.Bookmark{}).Where("user_id = ? AND id IN ?", userID, ids).Update("category", category) + return res.RowsAffected, res.Error +} + +func (r *BookmarkRepository) RenameCategoryByUser(userID uint, from, to string) (int64, error) { + res := r.db.Model(&domain.Bookmark{}).Where("user_id = ? AND category = ?", userID, from).Update("category", to) + return res.RowsAffected, res.Error +} + +func (r *BookmarkRepository) ReorderByUser(userID uint, orderedIDs []uint) error { + if len(orderedIDs) == 0 { + return nil + } + return r.db.Transaction(func(tx *gorm.DB) error { + for i, id := range orderedIDs { + if err := tx.Model(&domain.Bookmark{}). + Where("id = ? AND user_id = ?", id, userID). + Update("sort_order", i).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index bead4eb..d0288fd 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -41,6 +41,12 @@ func Setup(cfg *config.Config) *gin.Engine { { auth.GET("/bookmarks", bookmarkHandler.List) auth.POST("/bookmarks", bookmarkHandler.Create) + auth.POST("/bookmarks/meta", bookmarkHandler.FetchMeta) + auth.POST("/bookmarks/bulk", bookmarkHandler.BulkCreate) + auth.POST("/bookmarks/bulk-delete", bookmarkHandler.BulkDelete) + auth.POST("/bookmarks/bulk-move", bookmarkHandler.BulkMove) + auth.POST("/bookmarks/reorder", bookmarkHandler.Reorder) + auth.POST("/bookmarks/rename-category", bookmarkHandler.RenameCategory) auth.PUT("/bookmarks/:id", bookmarkHandler.Update) auth.DELETE("/bookmarks/:id", bookmarkHandler.Delete) } diff --git a/backend/internal/service/bookmark.go b/backend/internal/service/bookmark.go index 5b1c22c..48d3f4b 100644 --- a/backend/internal/service/bookmark.go +++ b/backend/internal/service/bookmark.go @@ -11,25 +11,34 @@ type BookmarkService struct { repo *repository.BookmarkRepository } +type BookmarkInput struct { + Title string + URL string + Description string + Icon string + Category string + SortOrder int +} + func NewBookmarkService(repo *repository.BookmarkRepository) *BookmarkService { return &BookmarkService{repo: repo} } -func (s *BookmarkService) Create(userID uint, title, url, description, icon, category string, sortOrder int) (*domain.Bookmark, error) { - if title == "" || url == "" { +func (s *BookmarkService) Create(userID uint, in BookmarkInput) (*domain.Bookmark, error) { + if in.Title == "" || in.URL == "" { return nil, errors.New("title and url are required") } - if category == "" { - category = "默认" + if in.Category == "" { + in.Category = "默认" } bm := &domain.Bookmark{ UserID: userID, - Title: title, - URL: url, - Description: description, - Icon: icon, - Category: category, - SortOrder: sortOrder, + Title: in.Title, + URL: in.URL, + Description: in.Description, + Icon: in.Icon, + Category: in.Category, + SortOrder: in.SortOrder, } if err := s.repo.Create(bm); err != nil { return nil, err @@ -37,6 +46,32 @@ func (s *BookmarkService) Create(userID uint, title, url, description, icon, cat return bm, nil } +func (s *BookmarkService) BulkCreate(userID uint, items []BookmarkInput) (int, error) { + bms := make([]*domain.Bookmark, 0, len(items)) + for _, in := range items { + if in.Title == "" || in.URL == "" { + continue + } + cat := in.Category + if cat == "" { + cat = "默认" + } + bms = append(bms, &domain.Bookmark{ + UserID: userID, + Title: in.Title, + URL: in.URL, + Description: in.Description, + Icon: in.Icon, + Category: cat, + SortOrder: in.SortOrder, + }) + } + if err := s.repo.BulkCreate(bms); err != nil { + return 0, err + } + return len(bms), nil +} + func (s *BookmarkService) ListByUser(userID uint) ([]domain.Bookmark, error) { return s.repo.ListByUser(userID) } @@ -49,7 +84,7 @@ func (s *BookmarkService) ListCategoriesByUser(userID uint) ([]string, error) { return s.repo.ListCategoriesByUser(userID) } -func (s *BookmarkService) Update(userID, bookmarkID uint, title, url, description, icon, category string, sortOrder int) (*domain.Bookmark, error) { +func (s *BookmarkService) Update(userID, bookmarkID uint, in BookmarkInput) (*domain.Bookmark, error) { bm, err := s.repo.FindByID(bookmarkID) if err != nil { return nil, errors.New("bookmark not found") @@ -57,18 +92,18 @@ func (s *BookmarkService) Update(userID, bookmarkID uint, title, url, descriptio if bm.UserID != userID { return nil, errors.New("forbidden") } - if title != "" { - bm.Title = title + if in.Title != "" { + bm.Title = in.Title } - if url != "" { - bm.URL = url + if in.URL != "" { + bm.URL = in.URL } - bm.Description = description - bm.Icon = icon - if category != "" { - bm.Category = category + bm.Description = in.Description + bm.Icon = in.Icon + if in.Category != "" { + bm.Category = in.Category } - bm.SortOrder = sortOrder + bm.SortOrder = in.SortOrder if err := s.repo.Update(bm); err != nil { return nil, err } @@ -85,3 +120,28 @@ func (s *BookmarkService) Delete(userID, bookmarkID uint) error { } return s.repo.Delete(bookmarkID) } + +func (s *BookmarkService) BulkDelete(userID uint, ids []uint) (int64, error) { + return s.repo.BulkDeleteByUser(userID, ids) +} + +func (s *BookmarkService) BulkUpdateCategory(userID uint, ids []uint, category string) (int64, error) { + if category == "" { + category = "默认" + } + return s.repo.BulkUpdateCategoryByUser(userID, ids, category) +} + +func (s *BookmarkService) RenameCategory(userID uint, from, to string) (int64, error) { + if from == "" || to == "" { + return 0, errors.New("from and to are required") + } + if from == to { + return 0, nil + } + return s.repo.RenameCategoryByUser(userID, from, to) +} + +func (s *BookmarkService) Reorder(userID uint, orderedIDs []uint) error { + return s.repo.ReorderByUser(userID, orderedIDs) +} diff --git a/backend/internal/service/meta.go b/backend/internal/service/meta.go new file mode 100644 index 0000000..f49755d --- /dev/null +++ b/backend/internal/service/meta.go @@ -0,0 +1,210 @@ +package service + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/url" + "strings" + "syscall" + "time" + + "golang.org/x/net/html" +) + +type FetchedMeta struct { + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` +} + +const ( + metaTimeout = 8 * time.Second + metaMaxBodyBytes = 1 << 20 // 1 MiB +) + +var safeHTTPClient = &http.Client{ + Timeout: metaTimeout, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + Control: restrictAddress, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many redirects") + } + return nil + }, +} + +func FetchURLMeta(ctx context.Context, raw string) (*FetchedMeta, error) { + u, err := normalizeURL(raw) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; EvanPageBot/1.0)") + req.Header.Set("Accept", "text/html,application/xhtml+xml") + + resp, err := safeHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, errors.New("upstream returned " + resp.Status) + } + + doc, err := html.Parse(io.LimitReader(resp.Body, metaMaxBodyBytes)) + if err != nil { + return nil, err + } + + meta := extractMeta(doc) + meta.Icon = resolveIcon(u, meta.Icon) + return meta, nil +} + +func normalizeURL(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("url is required") + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil { + return nil, errors.New("invalid url") + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, errors.New("only http(s) urls are allowed") + } + if u.Host == "" { + return nil, errors.New("invalid url") + } + return u, nil +} + +func restrictAddress(network, address string, _ syscall.RawConn) error { + if network != "tcp" && network != "tcp4" && network != "tcp6" { + return errors.New("disallowed network") + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip := net.ParseIP(host) + if ip == nil { + return errors.New("address is not an ip") + } + if isBlockedIP(ip) { + return errors.New("blocked ip range") + } + return nil +} + +func isBlockedIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || + ip.IsMulticast() || ip.IsUnspecified() { + return true + } + // Block 100.64.0.0/10 (CGNAT) and 169.254.0.0/16 (link-local) explicitly + cgnat := net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)} + if v4 := ip.To4(); v4 != nil && cgnat.Contains(v4) { + return true + } + return false +} + +func extractMeta(n *html.Node) *FetchedMeta { + m := &FetchedMeta{} + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.ElementNode { + switch strings.ToLower(node.Data) { + case "title": + if m.Title == "" && node.FirstChild != nil { + m.Title = strings.TrimSpace(textOf(node)) + } + case "meta": + name := strings.ToLower(attr(node, "name")) + prop := strings.ToLower(attr(node, "property")) + content := attr(node, "content") + switch { + case name == "description" && m.Description == "": + m.Description = strings.TrimSpace(content) + case prop == "og:description" && m.Description == "": + m.Description = strings.TrimSpace(content) + case prop == "og:title" && m.Title == "": + m.Title = strings.TrimSpace(content) + } + case "link": + rel := strings.ToLower(attr(node, "rel")) + href := attr(node, "href") + if href == "" { + return + } + if strings.Contains(rel, "icon") && m.Icon == "" { + m.Icon = href + } + } + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return m +} + +func attr(n *html.Node, key string) string { + for _, a := range n.Attr { + if strings.EqualFold(a.Key, key) { + return a.Val + } + } + return "" +} + +func textOf(n *html.Node) string { + var b strings.Builder + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.TextNode { + b.WriteString(c.Data) + } else { + b.WriteString(textOf(c)) + } + } + return b.String() +} + +func resolveIcon(base *url.URL, icon string) string { + if icon == "" { + return base.Scheme + "://" + base.Host + "/favicon.ico" + } + ref, err := url.Parse(icon) + if err != nil { + return "" + } + if ref.IsAbs() { + return ref.String() + } + return base.ResolveReference(ref).String() +} diff --git a/frontend/app/bookmarks/bookmark-manager.tsx b/frontend/app/(main)/dashboard/bookmarks/bookmark-manager.tsx similarity index 100% rename from frontend/app/bookmarks/bookmark-manager.tsx rename to frontend/app/(main)/dashboard/bookmarks/bookmark-manager.tsx