package app import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" _ "image/gif" _ "image/jpeg" _ "image/png" "net/url" "os" "path" "strconv" "strings" "time" "infinite-canvas/backend/internal/assets" "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" ) var errInvalidGeneratedDataURL = errors.New("生成内容 data URL 无效") type ResourceStream = assets.ResourceStream type ResourceDelivery = assets.ResourceDelivery func (s *Service) Resources(userID string, limit int) ([]model.Resource, error) { resources, err := s.repo.Resources(userID, limit) for index := range resources { resources[index].PublicURL = "" } return resources, err } func (s *Service) Resource(userID string, id string) (*model.Resource, error) { resource, err := s.repo.ResourceForUser(userID, id) if resource != nil { resource.PublicURL = "" } return resource, err } func (s *Service) verifyPublicResourceSignature(resourceID string, expires string, signature string) error { if strings.TrimSpace(signature) == "" || !decimalDigits(expires) { return Forbidden("匿名下载链接无效") } expiresAt, err := strconv.ParseInt(expires, 10, 64) if err != nil || time.Now().UTC().Unix() > expiresAt { return Forbidden("匿名下载链接已过期") } expected, err := s.signPublicResource(resourceID, expires) if err != nil { return err } if !hmac.Equal([]byte(expected), []byte(signature)) { return Forbidden("匿名下载链接无效") } return nil } func (s *Service) signPublicResource(resourceID string, expires string) (string, error) { key, err := s.settingsEncryptionKey() if err != nil { return "", err } mac := hmac.New(sha256.New, key) _, _ = mac.Write([]byte(resourceID + "\n" + expires)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil } func (s *Service) publicResourceBaseURL() (*url.URL, error) { _, setting, err := s.readOSSSetting() if err != nil { return nil, err } raw := firstNonEmpty(setting.PublicBaseURL, os.Getenv("CANVAS_PUBLIC_BASE_URL")) if raw == "" { return nil, BadAuthRequest("服务器本地存储尚未配置服务器访问地址,请设置 CANVAS_PUBLIC_BASE_URL 或在存储设置中配置公网访问地址(或改用 OSS 存储)") } return validatePublicResourceBaseURL(raw) } // 服务器访问地址是本服务对外暴露的根地址,由管理员自行决定, // 允许本机、局域网或任意主机;这里只校验格式,不套用出站私网策略。 func validatePublicResourceBaseURL(raw string) (*url.URL, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Hostname() == "" { return nil, BadAuthRequest("服务器访问地址必须是完整的 http/https 地址") } if parsed.Scheme != "https" && parsed.Scheme != "http" { return nil, BadAuthRequest("服务器访问地址只支持 http/https") } if parsed.User != nil { return nil, BadAuthRequest("服务器访问地址不能包含认证信息") } if parsed.RawQuery != "" || parsed.Fragment != "" { return nil, BadAuthRequest("服务器访问地址不能包含查询参数或片段") } if strings.HasSuffix(strings.TrimRight(parsed.Path, "/"), "/api") { return nil, BadAuthRequest("服务器访问地址请填写根地址,不要包含 /api") } return parsed, nil } func normalizeResourceKind(kind string, mimeType string) string { kind = strings.ToLower(strings.TrimSpace(kind)) switch kind { case "image", "video", "audio", "file": return kind } if strings.HasPrefix(mimeType, "image/") { return "image" } if strings.HasPrefix(mimeType, "video/") { return "video" } if strings.HasPrefix(mimeType, "audio/") { return "audio" } return "file" } func ossObjectKey(setting ossSettingValue, userID string, kind string, fileName string, mimeType string, now time.Time) string { ext := resourceFileExtension(fileName, mimeType, kind) name := newID() parts := []string{setting.PathPrefix, "users", safeObjectSegment(userID), kind, now.Format("2006/01/02"), name + ext} return strings.Trim(strings.Join(nonEmptySegments(parts), "/"), "/") } func normalizeSingleByteRange(value string) string { value = strings.TrimSpace(value) if len(value) > 128 || !strings.HasPrefix(value, "bytes=") || strings.Contains(value, ",") { return "" } start, end, ok := strings.Cut(strings.TrimPrefix(value, "bytes="), "-") if !ok || (start == "" && end == "") || !decimalDigits(start) || !decimalDigits(end) { return "" } return "bytes=" + start + "-" + end } func decimalDigits(value string) bool { for _, char := range value { if char < '0' || char > '9' { return false } } return true } func safeObjectSegment(value string) string { value = strings.TrimSpace(value) value = strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { return r } return '-' }, value) return strings.Trim(value, "-") } func firstNonEmpty(values ...string) string { return kernel.FirstNonEmpty(values...) } func nonEmptySegments(values []string) []string { result := make([]string, 0, len(values)) for _, value := range values { value = strings.Trim(strings.TrimSpace(path.Clean("/"+value)), "/") if value != "" && value != "." { result = append(result, value) } } return result }