feat(ops): add object storage management and site-mapping CSV import/export
Ship the Ops backstage 对象存储 page (browse / upload / move / delete / folder ops) backed by a new object-storage service + handler, with the shared storage client extended with List/Stat/Copy/Usage/DownloadURL so both MinIO and Aliyun providers cover the new surface. Add ForcePathStyle to the object-storage config and treat r2/s3/custom_s3 as external providers so Cloudflare R2 and other S3-compatibles can share the MinIO client path without spinning up the bundled MinIO. Also add CSV import/export + template download to the site-domain mappings page, raise gin MaxMultipartMemory to 8 MiB for the new multipart endpoints, register Ops swagger routes, and extend the descriptions-parity test to cover the ops router. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -296,6 +298,180 @@ func (c *aliyunClient) PublicURL(objectKey string) (string, error) {
|
||||
return buildPublicURL(c.cfg, objectKey, true)
|
||||
}
|
||||
|
||||
func (c *aliyunClient) PublicReadableURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
publicRead, err := c.isPublicReadable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !publicRead {
|
||||
return "", ErrObjectNotPublic
|
||||
}
|
||||
return buildPublicURL(c.cfg, objectKey, true)
|
||||
}
|
||||
|
||||
func (c *aliyunClient) DownloadURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.signedURL(objectKey, oss.ResponseContentDisposition(downloadContentDisposition(objectKey)))
|
||||
}
|
||||
|
||||
func (c *aliyunClient) Usage(ctx context.Context) (*UsageInfo, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stat, err := c.client.GetBucketStat(c.bucket)
|
||||
if err != nil {
|
||||
c.logError(ctx, "aliyun get bucket stat failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("get aliyun bucket stat: %w", err)
|
||||
}
|
||||
return &UsageInfo{
|
||||
Available: true,
|
||||
Provider: "aliyun",
|
||||
StorageSize: stat.Storage,
|
||||
ObjectCount: stat.ObjectCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *aliyunClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket, err := c.client.Bucket(c.bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get aliyun bucket: %w", err)
|
||||
}
|
||||
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
delimiter := strings.TrimSpace(in.Delimiter)
|
||||
if delimiter == "" && !in.Recursive {
|
||||
delimiter = "/"
|
||||
}
|
||||
options := []oss.Option{
|
||||
oss.Prefix(normalizeObjectKeyPath(in.Prefix)),
|
||||
oss.MaxKeys(limit),
|
||||
}
|
||||
if delimiter != "" {
|
||||
options = append(options, oss.Delimiter(delimiter))
|
||||
}
|
||||
if token := strings.TrimSpace(in.ContinuationToken); token != "" {
|
||||
options = append(options, oss.ContinuationToken(token))
|
||||
}
|
||||
if startAfter := normalizeObjectKeyPath(in.StartAfter); startAfter != "" {
|
||||
options = append(options, oss.StartAfter(startAfter))
|
||||
}
|
||||
|
||||
result, err := bucket.ListObjectsV2(options...)
|
||||
if err != nil {
|
||||
c.logError(ctx, "aliyun list objects failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("prefix", in.Prefix),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("list aliyun objects: %w", err)
|
||||
}
|
||||
|
||||
out := &ListResult{
|
||||
Objects: make([]ObjectInfo, 0, len(result.Objects)),
|
||||
CommonPrefixes: append([]string(nil), result.CommonPrefixes...),
|
||||
ContinuationToken: result.ContinuationToken,
|
||||
NextContinuationToken: result.NextContinuationToken,
|
||||
IsTruncated: result.IsTruncated,
|
||||
Prefix: result.Prefix,
|
||||
Delimiter: result.Delimiter,
|
||||
}
|
||||
for _, item := range result.Objects {
|
||||
out.Objects = append(out.Objects, ObjectInfo{
|
||||
Key: item.Key,
|
||||
Size: item.Size,
|
||||
ETag: strings.Trim(item.ETag, `"`),
|
||||
LastModified: item.LastModified,
|
||||
StorageClass: item.StorageClass,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *aliyunClient) Stat(ctx context.Context, objectKey string) (*ObjectInfo, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objectKey = normalizeObjectKeyPath(objectKey)
|
||||
if objectKey == "" {
|
||||
return nil, fmt.Errorf("object key is required")
|
||||
}
|
||||
bucket, err := c.client.Bucket(c.bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get aliyun bucket: %w", err)
|
||||
}
|
||||
header, err := bucket.GetObjectDetailedMeta(objectKey)
|
||||
if err != nil {
|
||||
c.logError(ctx, "aliyun stat object failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("object_key", objectKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
if isAliyunObjectNotFound(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey)
|
||||
}
|
||||
return nil, fmt.Errorf("stat aliyun object: %w", err)
|
||||
}
|
||||
return aliyunHeaderToObjectInfo(objectKey, header), nil
|
||||
}
|
||||
|
||||
func (c *aliyunClient) Copy(ctx context.Context, sourceKey, destinationKey string) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceKey = normalizeObjectKeyPath(sourceKey)
|
||||
destinationKey = normalizeObjectKeyPath(destinationKey)
|
||||
if sourceKey == "" || destinationKey == "" {
|
||||
return fmt.Errorf("source and destination object keys are required")
|
||||
}
|
||||
if sourceKey == destinationKey {
|
||||
return nil
|
||||
}
|
||||
bucket, err := c.client.Bucket(c.bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get aliyun bucket: %w", err)
|
||||
}
|
||||
if _, err := bucket.CopyObject(sourceKey, destinationKey); err != nil {
|
||||
c.logError(ctx, "aliyun copy object failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("source_key", sourceKey),
|
||||
zap.String("destination_key", destinationKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
if isAliyunObjectNotFound(err) {
|
||||
return fmt.Errorf("%w: %s", ErrObjectNotFound, sourceKey)
|
||||
}
|
||||
return fmt.Errorf("copy aliyun object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func aliyunHeaderToObjectInfo(objectKey string, header http.Header) *ObjectInfo {
|
||||
size, _ := strconv.ParseInt(strings.TrimSpace(header.Get(oss.HTTPHeaderContentLength)), 10, 64)
|
||||
lastModified, _ := http.ParseTime(header.Get(oss.HTTPHeaderLastModified))
|
||||
return &ObjectInfo{
|
||||
Key: objectKey,
|
||||
Size: size,
|
||||
ETag: strings.Trim(header.Get(oss.HTTPHeaderEtag), `"`),
|
||||
LastModified: lastModified,
|
||||
ContentType: header.Get(oss.HTTPHeaderContentType),
|
||||
StorageClass: header.Get(oss.HTTPHeaderOssStorageClass),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *aliyunClient) isPublicReadable() (bool, error) {
|
||||
objectACL := strings.ToLower(strings.TrimSpace(c.cfg.ObjectACL))
|
||||
if isPublicReadACL(objectACL) {
|
||||
@@ -317,7 +493,7 @@ func (c *aliyunClient) isPublicReadable() (bool, error) {
|
||||
return isPublicReadACL(result.ACL), nil
|
||||
}
|
||||
|
||||
func (c *aliyunClient) signedURL(objectKey string) (string, error) {
|
||||
func (c *aliyunClient) signedURL(objectKey string, options ...oss.Option) (string, error) {
|
||||
objectKey = normalizeObjectKeyPath(objectKey)
|
||||
if objectKey == "" {
|
||||
return "", fmt.Errorf("object key is required")
|
||||
@@ -330,7 +506,7 @@ func (c *aliyunClient) signedURL(objectKey string) (string, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
return bucket.SignURL(objectKey, oss.HTTPGet, int64(ttl.Seconds()))
|
||||
return bucket.SignURL(objectKey, oss.HTTPGet, int64(ttl.Seconds()), options...)
|
||||
}
|
||||
|
||||
func isPublicReadACL(acl string) bool {
|
||||
|
||||
Reference in New Issue
Block a user