package handler import ( "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/service" "github.com/gin-gonic/gin" ) func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { r.GET("/settings/prompt-templates", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } preferences, err := svc.UserPromptPreferences(user) if err != nil { failService(c, err) return } ok(c, gin.H{"preferences": preferences}) }) r.PATCH("/settings/prompt-templates/:operation", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) var req service.UserPromptCustomizationRequest if err := c.ShouldBindJSON(&req); err != nil { fail(c, http.StatusBadRequest, err) return } customization, err := svc.UpdateUserPromptCustomization(user, c.Param("operation"), req) if err != nil { failService(c, err) return } ok(c, gin.H{"customization": customization}) }) r.DELETE("/settings/prompt-templates/:operation", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } if err := svc.ResetUserPromptCustomization(user, c.Param("operation")); err != nil { failService(c, err) return } ok(c, gin.H{"ok": true}) }) r.GET("/settings/oss", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } setting, err := svc.UserOSSSetting(user) if err != nil { failService(c, err) return } ok(c, gin.H{"setting": setting}) }) r.PATCH("/settings/oss", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) var req service.OSSSettingRequest if err := c.ShouldBindJSON(&req); err != nil { fail(c, http.StatusBadRequest, err) return } setting, err := svc.UpdateUserOSSSetting(user, req) if err != nil { failService(c, err) return } ok(c, gin.H{"setting": setting}) }) r.GET("/resources", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200")) resources, err := svc.Resources(user.ID, limit) if err != nil { failService(c, err) return } ok(c, gin.H{"resources": resources}) }) r.GET("/resources/storage-usage", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } usage, err := svc.AccountFileStorageUsage(user.ID) if err != nil { failService(c, err) return } ok(c, gin.H{"usage": usage}) }) r.POST("/resources", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } policy, available := loadRuntimePolicy(c, svc) if !available || !enforceRateLimit(c, "resources-upload:"+user.ID, policy.Request.ResourceUploadPerMinute, time.Minute) { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, (policy.Resource.ResourceUploadMB<<20)+(1<<20)) file, err := c.FormFile("file") if err != nil { fail(c, http.StatusBadRequest, err) return } width, _ := strconv.Atoi(c.PostForm("width")) height, _ := strconv.Atoi(c.PostForm("height")) durationMs, _ := strconv.ParseInt(c.PostForm("durationMs"), 10, 64) resource, err := svc.UploadResource(user.ID, file, c.PostForm("kind"), width, height, durationMs) if err != nil { failService(c, err) return } ok(c, gin.H{"resource": resource}) }) r.POST("/resources/import", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } policy, available := loadRuntimePolicy(c, svc) if !available || !enforceRateLimit(c, "resources-import:"+user.ID, policy.Request.ResourceImportPerMinute, time.Minute) { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) var req struct { URL string `json:"url"` Kind string `json:"kind"` Width int `json:"width"` Height int `json:"height"` DurationMs int64 `json:"durationMs"` } if err := c.ShouldBindJSON(&req); err != nil { fail(c, http.StatusBadRequest, err) return } resource, err := svc.ImportResourceURL(user.ID, req.URL, req.Kind, req.Width, req.Height, req.DurationMs) if err != nil { failService(c, err) return } ok(c, gin.H{"resource": resource}) }) r.GET("/resources/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } resource, err := svc.Resource(user.ID, c.Param("id")) if err != nil { fail(c, http.StatusNotFound, err) return } ok(c, gin.H{"resource": resource}) }) r.GET("/resources/:id/oss-url", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } resource, err := svc.Resource(user.ID, c.Param("id")) if err != nil { fail(c, http.StatusNotFound, err) return } ossURL, err := svc.DirectResourceURL(user.ID, resource.ID) if err != nil { failService(c, err) return } // 签名地址只用于当前复制动作,禁止浏览器或中间代理缓存。 c.Header("Cache-Control", "private, no-store") c.Header("Referrer-Policy", "no-referrer") ok(c, gin.H{"url": ossURL}) }) r.GET("/resources/:id/file", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } delivery, err := svc.PrepareResourceDelivery(user.ID, c.Param("id"), service.ResourceDeliveryOptions{ ForceDirect: c.Query("direct") == "1", ForceProxy: c.Query("proxy") == "1", }) if err != nil { failService(c, err) return } if delivery.RedirectURL != "" { // CDN 或对象存储直连地址不进入应用缓存,也不作为后续请求的 Referer 泄露。 c.Header("Cache-Control", "private, no-store") c.Header("Referrer-Policy", "no-referrer") c.Header("X-Content-Type-Options", "nosniff") c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL) return } resource := delivery.Resource etag := resourceResponseETag(resource) // 私有资源允许浏览器保存响应,但每次复用前必须重新鉴权;304 会在读取 OSS 前返回。 c.Header("Cache-Control", "private, no-cache") c.Header("ETag", etag) c.Header("Accept-Ranges", "bytes") c.Header("X-Content-Type-Options", "nosniff") if resource.Kind == "file" { c.Header("Content-Disposition", "attachment") c.Header("Content-Security-Policy", "sandbox") } if ifNoneMatch(c.GetHeader("If-None-Match"), etag) { c.Status(http.StatusNotModified) return } rangeHeader := c.GetHeader("Range") if ifRange := strings.TrimSpace(c.GetHeader("If-Range")); ifRange != "" && ifRange != etag { rangeHeader = "" } stream, err := svc.OpenResourceRange(user.ID, resource.ID, rangeHeader) if err != nil { failService(c, err) return } defer stream.Body.Close() if resource.MimeType == "" { resource.MimeType = "application/octet-stream" } if resource.Provider == "local" { if seeker, ok := stream.Body.(io.ReadSeeker); ok { c.Header("Content-Type", resource.MimeType) http.ServeContent(c.Writer, c.Request, resource.ID, resource.UpdatedAt, seeker) return } } if stream.ContentRange != "" { c.Header("Content-Range", stream.ContentRange) } if stream.AcceptRanges != "" { c.Header("Accept-Ranges", stream.AcceptRanges) } c.DataFromReader(stream.StatusCode, stream.ContentLength, resource.MimeType, stream.Body, nil) }) r.GET("/public/resources/:id/file", func(c *gin.Context) { stream, err := svc.OpenPublicResourceRange(c.Param("id"), c.Query("expires"), c.Query("signature"), c.GetHeader("Range")) if err != nil { failService(c, err) return } defer stream.Body.Close() resource := stream.Resource if resource.MimeType == "" { resource.MimeType = "application/octet-stream" } c.Header("Cache-Control", "public, max-age=0, must-revalidate") c.Header("Accept-Ranges", "bytes") c.Header("Referrer-Policy", "no-referrer") c.Header("X-Content-Type-Options", "nosniff") if stream.ContentRange != "" { c.Header("Content-Range", stream.ContentRange) } if seeker, ok := stream.Body.(io.ReadSeeker); ok { c.Header("Content-Type", resource.MimeType) http.ServeContent(c.Writer, c.Request, resource.ID, resource.UpdatedAt, seeker) return } c.DataFromReader(stream.StatusCode, stream.ContentLength, resource.MimeType, stream.Body, nil) }) r.GET("/assets", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } assets, err := svc.UserAssetSummaries(user.ID) if err != nil { failService(c, err) return } ok(c, gin.H{"assets": assets}) }) r.GET("/user-data/snapshot", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } snapshot, err := svc.UserDataSnapshot(user.ID) if err != nil { failService(c, err) return } ok(c, snapshot) }) r.GET("/assets/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } asset, err := svc.UserAsset(user.ID, c.Param("id")) if err != nil { fail(c, http.StatusNotFound, err) return } ok(c, gin.H{"asset": asset}) }) r.PUT("/assets/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } policy, available := loadRuntimePolicy(c, svc) if !available || !enforceRateLimit(c, "assets-write:"+user.ID, policy.Request.AssetWritePerMinute, time.Minute) { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20) var req struct { Asset json.RawMessage `json:"asset"` } if err := c.ShouldBindJSON(&req); err != nil { fail(c, http.StatusBadRequest, err) return } var identity struct { ID string `json:"id"` } if json.Unmarshal(req.Asset, &identity) != nil || identity.ID != c.Param("id") { fail(c, http.StatusBadRequest, service.BadAuthRequest("素材 ID 与请求路径不一致")) return } asset, err := svc.UpsertUserAsset(user.ID, req.Asset) if err != nil { failService(c, err) return } ok(c, gin.H{"asset": asset}) }) r.DELETE("/assets/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } if err := svc.DeleteUserAsset(user.ID, c.Param("id")); err != nil { failService(c, err) return } ok(c, gin.H{"id": c.Param("id")}) }) r.GET("/canvas-projects", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } projects, err := svc.UserCanvasProjectSummaries(user.ID) if err != nil { failService(c, err) return } ok(c, gin.H{"projects": projects}) }) r.GET("/canvas-projects/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } project, err := svc.UserCanvasProject(user.ID, c.Param("id")) if err != nil { fail(c, http.StatusNotFound, err) return } ok(c, gin.H{"project": project}) }) r.PUT("/canvas-projects/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } policy, available := loadRuntimePolicy(c, svc) if !available || !enforceRateLimit(c, "canvas-write:"+user.ID, policy.Request.CanvasWritePerMinute, time.Minute) { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20) var req struct { Project json.RawMessage `json:"project"` } if err := c.ShouldBindJSON(&req); err != nil { fail(c, http.StatusBadRequest, err) return } var identity struct { ID string `json:"id"` } if json.Unmarshal(req.Project, &identity) != nil || identity.ID != c.Param("id") { fail(c, http.StatusBadRequest, service.BadAuthRequest("画布 ID 与请求路径不一致")) return } project, err := svc.UpsertUserCanvasProject(user.ID, req.Project) if err != nil { failService(c, err) return } ok(c, gin.H{"project": project}) }) r.DELETE("/canvas-projects/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } if err := svc.DeleteUserCanvasProject(user.ID, c.Param("id")); err != nil { failService(c, err) return } ok(c, gin.H{"id": c.Param("id")}) }) } func resourceResponseETag(resource *model.Resource) string { value := strings.Trim(strings.TrimSpace(resource.ETag), `"`) if value == "" { value = fmt.Sprintf("%s-%d-%d", resource.ID, resource.Size, resource.UpdatedAt.UnixNano()) } return strconv.Quote(value) } func ifNoneMatch(header string, etag string) bool { for _, candidate := range strings.Split(header, ",") { candidate = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(candidate), "W/")) if candidate == "*" || candidate == etag { return true } } return false }