Files
ttstd-infinite-canvas/backend/internal/handler/skills.go

203 lines
5.3 KiB
Go

package handler
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
)
func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
r.GET("/skills/image", proxySkillImage)
r.GET("/skills/capabilities", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
ok(c, gin.H{"capabilities": svc.SkillIntegrationCapabilities()})
})
r.GET("/skills/community", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "12"))
result, err := svc.CommunitySkills(c.Request.Context(), user.ID, service.CommunitySkillsRequest{
Page: page,
PageSize: pageSize,
Sort: c.DefaultQuery("sort", "hot"),
Search: c.Query("search"),
Categories: c.QueryArray("categories"),
})
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
r.GET("/skills/activated", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skills, err := svc.ActivatedSkills(c.Request.Context(), user.ID)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skills": skills})
})
r.GET("/skills/favorites", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skills, err := svc.FavoriteSkills(c.Request.Context(), user.ID)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skills": skills})
})
r.GET("/skills/community/:dir", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.CommunitySkillDetail(c.Request.Context(), user.ID, c.Param("dir"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
})
r.POST("/skills/:dir/activate", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.SetSkillActivated(c.Request.Context(), user.ID, c.Param("dir"), true)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
})
r.DELETE("/skills/:dir/activate", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.SetSkillActivated(c.Request.Context(), user.ID, c.Param("dir"), false)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
})
r.POST("/skills/:dir/favorite", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.SetSkillLiked(c.Request.Context(), user.ID, c.Param("dir"), true)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
})
r.DELETE("/skills/:dir/favorite", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.SetSkillLiked(c.Request.Context(), user.ID, c.Param("dir"), false)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
})
}
func proxySkillImage(c *gin.Context) {
rawURL := strings.TrimSpace(c.Query("url"))
target, err := url.Parse(rawURL)
if err != nil || !allowedSkillImageURL(target) {
fail(c, http.StatusBadRequest, errInvalidSkillImageURL())
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
req.Header.Set("Referer", "https://www.updream.cn/")
req.Header.Set("User-Agent", "Mozilla/5.0 InfiniteCanvas/skills-image-proxy")
client := service.OutboundHTTPClient(15 * time.Second)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 || !allowedSkillImageURL(req.URL) {
return errInvalidSkillImageURL()
}
return nil
}
resp, err := client.Do(req)
if err != nil {
fail(c, http.StatusBadGateway, err)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fail(c, http.StatusBadGateway, errSkillImageUpstream(resp.StatusCode))
return
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
c.Header("Cache-Control", "public, max-age=604800")
if etag := resp.Header.Get("ETag"); etag != "" {
c.Header("ETag", etag)
}
c.Status(http.StatusOK)
c.Header("Content-Type", contentType)
_, _ = io.Copy(c.Writer, resp.Body)
}
func allowedSkillImageURL(target *url.URL) bool {
if target == nil || (target.Scheme != "https" && target.Scheme != "http") {
return false
}
host := strings.ToLower(target.Hostname())
return host == "updream.cn" || strings.HasSuffix(host, ".updream.cn") || host == "hdslb.com" || strings.HasSuffix(host, ".hdslb.com")
}
func errInvalidSkillImageURL() error {
return errors.New("不支持的技能图片地址")
}
func errSkillImageUpstream(status int) error {
return errors.New("技能图片读取失败: " + strconv.Itoa(status))
}