Files
ttstd-infinite-canvas/backend/internal/app/resource.go

178 lines
5.2 KiB
Go

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
}