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

289 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"path"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"github.com/tencentyun/cos-go-sdk-v5"
"gorm.io/gorm"
)
type OSSConnectionTestResult struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
TestedAt time.Time `json:"testedAt"`
TestedDigest string `json:"testedDigest"`
}
var errOSSConnectionReadMismatch = errors.New("对象存储读取内容不一致")
func (s *Service) TestAdminOSSSetting(actor *model.User, req OSSSettingRequest) (*OSSConnectionTestResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
_, current, err := s.readOSSSetting()
if err != nil {
return nil, err
}
return s.testOSSSetting("platform", "", actor.ID, req, current)
}
func (s *Service) TestUserOSSSetting(actor *model.User, req OSSSettingRequest) (*OSSConnectionTestResult, error) {
if actor == nil {
return nil, Unauthorized("请先登录")
}
_, platform, err := s.readOSSSetting()
if err != nil {
return nil, err
}
if strings.EqualFold(strings.TrimSpace(req.Provider), s3Provider) && !platform.AllowUserS3 {
return nil, Forbidden("平台管理员尚未允许个人 S3 兼容存储")
}
_, current, err := s.readUserOSSSetting(actor.ID)
if err != nil {
return nil, err
}
return s.testOSSSetting("user", actor.ID, actor.ID, req, current)
}
func (s *Service) testOSSSetting(scope string, ownerID string, actorID string, req OSSSettingRequest, current ossSettingValue) (*OSSConnectionTestResult, error) {
req.Enabled = true
value, err := ossSettingFromRequest(req, current)
if err != nil {
return nil, err
}
key := scope + ":" + actorID
s.storageTestMu.Lock()
if s.activeStorageTests == nil {
s.activeStorageTests = make(map[string]bool)
}
if s.activeStorageTests[key] {
s.storageTestMu.Unlock()
return nil, NewAppError(http.StatusConflict, "对象存储连接测试正在进行")
}
s.activeStorageTests[key] = true
s.storageTestMu.Unlock()
defer func() {
s.storageTestMu.Lock()
delete(s.activeStorageTests, key)
s.storageTestMu.Unlock()
}()
testKey := path.Join(value.PathPrefix, ".yingce-tests", scope, newID())
if err := verifyOSSConnection(value, testKey); err != nil {
return nil, err
}
location, err := s.upsertStorageLocation(scope, ownerID, value)
if err != nil {
return nil, err
}
now := time.Now()
location.TestedDigest = storageTestDigest(value)
location.TestedAt = &now
if err := s.repo.SaveStorageLocation(location); err != nil {
return nil, err
}
return &OSSConnectionTestResult{OK: true, Message: "连接测试通过", TestedAt: now, TestedDigest: location.TestedDigest}, nil
}
func verifyOSSConnection(value ossSettingValue, testKey string) error {
payload := []byte("yingce-storage-test")
// 连接测试验证服务端到对象存储的真实读写权限。CDN 是浏览器读取出口,
// 可能存在回源鉴权或边缘同步延迟,不能参与刚写入对象的最小读写测试。
testValue := value
testValue.CDNBaseURL = ""
if _, err := putOSSObject(testValue, testKey, "application/octet-stream", int64(len(payload)), bytes.NewReader(payload)); err != nil {
return storageConnectionTestError(testValue.Provider, "写入", err)
}
stream, err := getOSSObjectRange(testValue, testKey, "bytes=0-3")
var rangeErr error
if err != nil {
rangeErr = err
} else {
rangeErr = verifyOSSConnectionRead(stream, payload)
}
if err := deleteOSSObject(testValue, testKey); err != nil {
return storageConnectionTestError(testValue.Provider, "删除", err)
}
if rangeErr != nil {
return storageConnectionTestError(testValue.Provider, "Range 读取", rangeErr)
}
return nil
}
func verifyOSSConnectionRead(stream *ossObjectStream, payload []byte) error {
if stream == nil || stream.Body == nil {
return fmt.Errorf("%w:响应为空", errOSSConnectionReadMismatch)
}
data, readErr := io.ReadAll(io.LimitReader(stream.Body, int64(len(payload)+1)))
closeErr := stream.Body.Close()
if readErr != nil {
return fmt.Errorf("对象存储响应读取失败:%w", readErr)
}
if closeErr != nil {
return fmt.Errorf("对象存储响应关闭失败:%w", closeErr)
}
// HTTP 允许服务端忽略 Range 并返回 200 + 完整对象。应用的资源代理也会
// 原样透传这种响应,因此连接测试应同时接受完整读取与标准的 206 分段读取。
switch stream.StatusCode {
case http.StatusPartialContent:
if len(payload) >= 4 && bytes.Equal(data, payload[:4]) {
return nil
}
case http.StatusOK:
if bytes.Equal(data, payload) {
return nil
}
}
return fmt.Errorf("%w(HTTP %d,读取 %d 字节)", errOSSConnectionReadMismatch, stream.StatusCode, len(data))
}
func storageConnectionTestError(provider string, operation string, cause error) error {
provider = strings.ToLower(strings.TrimSpace(provider))
providerName := "对象存储"
switch provider {
case aliyunOSSProvider:
providerName = "阿里云 OSS"
case tencentCOSProvider:
providerName = "腾讯云 COS"
case qiniuKodoProvider:
providerName = "七牛云 Kodo"
case s3Provider:
providerName = "S3 兼容存储"
}
if errors.Is(cause, context.DeadlineExceeded) {
return WrapAppError(http.StatusGatewayTimeout, fmt.Sprintf("%s %s测试超时,请检查 Endpoint 和服务端网络", providerName, operation), cause)
}
var networkErr net.Error
if errors.As(cause, &networkErr) && networkErr.Timeout() {
return WrapAppError(http.StatusGatewayTimeout, fmt.Sprintf("%s %s测试超时,请检查 Endpoint 和服务端网络", providerName, operation), cause)
}
if operation == "Range 读取" && errors.Is(cause, errOSSConnectionReadMismatch) {
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("%s Range 读取返回的数据与刚写入对象不一致,请检查 Endpoint 或出网代理是否忽略、改写了 Range 请求", providerName), cause)
}
if provider == tencentCOSProvider {
var responseErr *cos.ErrorResponse
if errors.As(cause, &responseErr) && responseErr.Response != nil {
switch responseErr.Response.StatusCode {
case http.StatusBadRequest, http.StatusUnprocessableEntity:
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s请求被拒绝,请检查 Bucket、Region 和 Endpoint", operation), cause)
case http.StatusUnauthorized, http.StatusForbidden:
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s鉴权失败,请检查 SecretId、SecretKey 和 Bucket 权限", operation), cause)
case http.StatusNotFound:
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s未找到 Bucket,请检查 Bucket、Region 和 Endpoint", operation), cause)
case http.StatusTooManyRequests:
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s请求过于频繁,请稍后重试", operation), cause)
}
if responseErr.Response.StatusCode >= http.StatusInternalServerError {
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s服务暂时不可用,请稍后重试", operation), cause)
}
}
}
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("%s %s测试失败,请检查 Endpoint、Bucket、访问密钥和存储桶权限", providerName, operation), cause)
}
func (s *Service) upsertStorageLocation(scope string, ownerID string, value ossSettingValue) (*model.StorageLocation, error) {
digest := storageLocationDigest(value)
location, err := s.repo.StorageLocationByDigest(scope, ownerID, value.Provider, digest)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
stored, err := s.encryptOSSSettingSecrets(value)
if err != nil {
return nil, err
}
stored.Enabled = true
stored.StorageLocationID = ""
encoded, err := json.Marshal(stored)
if err != nil {
return nil, err
}
if location == nil {
location = &model.StorageLocation{ID: newID(), Scope: scope, OwnerID: ownerID, Provider: value.Provider, LocationDigest: digest, ValueJSON: string(encoded)}
if err := s.repo.CreateStorageLocation(location); err != nil {
return nil, err
}
return location, nil
}
location.ValueJSON = string(encoded)
return location, nil
}
func (s *Service) storageLocationValue(id string) (*model.StorageLocation, ossSettingValue, error) {
location, err := s.repo.StorageLocation(id)
if err != nil {
return nil, ossSettingValue{}, err
}
var value ossSettingValue
if err := json.Unmarshal([]byte(location.ValueJSON), &value); err != nil {
return nil, ossSettingValue{}, errors.New("对象存储位置配置格式无效")
}
if _, err := s.decryptOSSSettingSecrets(&value); err != nil {
return nil, ossSettingValue{}, err
}
value.StorageLocationID = location.ID
return location, normalizeOSSSetting(value), nil
}
func (s *Service) requireTestedS3Location(scope string, ownerID string, value ossSettingValue) (*model.StorageLocation, error) {
location, err := s.repo.StorageLocationByDigest(scope, ownerID, s3Provider, storageLocationDigest(value))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("S3 关键配置尚未通过连接测试")
}
return nil, err
}
if location.TestedAt == nil || location.TestedDigest != storageTestDigest(value) {
return nil, BadAuthRequest("S3 关键配置或凭据已变化,请重新连接测试")
}
return location, nil
}
func (s *Service) populateStorageLocationPublic(result *PublicOSSSetting, scope string, ownerID string, id string) error {
count, err := s.repo.StorageLocationHistoryCount(scope, ownerID)
if err != nil {
return err
}
result.HistoryCount = count
if id == "" {
return nil
}
location, err := s.repo.StorageLocation(id)
if err != nil {
return err
}
result.TestedAt = location.TestedAt
result.TestedDigest = location.TestedDigest
result.ReferencedResourceCount, err = s.repo.StorageLocationResourceCount(id)
return err
}
func deleteOSSObject(setting ossSettingValue, objectKey string) error {
switch normalizeOSSSetting(setting).Provider {
case s3Provider:
return deleteS3Object(setting, objectKey)
case tencentCOSProvider:
return deleteTencentCOSObject(setting, objectKey)
case qiniuKodoProvider:
return deleteQiniuObject(setting, objectKey)
default:
return deleteAliyunOSSObject(setting, objectKey)
}
}