178 lines
5.2 KiB
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"
|
|
|
|
"yingce/backend/internal/assets"
|
|
"yingce/backend/internal/kernel"
|
|
"yingce/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
|
|
}
|