优化结构

This commit is contained in:
Yvan
2026-08-20 23:02:06 +08:00
parent 63dbb4cfbc
commit e65cf0b1d0
338 changed files with 622 additions and 481 deletions
@@ -1,107 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"path"
"strings"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"kra/internal/biz"
"kra/internal/conf"
)
type aliyunStorage struct {
bucket *oss.Bucket
baseURL, prefix string
}
func newAliyunStorage(config *conf.AdminBackend_ObjectStore) (biz.FileStorage, error) {
if config == nil || config.Endpoint == "" || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
return nil, fmt.Errorf("aliyun-oss storage configuration is incomplete")
}
client, err := oss.New(config.Endpoint, config.AccessKey, config.SecretKey)
if err != nil {
return nil, err
}
bucket, err := client.Bucket(config.Bucket)
if err != nil {
return nil, err
}
return &aliyunStorage{bucket: bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
}
func (s *aliyunStorage) key(name string) string {
if s.prefix == "" {
return strings.TrimPrefix(name, "/")
}
return path.Join(s.prefix, strings.TrimPrefix(name, "/"))
}
func (s *aliyunStorage) unkey(key string) string {
return strings.TrimPrefix(strings.TrimPrefix(key, s.prefix), "/")
}
func (s *aliyunStorage) file(key string, size int64) *biz.StoredFile {
name := s.unkey(key)
url := s.baseURL + "/" + key
return &biz.StoredFile{Name: path.Base(name), Path: name, URL: url, Size: size}
}
func (s *aliyunStorage) Put(_ context.Context, name string, reader io.Reader) (*biz.StoredFile, error) {
key := s.key(name)
if err := s.bucket.PutObject(key, reader); err != nil {
return nil, err
}
meta, err := s.bucket.GetObjectDetailedMeta(key)
if err != nil {
return nil, err
}
size := int64(0)
_, _ = fmt.Sscan(meta.Get("Content-Length"), &size)
return s.file(key, size), nil
}
func (s *aliyunStorage) Open(_ context.Context, name string) (io.ReadCloser, error) {
return s.bucket.GetObject(s.key(name))
}
func (s *aliyunStorage) Delete(_ context.Context, name string) error {
return s.bucket.DeleteObject(s.key(name))
}
func (s *aliyunStorage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
return composeFiles(ctx, s, names, destination)
}
func (s *aliyunStorage) DeletePrefix(ctx context.Context, prefix string) error {
cursor := ""
for {
items, next, more, err := s.List(ctx, prefix, cursor, 1000)
if err != nil {
return err
}
for _, item := range items {
if err = s.Delete(ctx, item.Path); err != nil {
return err
}
}
if !more {
return nil
}
cursor = next
}
}
func (s *aliyunStorage) List(_ context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) {
if limit <= 0 {
limit = 100
}
result, err := s.bucket.ListObjects(oss.Prefix(s.key(prefix)), oss.Marker(cursor), oss.MaxKeys(limit))
if err != nil {
return nil, "", false, err
}
items := make([]*biz.StoredFile, 0, len(result.Objects))
for _, object := range result.Objects {
item := s.file(object.Key, object.Size)
item.LastModified = object.LastModified
items = append(items, item)
}
next := ""
if result.IsTruncated {
next = result.NextMarker
}
return items, next, result.IsTruncated, nil
}
-156
View File
@@ -1,156 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"path"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
"kra/internal/biz"
"kra/internal/conf"
)
type awsStorage struct {
client *s3.Client
bucket, baseURL, prefix string
}
func newAWSStorage(provider string, config *conf.AdminBackend_ObjectStore) (biz.FileStorage, error) {
if config == nil || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
return nil, fmt.Errorf("%s storage configuration is incomplete", provider)
}
region := config.Region
endpoint := config.Endpoint
if provider == "cloudflare-r2" {
region = "auto"
if endpoint == "" {
if config.AccountId == "" {
return nil, fmt.Errorf("cloudflare-r2 account id is required")
}
endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", config.AccountId)
}
}
if region == "" {
return nil, fmt.Errorf("%s region is required", provider)
}
loaded, err := awsconfig.LoadDefaultConfig(context.Background(), awsconfig.WithRegion(region), awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKey, config.SecretKey, "")))
if err != nil {
return nil, err
}
client := s3.NewFromConfig(loaded, func(options *s3.Options) {
if endpoint != "" {
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
if config.UseSsl {
endpoint = "https://" + endpoint
} else {
endpoint = "http://" + endpoint
}
}
options.BaseEndpoint = aws.String(endpoint)
}
options.UsePathStyle = config.ForcePathStyle
})
return &awsStorage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
}
func (s *awsStorage) key(name string) string {
if s.prefix == "" {
return strings.TrimPrefix(name, "/")
}
return path.Join(s.prefix, strings.TrimPrefix(name, "/"))
}
func (s *awsStorage) unkey(key string) string {
return strings.TrimPrefix(strings.TrimPrefix(key, s.prefix), "/")
}
func (s *awsStorage) file(key string, size int64) *biz.StoredFile {
name := s.unkey(key)
url := s.baseURL + "/" + key
return &biz.StoredFile{Name: path.Base(name), Path: name, URL: url, Size: size}
}
func (s *awsStorage) Put(ctx context.Context, name string, reader io.Reader) (*biz.StoredFile, error) {
key := s.key(name)
result, err := manager.NewUploader(s.client).Upload(ctx, &s3.PutObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key), Body: reader})
if err != nil {
return nil, err
}
_ = result
head, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)})
if err != nil {
return nil, err
}
size := int64(0)
if head.ContentLength != nil {
size = *head.ContentLength
}
return s.file(key, size), nil
}
func (s *awsStorage) Open(ctx context.Context, name string) (io.ReadCloser, error) {
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(s.key(name))})
if err != nil {
return nil, err
}
return result.Body, nil
}
func (s *awsStorage) Delete(ctx context.Context, name string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(s.key(name))})
return err
}
func (s *awsStorage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
return composeFiles(ctx, s, names, destination)
}
func (s *awsStorage) DeletePrefix(ctx context.Context, prefix string) error {
for {
items, _, more, err := s.List(ctx, prefix, "", 1000)
if err != nil {
return err
}
for _, item := range items {
if err = s.Delete(ctx, item.Path); err != nil {
return err
}
}
if !more || len(items) == 0 {
return nil
}
}
}
func (s *awsStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) {
if limit <= 0 {
limit = 100
}
input := &s3.ListObjectsV2Input{Bucket: aws.String(s.bucket), Prefix: aws.String(s.key(prefix)), MaxKeys: aws.Int32(int32(limit))}
if cursor != "" {
input.ContinuationToken = aws.String(cursor)
}
result, err := s.client.ListObjectsV2(ctx, input)
if err != nil {
return nil, "", false, err
}
items := make([]*biz.StoredFile, 0, len(result.Contents))
for _, object := range result.Contents {
if object.Key == nil {
continue
}
size := int64(0)
if object.Size != nil {
size = *object.Size
}
item := s.file(*object.Key, size)
if object.LastModified != nil {
item.LastModified = *object.LastModified
}
items = append(items, item)
}
next := ""
if result.NextContinuationToken != nil {
next = *result.NextContinuationToken
}
more := result.IsTruncated != nil && *result.IsTruncated
return items, next, more, nil
}
-56
View File
@@ -1,56 +0,0 @@
package storage
import (
"context"
"crypto/md5"
"encoding/hex"
"io"
)
// composeStreams concatenates objects into destination while calculating the
// MD5 of the exact bytes written. Provider-specific storage clients stay out
// of this package; callbacks keep this helper independent from biz and SDKs.
func composeStreams(
ctx context.Context,
names []string,
open func(context.Context, string) (io.ReadCloser, error),
put func(context.Context, string, io.Reader) error,
remove func(context.Context, string) error,
destination string,
) (string, error) {
reader, writer := io.Pipe()
hash := md5.New()
errCh := make(chan error, 1)
go func() {
defer writer.Close()
for _, name := range names {
if err := ctx.Err(); err != nil {
errCh <- err
return
}
file, err := open(ctx, name)
if err != nil {
errCh <- err
return
}
_, copyErr := io.Copy(io.MultiWriter(writer, hash), file)
_ = file.Close()
if copyErr != nil {
errCh <- copyErr
return
}
}
errCh <- nil
}()
putErr := put(ctx, destination, reader)
composeErr := <-errCh
if putErr != nil {
_ = remove(ctx, destination)
return "", putErr
}
if composeErr != nil {
_ = remove(ctx, destination)
return "", composeErr
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
@@ -1,50 +0,0 @@
package storage
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"io"
"strings"
"testing"
)
func TestComposeStreams(t *testing.T) {
objects := map[string]string{"a": "hello ", "b": "world"}
var stored string
hash, err := composeStreams(context.Background(), []string{"a", "b"}, func(_ context.Context, name string) (io.ReadCloser, error) {
value, ok := objects[name]
if !ok {
return nil, errors.New("missing object")
}
return io.NopCloser(strings.NewReader(value)), nil
}, func(_ context.Context, _ string, reader io.Reader) error {
value, err := io.ReadAll(reader)
stored = string(value)
return err
}, func(context.Context, string) error { return nil }, "out")
if err != nil || stored != "hello world" {
t.Fatalf("compose = %q, %v", stored, err)
}
want := md5.Sum([]byte(stored))
if hash != hex.EncodeToString(want[:]) {
t.Fatalf("hash = %q", hash)
}
}
func TestComposeStreamsRemovesPartialDestination(t *testing.T) {
removed := false
_, err := composeStreams(context.Background(), []string{"missing"}, func(context.Context, string) (io.ReadCloser, error) {
return nil, errors.New("open failed")
}, func(_ context.Context, _ string, reader io.Reader) error {
_, _ = io.ReadAll(reader)
return nil
}, func(context.Context, string) error {
removed = true
return nil
}, "out")
if err == nil || !removed {
t.Fatalf("compose error = %v, removed = %v", err, removed)
}
}
@@ -1,106 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"path"
"strings"
"github.com/huaweicloud/huaweicloud-sdk-go-obs/obs"
"kra/internal/biz"
"kra/internal/conf"
)
type huaweiStorage struct {
client *obs.ObsClient
bucket, baseURL, prefix string
}
func newHuaweiStorage(config *conf.AdminBackend_ObjectStore) (biz.FileStorage, error) {
if config == nil || config.Endpoint == "" || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
return nil, fmt.Errorf("huawei-obs storage configuration is incomplete")
}
client, err := obs.New(config.AccessKey, config.SecretKey, config.Endpoint)
if err != nil {
return nil, err
}
return &huaweiStorage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
}
func (s *huaweiStorage) key(name string) string {
if s.prefix == "" {
return strings.TrimPrefix(name, "/")
}
return path.Join(s.prefix, strings.TrimPrefix(name, "/"))
}
func (s *huaweiStorage) unkey(key string) string {
return strings.TrimPrefix(strings.TrimPrefix(key, s.prefix), "/")
}
func (s *huaweiStorage) file(key string, size int64) *biz.StoredFile {
name := s.unkey(key)
fileURL := s.baseURL + "/" + key
return &biz.StoredFile{Name: path.Base(name), Path: name, URL: fileURL, Size: size}
}
func (s *huaweiStorage) Put(_ context.Context, name string, reader io.Reader) (*biz.StoredFile, error) {
key := s.key(name)
if _, err := s.client.PutObject(&obs.PutObjectInput{PutObjectBasicInput: obs.PutObjectBasicInput{ObjectOperationInput: obs.ObjectOperationInput{Bucket: s.bucket, Key: key}}, Body: reader}); err != nil {
return nil, err
}
meta, err := s.client.GetObjectMetadata(&obs.GetObjectMetadataInput{Bucket: s.bucket, Key: key})
if err != nil {
return nil, err
}
return s.file(key, meta.ContentLength), nil
}
func (s *huaweiStorage) Open(_ context.Context, name string) (io.ReadCloser, error) {
result, err := s.client.GetObject(&obs.GetObjectInput{GetObjectMetadataInput: obs.GetObjectMetadataInput{Bucket: s.bucket, Key: s.key(name)}})
if err != nil {
return nil, err
}
return result.Body, nil
}
func (s *huaweiStorage) Delete(_ context.Context, name string) error {
_, err := s.client.DeleteObject(&obs.DeleteObjectInput{Bucket: s.bucket, Key: s.key(name)})
return err
}
func (s *huaweiStorage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
return composeFiles(ctx, s, names, destination)
}
func (s *huaweiStorage) DeletePrefix(ctx context.Context, prefix string) error {
cursor := ""
for {
items, next, more, err := s.List(ctx, prefix, cursor, 1000)
if err != nil {
return err
}
for _, item := range items {
if err = s.Delete(ctx, item.Path); err != nil {
return err
}
}
if !more {
return nil
}
cursor = next
}
}
func (s *huaweiStorage) List(_ context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) {
if limit <= 0 {
limit = 100
}
result, err := s.client.ListObjects(&obs.ListObjectsInput{ListObjsInput: obs.ListObjsInput{Prefix: s.key(prefix), MaxKeys: limit}, Bucket: s.bucket, Marker: cursor})
if err != nil {
return nil, "", false, err
}
items := make([]*biz.StoredFile, 0, len(result.Contents))
for _, object := range result.Contents {
item := s.file(object.Key, object.Size)
item.LastModified = object.LastModified
items = append(items, item)
}
next := result.NextMarker
if result.IsTruncated && next == "" && len(result.Contents) > 0 {
next = result.Contents[len(result.Contents)-1].Key
}
return items, next, result.IsTruncated, nil
}
-202
View File
@@ -1,202 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"sort"
"strings"
"kra/internal/biz"
"kra/internal/conf"
)
type fileStorage struct {
root string
urlPrefix string
}
func New(config *conf.AdminBackend) (biz.FileStorage, error) {
storageType := "local"
if config != nil && config.Storage != nil && config.Storage.Type != "" {
storageType = strings.ToLower(config.Storage.Type)
}
if storageType == "qiniu" {
return newQiniuStorage(config.Storage.Qiniu)
}
if storageType != "local" {
var object *conf.AdminBackend_ObjectStore
switch storageType {
case "aliyun-oss":
return newAliyunStorage(config.Storage.AliyunOss)
case "huawei-obs":
return newHuaweiStorage(config.Storage.HuaweiObs)
case "tencent-cos":
return newTencentStorage(config.Storage.TencentCos)
case "aws-s3":
return newAWSStorage(storageType, config.Storage.AwsS3)
case "cloudflare-r2":
return newAWSStorage(storageType, config.Storage.CloudflareR2)
case "minio":
object = config.Storage.Minio
default:
return nil, fmt.Errorf("unsupported storage type %q", storageType)
}
return newS3Storage(storageType, object)
}
root, prefix := "uploads/file", "uploads/file"
if config != nil && config.Local != nil {
if config.Local.StorePath != "" {
root = config.Local.StorePath
}
if config.Local.PathPrefix != "" {
prefix = config.Local.PathPrefix
}
}
root = filepath.Clean(root)
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create upload directory: %w", err)
}
return &fileStorage{root: root, urlPrefix: "/" + strings.Trim(prefix, "/")}, nil
}
func composeFiles(ctx context.Context, storage biz.FileStorage, names []string, destination string) (*biz.StoredFile, string, error) {
var stored *biz.StoredFile
hash, err := composeStreams(ctx, names, storage.Open, func(ctx context.Context, destination string, reader io.Reader) error {
var putErr error
stored, putErr = storage.Put(ctx, destination, reader)
return putErr
}, storage.Delete, destination)
if err != nil {
return nil, "", err
}
return stored, hash, nil
}
func (s *fileStorage) resolve(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("key不能为空")
}
clean := filepath.Clean(strings.TrimPrefix(name, "/"))
if clean == "." || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("invalid storage path")
}
return filepath.Join(s.root, clean), nil
}
func (s *fileStorage) Put(ctx context.Context, name string, reader io.Reader) (*biz.StoredFile, error) {
path, err := s.resolve(name)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return nil, err
}
size, copyErr := io.Copy(file, reader)
closeErr := file.Close()
if copyErr != nil {
return nil, copyErr
}
if closeErr != nil {
return nil, closeErr
}
return &biz.StoredFile{Name: filepath.Base(name), Path: name, URL: s.urlPrefix + "/" + strings.TrimPrefix(filepath.ToSlash(name), "/"), Size: size}, nil
}
func (s *fileStorage) Open(ctx context.Context, name string) (io.ReadCloser, error) {
path, err := s.resolve(name)
if err != nil {
return nil, err
}
return os.Open(path)
}
func (s *fileStorage) Delete(ctx context.Context, name string) error {
path, err := s.resolve(name)
if err != nil {
return err
}
// Match the compatible Local.DeleteFile behavior: a missing object is an error and the
// media DB record is retained when the physical file cannot be removed.
if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Errorf("文件不存在")
}
if err := os.Remove(path); err != nil {
return err
}
return nil
}
func (s *fileStorage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
return composeFiles(ctx, s, names, destination)
}
func (s *fileStorage) DeletePrefix(ctx context.Context, prefix string) error {
path, err := s.resolve(strings.TrimSuffix(prefix, "/") + "/placeholder")
if err != nil {
return err
}
dir := filepath.Dir(path)
if !strings.HasPrefix(dir, s.root+string(filepath.Separator)) {
return fmt.Errorf("invalid storage path")
}
if err = os.RemoveAll(dir); err != nil {
return err
}
return nil
}
func (s *fileStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) {
if limit <= 0 {
limit = 100
}
root, err := s.resolve(strings.TrimSuffix(prefix, "/") + "/placeholder")
if err != nil {
return nil, "", false, err
}
walkRoot := filepath.Dir(root)
items := []*biz.StoredFile{}
err = filepath.WalkDir(walkRoot, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
if os.IsNotExist(walkErr) {
return nil
}
return walkErr
}
if ctx.Err() != nil {
return ctx.Err()
}
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
return nil
}
relative, _ := filepath.Rel(s.root, path)
relative = filepath.ToSlash(relative)
if relative <= cursor {
return nil
}
info, infoErr := entry.Info()
if infoErr != nil {
return infoErr
}
items = append(items, &biz.StoredFile{Name: entry.Name(), Path: relative, URL: s.urlPrefix + "/" + relative, Size: info.Size(), LastModified: info.ModTime(), ContentType: mime.TypeByExtension(filepath.Ext(entry.Name()))})
return nil
})
if err != nil {
return nil, "", false, err
}
sort.Slice(items, func(i, j int) bool { return items[i].Path < items[j].Path })
hasMore := len(items) > limit
if hasMore {
items = items[:limit]
}
next := ""
if len(items) > 0 {
next = items[len(items)-1].Path
}
return items, next, hasMore, nil
}
@@ -1,129 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/qiniu/go-sdk/v7/auth/qbox"
qstorage "github.com/qiniu/go-sdk/v7/storage"
"kra/internal/biz"
"kra/internal/conf"
)
type qiniuStorage struct {
config *conf.AdminBackend_Qiniu
upload *qstorage.FormUploader
manager *qstorage.BucketManager
mac *qbox.Mac
token string
}
func newQiniuStorage(config *conf.AdminBackend_Qiniu) (biz.FileStorage, error) {
if config == nil || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
return nil, fmt.Errorf("qiniu storage configuration is incomplete")
}
cfg := qstorage.Config{UseHTTPS: config.UseHttps, UseCdnDomains: config.UseCdnDomains}
switch config.Zone {
case "ZoneHuadong":
cfg.Zone = &qstorage.ZoneHuadong
case "ZoneHuabei":
cfg.Zone = &qstorage.ZoneHuabei
case "ZoneHuanan":
cfg.Zone = &qstorage.ZoneHuanan
case "ZoneBeimei":
cfg.Zone = &qstorage.ZoneBeimei
case "ZoneXinjiapo":
cfg.Zone = &qstorage.ZoneXinjiapo
}
mac := qbox.NewMac(config.AccessKey, config.SecretKey)
policy := qstorage.PutPolicy{Scope: config.Bucket}
token := policy.UploadToken(mac)
return &qiniuStorage{config: config, upload: qstorage.NewFormUploader(&cfg), manager: qstorage.NewBucketManager(mac, &cfg), mac: mac, token: token}, nil
}
func (s *qiniuStorage) file(key string, size int64) *biz.StoredFile {
return &biz.StoredFile{Name: path.Base(key), Path: key, URL: strings.TrimSuffix(s.config.BaseUrl, "/") + "/" + key, Size: size}
}
func (s *qiniuStorage) Put(ctx context.Context, name string, reader io.Reader) (*biz.StoredFile, error) {
temporary, err := os.CreateTemp("", "kra-qiniu-upload-*")
if err != nil {
return nil, err
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
size, err := io.Copy(temporary, reader)
if err != nil {
_ = temporary.Close()
return nil, err
}
if _, err = temporary.Seek(0, io.SeekStart); err != nil {
_ = temporary.Close()
return nil, err
}
defer temporary.Close()
ret := qstorage.PutRet{}
if err = s.upload.Put(ctx, &ret, s.token, strings.TrimPrefix(name, "/"), temporary, size, &qstorage.PutExtra{}); err != nil {
return nil, err
}
return s.file(ret.Key, size), nil
}
func (s *qiniuStorage) Open(ctx context.Context, name string) (io.ReadCloser, error) {
downloadURL := qstorage.MakePrivateURL(s.mac, strings.TrimSuffix(s.config.BaseUrl, "/"), strings.TrimPrefix(name, "/"), time.Now().Add(time.Hour).Unix())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
_ = resp.Body.Close()
return nil, fmt.Errorf("qiniu download failed: %s", resp.Status)
}
return resp.Body, nil
}
func (s *qiniuStorage) Delete(ctx context.Context, name string) error {
return s.manager.Delete(s.config.Bucket, strings.TrimPrefix(name, "/"))
}
func (s *qiniuStorage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
return composeFiles(ctx, s, names, destination)
}
func (s *qiniuStorage) DeletePrefix(ctx context.Context, prefix string) error {
for {
items, _, more, err := s.List(ctx, prefix, "", 1000)
if err != nil {
return err
}
for _, item := range items {
if err = s.Delete(ctx, item.Path); err != nil {
return err
}
}
if !more {
return nil
}
}
}
func (s *qiniuStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) {
if limit <= 0 {
limit = 100
}
entries, _, marker, more, err := s.manager.ListFiles(s.config.Bucket, prefix, "", cursor, limit)
if err != nil {
return nil, "", false, err
}
out := make([]*biz.StoredFile, 0, len(entries))
for _, entry := range entries {
item := s.file(entry.Key, entry.Fsize)
item.LastModified = time.Unix(0, entry.PutTime*100)
item.ContentType = entry.MimeType
out = append(out, item)
}
return out, marker, more, nil
}
@@ -1,68 +0,0 @@
package storage
import (
"context"
"io"
"sync"
"kra/internal/biz"
"kra/internal/conf"
)
type Reloadable struct {
mu sync.RWMutex
current biz.FileStorage
}
func NewFileStorage(runtime *conf.Runtime) (*Reloadable, error) {
var config *conf.AdminBackend
if runtime != nil {
config = runtime.Admin()
}
return NewReloadable(config)
}
func NewReloadable(config *conf.AdminBackend) (*Reloadable, error) {
current, err := New(config)
if err != nil {
return nil, err
}
return &Reloadable{current: current}, nil
}
func (s *Reloadable) Replace(current biz.FileStorage) {
s.mu.Lock()
s.current = current
s.mu.Unlock()
}
func (s *Reloadable) Put(ctx context.Context, name string, reader io.Reader) (*biz.StoredFile, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.current.Put(ctx, name, reader)
}
func (s *Reloadable) Open(ctx context.Context, name string) (io.ReadCloser, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.current.Open(ctx, name)
}
func (s *Reloadable) Delete(ctx context.Context, name string) error {
s.mu.RLock()
defer s.mu.RUnlock()
return s.current.Delete(ctx, name)
}
func (s *Reloadable) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.current.Compose(ctx, names, destination)
}
func (s *Reloadable) DeletePrefix(ctx context.Context, prefix string) error {
s.mu.RLock()
defer s.mu.RUnlock()
return s.current.DeletePrefix(ctx, prefix)
}
func (s *Reloadable) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.current.List(ctx, prefix, cursor, limit)
}
-138
View File
@@ -1,138 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"net/url"
"path"
"strings"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"kra/internal/biz"
"kra/internal/conf"
)
type s3Storage struct {
client *minio.Client
bucket, baseURL, prefix string
}
func newS3Storage(provider string, config *conf.AdminBackend_ObjectStore) (biz.FileStorage, error) {
if config == nil || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
return nil, fmt.Errorf("%s storage configuration is incomplete", provider)
}
endpoint := config.Endpoint
if endpoint == "" {
switch provider {
case "aws-s3":
endpoint = "s3." + config.Region + ".amazonaws.com"
case "cloudflare-r2":
endpoint = config.AccountId + ".r2.cloudflarestorage.com"
case "aliyun-oss":
endpoint = "oss-" + config.Region + ".aliyuncs.com"
case "huawei-obs":
endpoint = "obs." + config.Region + ".myhuaweicloud.com"
case "tencent-cos":
endpoint = "cos." + config.Region + ".myqcloud.com"
}
}
secure := config.UseSsl
if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" {
secure = parsed.Scheme == "https"
endpoint = parsed.Host
}
if endpoint == "" {
return nil, fmt.Errorf("%s endpoint is required", provider)
}
client, err := minio.New(strings.TrimSuffix(endpoint, "/"), &minio.Options{Creds: credentials.NewStaticV4(config.AccessKey, config.SecretKey, ""), Secure: secure, Region: config.Region, BucketLookup: func() minio.BucketLookupType {
if config.ForcePathStyle {
return minio.BucketLookupPath
}
return minio.BucketLookupAuto
}()})
if err != nil {
return nil, err
}
return &s3Storage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
}
func (s *s3Storage) key(name string) string {
if s.prefix == "" {
return strings.TrimPrefix(name, "/")
}
return path.Join(s.prefix, strings.TrimPrefix(name, "/"))
}
func (s *s3Storage) unkey(key string) string {
return strings.TrimPrefix(strings.TrimPrefix(key, s.prefix), "/")
}
func (s *s3Storage) file(key string, size int64) *biz.StoredFile {
name := s.unkey(key)
rawURL := s.baseURL + "/" + key
return &biz.StoredFile{Name: path.Base(name), Path: name, URL: rawURL, Size: size}
}
func (s *s3Storage) Put(ctx context.Context, name string, reader io.Reader) (*biz.StoredFile, error) {
key := s.key(name)
info, err := s.client.PutObject(ctx, s.bucket, key, reader, -1, minio.PutObjectOptions{})
if err != nil {
return nil, err
}
return s.file(key, info.Size), nil
}
func (s *s3Storage) Open(ctx context.Context, name string) (io.ReadCloser, error) {
obj, err := s.client.GetObject(ctx, s.bucket, s.key(name), minio.GetObjectOptions{})
if err != nil {
return nil, err
}
if _, err = obj.Stat(); err != nil {
_ = obj.Close()
return nil, err
}
return obj, nil
}
func (s *s3Storage) Delete(ctx context.Context, name string) error {
return s.client.RemoveObject(ctx, s.bucket, s.key(name), minio.RemoveObjectOptions{})
}
func (s *s3Storage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
return composeFiles(ctx, s, names, destination)
}
func (s *s3Storage) DeletePrefix(ctx context.Context, prefix string) error {
items := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{Prefix: s.key(prefix), Recursive: true})
for item := range items {
if item.Err != nil {
return item.Err
}
if err := s.client.RemoveObject(ctx, s.bucket, item.Key, minio.RemoveObjectOptions{}); err != nil {
return err
}
}
return nil
}
func (s *s3Storage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) {
if limit <= 0 {
limit = 100
}
items := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{Prefix: s.key(prefix), Recursive: true, StartAfter: s.key(cursor)})
out := make([]*biz.StoredFile, 0, limit+1)
for item := range items {
if item.Err != nil {
return nil, "", false, item.Err
}
file := s.file(item.Key, item.Size)
file.LastModified = item.LastModified
file.ContentType = item.ContentType
out = append(out, file)
if len(out) > limit {
break
}
}
more := len(out) > limit
if more {
out = out[:limit]
}
next := ""
if len(out) > 0 {
next = out[len(out)-1].Path
}
return out, next, more, nil
}
@@ -1,125 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"time"
cos "github.com/tencentyun/cos-go-sdk-v5"
"kra/internal/biz"
"kra/internal/conf"
)
type tencentStorage struct {
client *cos.Client
baseURL, prefix string
}
func newTencentStorage(config *conf.AdminBackend_ObjectStore) (biz.FileStorage, error) {
if config == nil || config.Bucket == "" || config.Region == "" || config.AccessKey == "" || config.SecretKey == "" {
return nil, fmt.Errorf("tencent-cos storage configuration is incomplete")
}
endpoint := config.Endpoint
if endpoint == "" {
endpoint = fmt.Sprintf("https://%s.cos.%s.myqcloud.com", config.Bucket, config.Region)
} else if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
if config.UseSsl {
endpoint = "https://" + endpoint
} else {
endpoint = "http://" + endpoint
}
}
bucketURL, err := url.Parse(endpoint)
if err != nil {
return nil, err
}
client := cos.NewClient(&cos.BaseURL{BucketURL: bucketURL}, &http.Client{Transport: &cos.AuthorizationTransport{SecretID: config.AccessKey, SecretKey: config.SecretKey}})
return &tencentStorage{client: client, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
}
func (s *tencentStorage) key(name string) string {
if s.prefix == "" {
return strings.TrimPrefix(name, "/")
}
return path.Join(s.prefix, strings.TrimPrefix(name, "/"))
}
func (s *tencentStorage) unkey(key string) string {
return strings.TrimPrefix(strings.TrimPrefix(key, s.prefix), "/")
}
func (s *tencentStorage) file(key string, size int64) *biz.StoredFile {
name := s.unkey(key)
fileURL := s.baseURL + "/" + key
return &biz.StoredFile{Name: path.Base(name), Path: name, URL: fileURL, Size: size}
}
func (s *tencentStorage) Put(ctx context.Context, name string, reader io.Reader) (*biz.StoredFile, error) {
key := s.key(name)
if _, err := s.client.Object.Put(ctx, key, reader, nil); err != nil {
return nil, err
}
response, err := s.client.Object.Head(ctx, key, nil)
if err != nil {
return nil, err
}
return s.file(key, response.ContentLength), nil
}
func (s *tencentStorage) Open(ctx context.Context, name string) (io.ReadCloser, error) {
response, err := s.client.Object.Get(ctx, s.key(name), nil)
if err != nil {
return nil, err
}
return response.Body, nil
}
func (s *tencentStorage) Delete(ctx context.Context, name string) error {
_, err := s.client.Object.Delete(ctx, s.key(name))
return err
}
func (s *tencentStorage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
return composeFiles(ctx, s, names, destination)
}
func (s *tencentStorage) DeletePrefix(ctx context.Context, prefix string) error {
cursor := ""
for {
items, next, more, err := s.List(ctx, prefix, cursor, 1000)
if err != nil {
return err
}
for _, item := range items {
if err = s.Delete(ctx, item.Path); err != nil {
return err
}
}
if !more {
return nil
}
cursor = next
}
}
func (s *tencentStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) {
if limit <= 0 {
limit = 100
}
result, _, err := s.client.Bucket.Get(ctx, &cos.BucketGetOptions{Prefix: s.key(prefix), Marker: cursor, MaxKeys: limit})
if err != nil {
return nil, "", false, err
}
items := make([]*biz.StoredFile, 0, len(result.Contents))
for _, object := range result.Contents {
item := s.file(object.Key, object.Size)
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05.000Z"} {
if parsed, parseErr := time.Parse(layout, object.LastModified); parseErr == nil {
item.LastModified = parsed
break
}
}
items = append(items, item)
}
next := result.NextMarker
if result.IsTruncated && next == "" && len(result.Contents) > 0 {
next = result.Contents[len(result.Contents)-1].Key
}
return items, next, result.IsTruncated, nil
}