fix: 初验针对修改
This commit is contained in:
@@ -1,21 +1,18 @@
|
||||
package storage
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.apinb.com/ops/files/internal/config"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *oss.Client
|
||||
bucket string
|
||||
publicBaseURL string
|
||||
presignTTL time.Duration
|
||||
client *oss.Client
|
||||
bucket string
|
||||
presignTTL int64
|
||||
}
|
||||
|
||||
func New(cfg config.ObjectStorageConf) (*Client, error) {
|
||||
func New(cfg config.AliyunStorageConf, accessTTLSeconds int64) (*Client, error) {
|
||||
ossConfig := oss.LoadDefaultConfig().
|
||||
WithRegion(cfg.Region).
|
||||
WithEndpoint(cfg.Endpoint).
|
||||
@@ -23,9 +20,12 @@ func New(cfg config.ObjectStorageConf) (*Client, error) {
|
||||
WithSignatureVersion(oss.SignatureVersionV4)
|
||||
|
||||
return &Client{
|
||||
client: oss.NewClient(ossConfig),
|
||||
bucket: cfg.Bucket,
|
||||
publicBaseURL: cfg.PublicBaseURL,
|
||||
presignTTL: time.Duration(cfg.PresignTTLSeconds) * time.Second,
|
||||
client: oss.NewClient(ossConfig),
|
||||
bucket: cfg.Bucket,
|
||||
presignTTL: accessTTLSeconds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Provider() string { return "aliyun" }
|
||||
|
||||
func (c *Client) Container() string { return c.bucket }
|
||||
99
internal/storage/aliyun/object.go
Normal file
99
internal/storage/aliyun/object.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
)
|
||||
|
||||
// Open 返回对象存储中的只读内容流。
|
||||
func (c *Client) Open(ctx context.Context, objectKey string, byteRange *storage.ByteRange) (io.ReadCloser, error) {
|
||||
if strings.TrimSpace(objectKey) == "" {
|
||||
return nil, fmt.Errorf("对象键不能为空")
|
||||
}
|
||||
request := &oss.GetObjectRequest{
|
||||
Bucket: oss.Ptr(c.bucket),
|
||||
Key: oss.Ptr(objectKey),
|
||||
}
|
||||
if byteRange != nil {
|
||||
rangeHeader := fmt.Sprintf("bytes=%d-%d", byteRange.Start, byteRange.End)
|
||||
request.Range = &rangeHeader
|
||||
}
|
||||
result, err := c.client.GetObject(ctx, request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取对象: %w", err)
|
||||
}
|
||||
if result == nil || result.Body == nil {
|
||||
return nil, fmt.Errorf("对象内容为空")
|
||||
}
|
||||
return result.Body, nil
|
||||
}
|
||||
|
||||
func (c *Client) Stat(ctx context.Context, objectKey string) (storage.ObjectInfo, error) {
|
||||
if strings.TrimSpace(objectKey) == "" {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("对象键不能为空")
|
||||
}
|
||||
|
||||
result, err := c.client.GetObjectMeta(ctx, &oss.GetObjectMetaRequest{
|
||||
Bucket: oss.Ptr(c.bucket),
|
||||
Key: oss.Ptr(objectKey),
|
||||
})
|
||||
if err != nil {
|
||||
var serviceError *oss.ServiceError
|
||||
if errors.As(err, &serviceError) && serviceError.Code == "NoSuchKey" {
|
||||
return storage.ObjectInfo{}, storage.ErrObjectNotFound
|
||||
}
|
||||
return storage.ObjectInfo{}, fmt.Errorf("查询对象元数据: %w", err)
|
||||
}
|
||||
if result == nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("对象元数据为空")
|
||||
}
|
||||
if result.ContentLength < 0 {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("对象大小无效: %d", result.ContentLength)
|
||||
}
|
||||
if result.ETag == nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("对象 ETag 缺失")
|
||||
}
|
||||
etag := strings.TrimSpace(strings.Trim(strings.TrimSpace(*result.ETag), "\""))
|
||||
if etag == "" {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("对象 ETag 无效")
|
||||
}
|
||||
if result.LastModified == nil || result.LastModified.IsZero() {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("对象最后修改时间缺失")
|
||||
}
|
||||
|
||||
return storage.ObjectInfo{
|
||||
Size: result.ContentLength,
|
||||
StorageETag: etag,
|
||||
LastModified: *result.LastModified,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Delete(ctx context.Context, objectKey string) error {
|
||||
if strings.TrimSpace(objectKey) == "" {
|
||||
return fmt.Errorf("对象键不能为空")
|
||||
}
|
||||
|
||||
_, err := c.client.DeleteObject(ctx, &oss.DeleteObjectRequest{
|
||||
Bucket: oss.Ptr(c.bucket),
|
||||
Key: oss.Ptr(objectKey),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除对象: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Status(ctx context.Context) (storage.RuntimeStatus, error) {
|
||||
_, err := c.client.ListObjectsV2(ctx, &oss.ListObjectsV2Request{Bucket: oss.Ptr(c.bucket), MaxKeys: 1})
|
||||
if err != nil {
|
||||
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container()}, fmt.Errorf("检查 OSS 存储状态: %w", err)
|
||||
}
|
||||
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Writable: true}, nil
|
||||
}
|
||||
37
internal/storage/aliyun/upload.go
Normal file
37
internal/storage/aliyun/upload.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
)
|
||||
|
||||
func (c *Client) PrepareUpload(ctx context.Context, request storage.UploadRequest) (storage.UploadInstruction, error) {
|
||||
if strings.TrimSpace(request.ObjectKey) == "" {
|
||||
return storage.UploadInstruction{}, fmt.Errorf("对象键不能为空")
|
||||
}
|
||||
if strings.TrimSpace(request.ContentType) == "" {
|
||||
return storage.UploadInstruction{}, fmt.Errorf("内容类型不能为空")
|
||||
}
|
||||
|
||||
result, err := c.client.Presign(ctx, &oss.PutObjectRequest{
|
||||
Bucket: oss.Ptr(c.bucket),
|
||||
Key: oss.Ptr(request.ObjectKey),
|
||||
ContentType: oss.Ptr(request.ContentType),
|
||||
ForbidOverwrite: oss.Ptr("true"),
|
||||
}, oss.PresignExpires(time.Duration(c.presignTTL)*time.Second))
|
||||
if err != nil {
|
||||
return storage.UploadInstruction{}, fmt.Errorf("生成上传预签名: %w", err)
|
||||
}
|
||||
|
||||
return storage.UploadInstruction{
|
||||
Method: result.Method,
|
||||
URL: result.URL,
|
||||
Headers: result.SignedHeaders,
|
||||
ExpiresAt: result.Expiration,
|
||||
}, nil
|
||||
}
|
||||
77
internal/storage/backend.go
Normal file
77
internal/storage/backend.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientSpace = errors.New("文件存储空间不足")
|
||||
ErrUploadLocked = errors.New("文件正在上传")
|
||||
ErrObjectNotFound = errors.New("文件对象不存在")
|
||||
)
|
||||
|
||||
type UploadRequest struct {
|
||||
FileID string
|
||||
ObjectKey string
|
||||
ContentType string
|
||||
ExpectedSize int64
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type UploadInstruction struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type ByteRange struct {
|
||||
Start int64
|
||||
End int64
|
||||
}
|
||||
|
||||
func (r ByteRange) Length() int64 {
|
||||
return r.End - r.Start + 1
|
||||
}
|
||||
|
||||
type ObjectInfo struct {
|
||||
Size int64
|
||||
StorageETag string
|
||||
LastModified time.Time
|
||||
}
|
||||
|
||||
type DiskStatus struct {
|
||||
Path string `json:"path"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
FreeBytes uint64 `json:"free_bytes"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
MinimumFreeBytes uint64 `json:"minimum_free_bytes"`
|
||||
Level string `json:"level"`
|
||||
}
|
||||
|
||||
type RuntimeStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
Container string `json:"container"`
|
||||
Writable bool `json:"writable"`
|
||||
Disk *DiskStatus `json:"disk,omitempty"`
|
||||
}
|
||||
|
||||
type Backend interface {
|
||||
Provider() string
|
||||
Container() string
|
||||
PrepareUpload(context.Context, UploadRequest) (UploadInstruction, error)
|
||||
Open(context.Context, string, *ByteRange) (io.ReadCloser, error)
|
||||
Stat(context.Context, string) (ObjectInfo, error)
|
||||
Delete(context.Context, string) error
|
||||
Status(context.Context) (RuntimeStatus, error)
|
||||
}
|
||||
|
||||
type UploadReceiver interface {
|
||||
AcquireUpload(context.Context, string) (release func() error, err error)
|
||||
ReceiveUpload(context.Context, UploadRequest, io.Reader) (ObjectInfo, error)
|
||||
DiscardUpload(context.Context, string) error
|
||||
}
|
||||
21
internal/storage/factory/factory.go
Normal file
21
internal/storage/factory/factory.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.apinb.com/ops/files/internal/config"
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
"git.apinb.com/ops/files/internal/storage/aliyun"
|
||||
"git.apinb.com/ops/files/internal/storage/local"
|
||||
)
|
||||
|
||||
func New(storageConfig config.StorageConf) (storage.Backend, error) {
|
||||
switch storageConfig.Provider {
|
||||
case "local":
|
||||
return local.New(storageConfig)
|
||||
case "aliyun":
|
||||
return aliyun.New(storageConfig.Aliyun, storageConfig.AccessTTLSeconds)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的文件存储类型: %s", storageConfig.Provider)
|
||||
}
|
||||
}
|
||||
46
internal/storage/local/capacity.go
Normal file
46
internal/storage/local/capacity.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
)
|
||||
|
||||
func (c *Client) diskStatus() (*storage.DiskStatus, error) {
|
||||
usage, err := disk.Usage(c.rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取本地存储磁盘容量: %w", err)
|
||||
}
|
||||
level := "normal"
|
||||
if usage.Free < c.minimumFreeBytes {
|
||||
level = "critical"
|
||||
} else if c.minimumFreeBytes <= math.MaxUint64/2 && usage.Free < c.minimumFreeBytes*2 {
|
||||
level = "warning"
|
||||
}
|
||||
return &storage.DiskStatus{
|
||||
Path: c.rootPath,
|
||||
TotalBytes: usage.Total,
|
||||
UsedBytes: usage.Used,
|
||||
FreeBytes: usage.Free,
|
||||
UsedPercent: usage.UsedPercent,
|
||||
MinimumFreeBytes: c.minimumFreeBytes,
|
||||
Level: level,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureCapacity(expectedSize int64) error {
|
||||
if expectedSize < 0 {
|
||||
return fmt.Errorf("文件大小不能为负数")
|
||||
}
|
||||
diskStatus, err := c.diskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requested := uint64(expectedSize)
|
||||
if requested > math.MaxUint64-c.minimumFreeBytes || diskStatus.FreeBytes < requested+c.minimumFreeBytes {
|
||||
return storage.ErrInsufficientSpace
|
||||
}
|
||||
return nil
|
||||
}
|
||||
60
internal/storage/local/client.go
Normal file
60
internal/storage/local/client.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/ops/files/internal/config"
|
||||
)
|
||||
|
||||
const containerName = "local"
|
||||
|
||||
type Client struct {
|
||||
rootPath string
|
||||
objectsPath string
|
||||
stagingPath string
|
||||
locksPath string
|
||||
externalBaseURL string
|
||||
signingSecret string
|
||||
accessTTLSeconds int64
|
||||
minimumFreeBytes uint64
|
||||
}
|
||||
|
||||
func New(storageConfig config.StorageConf) (*Client, error) {
|
||||
rootPath, err := filepath.Abs(storageConfig.Local.RootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析本地存储根目录: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(rootPath, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("创建本地存储根目录: %w", err)
|
||||
}
|
||||
rootPath, err = filepath.EvalSymlinks(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析本地存储真实路径: %w", err)
|
||||
}
|
||||
client := &Client{
|
||||
rootPath: filepath.Clean(rootPath),
|
||||
objectsPath: filepath.Join(rootPath, "objects"),
|
||||
stagingPath: filepath.Join(rootPath, ".staging"),
|
||||
locksPath: filepath.Join(rootPath, ".locks"),
|
||||
externalBaseURL: strings.TrimRight(storageConfig.ExternalBaseURL, "/"),
|
||||
signingSecret: storageConfig.SigningSecret,
|
||||
accessTTLSeconds: storageConfig.AccessTTLSeconds,
|
||||
minimumFreeBytes: storageConfig.Local.MinimumFreeSpaceMB * 1024 * 1024,
|
||||
}
|
||||
for _, directory := range []string{client.objectsPath, client.stagingPath, client.locksPath} {
|
||||
if err := client.ensureDirectory(directory); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := client.Status(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) Provider() string { return "local" }
|
||||
|
||||
func (c *Client) Container() string { return containerName }
|
||||
152
internal/storage/local/object.go
Normal file
152
internal/storage/local/object.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
)
|
||||
|
||||
func (c *Client) Open(ctx context.Context, objectKey string, byteRange *storage.ByteRange) (io.ReadCloser, error) {
|
||||
path, err := c.resolveObjectPath(objectKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, storage.ErrObjectNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("打开本地文件: %w", err)
|
||||
}
|
||||
if byteRange == nil {
|
||||
return &contextReadCloser{ctx: ctx, reader: file, closer: file}, nil
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if byteRange.Start < 0 || byteRange.End < byteRange.Start || byteRange.End >= info.Size() {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("文件读取范围无效")
|
||||
}
|
||||
if _, err := file.Seek(byteRange.Start, io.SeekStart); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &contextReadCloser{ctx: ctx, reader: io.LimitReader(file, byteRange.Length()), closer: file}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Stat(ctx context.Context, objectKey string) (storage.ObjectInfo, error) {
|
||||
path, err := c.resolveObjectPath(objectKey)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return storage.ObjectInfo{}, storage.ErrObjectNotFound
|
||||
}
|
||||
return storage.ObjectInfo{}, fmt.Errorf("打开本地文件: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, err
|
||||
}
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, &contextReader{ctx: ctx, reader: file}); err != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("计算本地文件摘要: %w", err)
|
||||
}
|
||||
return storage.ObjectInfo{
|
||||
Size: info.Size(),
|
||||
StorageETag: "sha256:" + hex.EncodeToString(hasher.Sum(nil)),
|
||||
LastModified: info.ModTime().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Delete(_ context.Context, objectKey string) error {
|
||||
path, err := c.resolveObjectPath(objectKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除本地文件: %w", err)
|
||||
}
|
||||
c.removeEmptyParents(filepath.Dir(path))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) removeEmptyParents(directory string) {
|
||||
boundary := filepath.Clean(c.objectsPath)
|
||||
for current := filepath.Clean(directory); current != boundary; current = filepath.Dir(current) {
|
||||
relative, err := filepath.Rel(boundary, current)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return
|
||||
}
|
||||
if err := os.Remove(current); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Status(_ context.Context) (storage.RuntimeStatus, error) {
|
||||
diskStatus, err := c.diskStatus()
|
||||
if err != nil {
|
||||
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container()}, err
|
||||
}
|
||||
probe, err := os.CreateTemp(c.stagingPath, ".write-probe-")
|
||||
if err != nil {
|
||||
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, fmt.Errorf("本地存储目录不可写: %w", err)
|
||||
}
|
||||
probePath := probe.Name()
|
||||
removeProbe := func() { _ = os.Remove(probePath) }
|
||||
defer removeProbe()
|
||||
if err := probe.Chmod(0o640); err != nil {
|
||||
_ = probe.Close()
|
||||
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, err
|
||||
}
|
||||
if _, err := probe.Write([]byte("files-storage-probe")); err != nil {
|
||||
_ = probe.Close()
|
||||
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, err
|
||||
}
|
||||
if err := probe.Sync(); err != nil {
|
||||
_ = probe.Close()
|
||||
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, err
|
||||
}
|
||||
if err := probe.Close(); err != nil {
|
||||
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, err
|
||||
}
|
||||
if err := os.Remove(probePath); err != nil {
|
||||
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, err
|
||||
}
|
||||
return storage.RuntimeStatus{
|
||||
Provider: c.Provider(),
|
||||
Container: c.Container(),
|
||||
Writable: diskStatus.Level != "critical",
|
||||
Disk: diskStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type contextReadCloser struct {
|
||||
ctx context.Context
|
||||
reader io.Reader
|
||||
closer io.Closer
|
||||
}
|
||||
|
||||
func (r *contextReadCloser) Read(buffer []byte) (int, error) {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.reader.Read(buffer)
|
||||
}
|
||||
|
||||
func (r *contextReadCloser) Close() error { return r.closer.Close() }
|
||||
79
internal/storage/local/path.go
Normal file
79
internal/storage/local/path.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (c *Client) resolveObjectPath(objectKey string) (string, error) {
|
||||
if strings.TrimSpace(objectKey) == "" || filepath.IsAbs(objectKey) || strings.Contains(objectKey, "\\") {
|
||||
return "", fmt.Errorf("对象键不是安全的相对路径")
|
||||
}
|
||||
for _, segment := range strings.Split(objectKey, "/") {
|
||||
if segment == "" || segment == "." || segment == ".." {
|
||||
return "", fmt.Errorf("对象键包含无效路径段")
|
||||
}
|
||||
}
|
||||
target := filepath.Join(c.objectsPath, filepath.FromSlash(objectKey))
|
||||
relative, err := filepath.Rel(c.objectsPath, target)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("对象路径超出存储目录")
|
||||
}
|
||||
if err := c.ensureNoSymlink(filepath.Dir(target), c.objectsPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureDirectory(directory string) error {
|
||||
if err := os.MkdirAll(directory, 0o750); err != nil {
|
||||
return fmt.Errorf("创建本地存储目录 %s: %w", directory, err)
|
||||
}
|
||||
if err := c.ensureNoSymlink(directory, c.rootPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(directory, 0o750)
|
||||
}
|
||||
|
||||
func (c *Client) ensureNoSymlink(target, boundary string) error {
|
||||
target = filepath.Clean(target)
|
||||
boundary = filepath.Clean(boundary)
|
||||
relative, err := filepath.Rel(boundary, target)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("路径超出本地存储边界")
|
||||
}
|
||||
current := boundary
|
||||
if relative == "." {
|
||||
return rejectSymlink(current)
|
||||
}
|
||||
if err := rejectSymlink(current); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, segment := range strings.Split(relative, string(filepath.Separator)) {
|
||||
current = filepath.Join(current, segment)
|
||||
info, statErr := os.Lstat(current)
|
||||
if os.IsNotExist(statErr) {
|
||||
continue
|
||||
}
|
||||
if statErr != nil {
|
||||
return fmt.Errorf("检查路径 %s: %w", current, statErr)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("本地存储路径不允许符号链接: %s", current)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectSymlink(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("本地存储路径不允许符号链接: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
190
internal/storage/local/upload.go
Normal file
190
internal/storage/local/upload.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/ops/files/internal/signing"
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
)
|
||||
|
||||
func (c *Client) PrepareUpload(_ context.Context, request storage.UploadRequest) (storage.UploadInstruction, error) {
|
||||
if err := c.ensureCapacity(request.ExpectedSize); err != nil {
|
||||
return storage.UploadInstruction{}, err
|
||||
}
|
||||
signature := signing.SignUpload(c.signingSecret, request)
|
||||
return storage.UploadInstruction{
|
||||
Method: "PUT",
|
||||
URL: fmt.Sprintf("%s/v1/local/uploads/%s?expires=%d&signature=%s",
|
||||
c.externalBaseURL, request.FileID, request.ExpiresAt.Unix(), signature),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": request.ContentType,
|
||||
"Content-Length": fmt.Sprintf("%d", request.ExpectedSize),
|
||||
},
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) AcquireUpload(ctx context.Context, fileID string) (func() error, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validFileID(fileID) {
|
||||
return nil, fmt.Errorf("文件标识无效")
|
||||
}
|
||||
lockPath := filepath.Join(c.locksPath, fileID+".lock")
|
||||
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
return nil, storage.ErrUploadLocked
|
||||
}
|
||||
return nil, fmt.Errorf("获取上传锁: %w", err)
|
||||
}
|
||||
return func() error {
|
||||
closeErr := lockFile.Close()
|
||||
removeErr := os.Remove(lockPath)
|
||||
if os.IsNotExist(removeErr) {
|
||||
removeErr = nil
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return removeErr
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReceiveUpload(ctx context.Context, request storage.UploadRequest, source io.Reader) (storage.ObjectInfo, error) {
|
||||
if err := c.ensureCapacity(request.ExpectedSize); err != nil {
|
||||
return storage.ObjectInfo{}, err
|
||||
}
|
||||
targetPath, err := c.resolveObjectPath(request.ObjectKey)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, err
|
||||
}
|
||||
if err := c.ensureDirectory(filepath.Dir(targetPath)); err != nil {
|
||||
return storage.ObjectInfo{}, err
|
||||
}
|
||||
if existing, statErr := c.Stat(ctx, request.ObjectKey); statErr == nil {
|
||||
if existing.Size != request.ExpectedSize {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("已存在文件的大小与上传请求不一致")
|
||||
}
|
||||
return existing, nil
|
||||
} else if !errors.Is(statErr, storage.ErrObjectNotFound) {
|
||||
return storage.ObjectInfo{}, statErr
|
||||
}
|
||||
nonce, err := signing.UploadNonce(c.signingSecret, request)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, err
|
||||
}
|
||||
stagingPath := filepath.Join(c.stagingPath, request.FileID+"."+nonce+".part")
|
||||
staging, err := os.OpenFile(stagingPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("创建上传暂存文件: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
_ = staging.Close()
|
||||
if !committed {
|
||||
_ = os.Remove(stagingPath)
|
||||
}
|
||||
}()
|
||||
|
||||
hasher := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(staging, hasher), io.LimitReader(&contextReader{ctx: ctx, reader: source}, request.ExpectedSize+1))
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("写入上传暂存文件: %w", err)
|
||||
}
|
||||
if written != request.ExpectedSize {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("上传文件大小不匹配: 期望 %d,实际 %d", request.ExpectedSize, written)
|
||||
}
|
||||
if err := staging.Sync(); err != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("同步上传暂存文件: %w", err)
|
||||
}
|
||||
if err := staging.Close(); err != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("关闭上传暂存文件: %w", err)
|
||||
}
|
||||
if err := os.Link(stagingPath, targetPath); err != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("提交上传文件: %w", err)
|
||||
}
|
||||
if err := os.Chmod(targetPath, 0o640); err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
return storage.ObjectInfo{}, fmt.Errorf("设置上传文件权限: %w", err)
|
||||
}
|
||||
if err := os.Remove(stagingPath); err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
return storage.ObjectInfo{}, fmt.Errorf("清理上传暂存文件: %w", err)
|
||||
}
|
||||
if err := syncDirectory(filepath.Dir(targetPath)); err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
return storage.ObjectInfo{}, err
|
||||
}
|
||||
committed = true
|
||||
return storage.ObjectInfo{
|
||||
Size: written,
|
||||
StorageETag: "sha256:" + hex.EncodeToString(hasher.Sum(nil)),
|
||||
LastModified: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) DiscardUpload(_ context.Context, fileID string) error {
|
||||
if !validFileID(fileID) {
|
||||
return fmt.Errorf("文件标识无效")
|
||||
}
|
||||
entries, err := os.ReadDir(c.stagingPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prefix := fileID + "."
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) && strings.HasSuffix(entry.Name(), ".part") {
|
||||
if err := os.Remove(filepath.Join(c.stagingPath, entry.Name())); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type contextReader struct {
|
||||
ctx context.Context
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (r *contextReader) Read(buffer []byte) (int, error) {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.reader.Read(buffer)
|
||||
}
|
||||
|
||||
func validFileID(fileID string) bool {
|
||||
if fileID == "" {
|
||||
return false
|
||||
}
|
||||
for _, character := range fileID {
|
||||
if (character < '0' || character > '9') && (character < 'A' || character > 'Z') && (character < 'a' || character > 'z') && character != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func syncDirectory(path string) error {
|
||||
directory, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开目录进行同步: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
if err := directory.Sync(); err != nil {
|
||||
return fmt.Errorf("同步文件目录: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
)
|
||||
|
||||
type ObjectInfo struct {
|
||||
Size int64
|
||||
ETag string
|
||||
LastModified time.Time
|
||||
}
|
||||
|
||||
func (c *Client) Stat(ctx context.Context, objectKey string) (ObjectInfo, error) {
|
||||
if strings.TrimSpace(objectKey) == "" {
|
||||
return ObjectInfo{}, fmt.Errorf("对象键不能为空")
|
||||
}
|
||||
|
||||
result, err := c.client.GetObjectMeta(ctx, &oss.GetObjectMetaRequest{
|
||||
Bucket: oss.Ptr(c.bucket),
|
||||
Key: oss.Ptr(objectKey),
|
||||
})
|
||||
if err != nil {
|
||||
return ObjectInfo{}, fmt.Errorf("查询对象元数据: %w", err)
|
||||
}
|
||||
if result == nil {
|
||||
return ObjectInfo{}, fmt.Errorf("对象元数据为空")
|
||||
}
|
||||
if result.ContentLength < 0 {
|
||||
return ObjectInfo{}, fmt.Errorf("对象大小无效: %d", result.ContentLength)
|
||||
}
|
||||
if result.ETag == nil {
|
||||
return ObjectInfo{}, fmt.Errorf("对象 ETag 缺失")
|
||||
}
|
||||
etag := strings.TrimSpace(strings.Trim(strings.TrimSpace(*result.ETag), "\""))
|
||||
if etag == "" {
|
||||
return ObjectInfo{}, fmt.Errorf("对象 ETag 无效")
|
||||
}
|
||||
if result.LastModified == nil || result.LastModified.IsZero() {
|
||||
return ObjectInfo{}, fmt.Errorf("对象最后修改时间缺失")
|
||||
}
|
||||
|
||||
return ObjectInfo{
|
||||
Size: result.ContentLength,
|
||||
ETag: etag,
|
||||
LastModified: *result.LastModified,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Delete(ctx context.Context, objectKey string) error {
|
||||
if strings.TrimSpace(objectKey) == "" {
|
||||
return fmt.Errorf("对象键不能为空")
|
||||
}
|
||||
|
||||
_, err := c.client.DeleteObject(ctx, &oss.DeleteObjectRequest{
|
||||
Bucket: oss.Ptr(c.bucket),
|
||||
Key: oss.Ptr(objectKey),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除对象: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) PublicURL(objectKey string) string {
|
||||
return strings.TrimRight(c.publicBaseURL, "/") + "/" + strings.TrimLeft(objectKey, "/")
|
||||
}
|
||||
|
||||
func (c *Client) Bucket() string {
|
||||
return c.bucket
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
)
|
||||
|
||||
type UploadInstruction struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (c *Client) PresignPut(ctx context.Context, objectKey, contentType string) (UploadInstruction, error) {
|
||||
if strings.TrimSpace(objectKey) == "" {
|
||||
return UploadInstruction{}, fmt.Errorf("对象键不能为空")
|
||||
}
|
||||
if strings.TrimSpace(contentType) == "" {
|
||||
return UploadInstruction{}, fmt.Errorf("内容类型不能为空")
|
||||
}
|
||||
|
||||
result, err := c.client.Presign(ctx, &oss.PutObjectRequest{
|
||||
Bucket: oss.Ptr(c.bucket),
|
||||
Key: oss.Ptr(objectKey),
|
||||
ContentType: oss.Ptr(contentType),
|
||||
ForbidOverwrite: oss.Ptr("true"),
|
||||
}, oss.PresignExpires(c.presignTTL))
|
||||
if err != nil {
|
||||
return UploadInstruction{}, fmt.Errorf("生成上传预签名: %w", err)
|
||||
}
|
||||
|
||||
return UploadInstruction{
|
||||
Method: result.Method,
|
||||
URL: result.URL,
|
||||
Headers: result.SignedHeaders,
|
||||
ExpiresAt: result.Expiration,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user