289 lines
10 KiB
Go
289 lines
10 KiB
Go
package app
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"path"
|
||
"strings"
|
||
"time"
|
||
|
||
"yingce/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(s.storageSettingWithRuntimePolicy(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)
|
||
}
|
||
}
|