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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
210
backend/internal/service/meta.go
Normal file
210
backend/internal/service/meta.go
Normal file
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user