fix: 初验针对修改
This commit is contained in:
154
internal/logic/files/access.go
Normal file
154
internal/logic/files/access.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/ops/files/internal/auth"
|
||||
"git.apinb.com/ops/files/internal/config"
|
||||
fileerrors "git.apinb.com/ops/files/internal/errors"
|
||||
"git.apinb.com/ops/files/internal/impl"
|
||||
"git.apinb.com/ops/files/internal/models"
|
||||
"git.apinb.com/ops/files/internal/signing"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Access(ctx *gin.Context) {
|
||||
fileObject, ok := loadOwnedFile(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if fileObject.Status != models.FileStatusReady {
|
||||
infra.Response.Error(ctx, fileerrors.ErrFileNotFound)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, newAccessInstruction(fileObject, normalizeDisposition(ctx.Query("disposition"), "inline")))
|
||||
}
|
||||
|
||||
func BatchAccess(ctx *gin.Context) {
|
||||
actor, ok := auth.FromContext(ctx)
|
||||
if !ok || actor.Type != auth.ActorTypeService {
|
||||
infra.Response.Error(ctx, fileerrors.ErrUnauthorizedOperation)
|
||||
return
|
||||
}
|
||||
var request BatchAccessRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil || len(request.FileIDs) == 0 || len(request.FileIDs) > 100 {
|
||||
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
|
||||
return
|
||||
}
|
||||
fileIDs := uniqueFileIDs(request.FileIDs)
|
||||
if len(fileIDs) != len(request.FileIDs) {
|
||||
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
|
||||
return
|
||||
}
|
||||
var fileObjects []models.FileObject
|
||||
if err := impl.DBService.Where(
|
||||
"identity IN ? AND owner_type = ? AND owner_identity = ? AND status = ?",
|
||||
fileIDs, auth.ActorTypeService, actor.Identity, models.FileStatusReady,
|
||||
).Find(&fileObjects).Error; err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
|
||||
return
|
||||
}
|
||||
if len(fileObjects) != len(fileIDs) {
|
||||
infra.Response.Error(ctx, fileerrors.ErrFileNotFound)
|
||||
return
|
||||
}
|
||||
byIdentity := make(map[string]models.FileObject, len(fileObjects))
|
||||
for _, fileObject := range fileObjects {
|
||||
byIdentity[fileObject.Identity] = fileObject
|
||||
}
|
||||
disposition := normalizeDisposition(request.Disposition, "inline")
|
||||
response := make([]AccessInstruction, 0, len(fileIDs))
|
||||
for _, fileID := range fileIDs {
|
||||
response = append(response, newAccessInstruction(byIdentity[fileID], disposition))
|
||||
}
|
||||
infra.Response.Success(ctx, response)
|
||||
}
|
||||
|
||||
func SignedAccess(ctx *gin.Context) {
|
||||
fileID := strings.TrimSpace(ctx.Param("identity"))
|
||||
disposition := normalizeDisposition(ctx.Query("disposition"), "inline")
|
||||
expiresUnix, err := strconv.ParseInt(ctx.Query("expires"), 10, 64)
|
||||
if err != nil || !signing.VerifyAccess(
|
||||
config.Spec.Storage.SigningSecret, fileID, time.Unix(expiresUnix, 0), disposition,
|
||||
ctx.Query("signature"), time.Now(),
|
||||
) {
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"message": "文件访问签名无效或已过期"})
|
||||
return
|
||||
}
|
||||
fileObject, ok := loadReadyFile(ctx, fileID, "")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
streamFile(ctx, fileObject, disposition)
|
||||
}
|
||||
|
||||
func PublicAccess(ctx *gin.Context) {
|
||||
fileObject, ok := loadReadyFile(ctx, strings.TrimSpace(ctx.Param("identity")), models.FileVisibilityPublic)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
streamFile(ctx, fileObject, normalizeDisposition(ctx.Query("disposition"), "inline"))
|
||||
}
|
||||
|
||||
func newAccessInstruction(fileObject models.FileObject, disposition string) AccessInstruction {
|
||||
baseURL := strings.TrimRight(config.Spec.Storage.ExternalBaseURL, "/")
|
||||
result := AccessInstruction{
|
||||
FileID: fileObject.Identity, Filename: fileObject.OriginalName, ContentType: fileObject.ContentType,
|
||||
Size: fileObject.ActualSize, Disposition: disposition,
|
||||
}
|
||||
if fileObject.Visibility == models.FileVisibilityPublic {
|
||||
result.URL = fmt.Sprintf("%s/v1/public/files/%s/content?disposition=%s", baseURL, fileObject.Identity, disposition)
|
||||
return result
|
||||
}
|
||||
expiresAt := time.Now().Add(time.Duration(config.Spec.Storage.AccessTTLSeconds) * time.Second).UTC()
|
||||
signature := signing.SignAccess(config.Spec.Storage.SigningSecret, fileObject.Identity, expiresAt, disposition)
|
||||
result.ExpiresAt = expiresAt
|
||||
result.URL = fmt.Sprintf("%s/v1/access/%s/content?expires=%d&disposition=%s&signature=%s",
|
||||
baseURL, fileObject.Identity, expiresAt.Unix(), disposition, signature)
|
||||
return result
|
||||
}
|
||||
|
||||
func loadReadyFile(ctx *gin.Context, fileID string, visibility models.FileVisibility) (models.FileObject, bool) {
|
||||
if fileID == "" {
|
||||
ctx.Status(http.StatusNotFound)
|
||||
return models.FileObject{}, false
|
||||
}
|
||||
query := impl.DBService.Where("identity = ? AND status = ?", fileID, models.FileStatusReady)
|
||||
if visibility != "" {
|
||||
query = query.Where("visibility = ?", visibility)
|
||||
}
|
||||
var fileObject models.FileObject
|
||||
if err := query.First(&fileObject).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
ctx.Status(http.StatusNotFound)
|
||||
} else {
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
}
|
||||
return models.FileObject{}, false
|
||||
}
|
||||
return fileObject, true
|
||||
}
|
||||
|
||||
func uniqueFileIDs(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -7,10 +7,19 @@ import (
|
||||
fileerrors "git.apinb.com/ops/files/internal/errors"
|
||||
"git.apinb.com/ops/files/internal/impl"
|
||||
"git.apinb.com/ops/files/internal/models"
|
||||
"git.apinb.com/ops/files/internal/runtimeinfo"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CompleteUpload(ctx *gin.Context) {
|
||||
success := false
|
||||
defer func() {
|
||||
if success {
|
||||
runtimeinfo.RecordUploadSuccess()
|
||||
} else {
|
||||
runtimeinfo.RecordUploadFailure()
|
||||
}
|
||||
}()
|
||||
fileObject, ok := loadOwnedFile(ctx)
|
||||
if !ok {
|
||||
return
|
||||
@@ -27,7 +36,7 @@ func CompleteUpload(ctx *gin.Context) {
|
||||
|
||||
objectInfo, err := impl.StorageService.Stat(ctx.Request.Context(), fileObject.ObjectKey)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
|
||||
infra.Response.Error(ctx, fileerrors.ErrStorageOperation)
|
||||
return
|
||||
}
|
||||
if objectInfo.Size != fileObject.ExpectedSize {
|
||||
@@ -41,7 +50,7 @@ func CompleteUpload(ctx *gin.Context) {
|
||||
Updates(map[string]any{
|
||||
"status": models.FileStatusReady,
|
||||
"actual_size": objectInfo.Size,
|
||||
"e_tag": objectInfo.ETag,
|
||||
"storage_etag": objectInfo.StorageETag,
|
||||
"completed_at": completedAt,
|
||||
})
|
||||
if result.Error != nil {
|
||||
@@ -55,7 +64,8 @@ func CompleteUpload(ctx *gin.Context) {
|
||||
|
||||
fileObject.Status = models.FileStatusReady
|
||||
fileObject.ActualSize = objectInfo.Size
|
||||
fileObject.ETag = objectInfo.ETag
|
||||
fileObject.StorageETag = objectInfo.StorageETag
|
||||
fileObject.CompletedAt = &completedAt
|
||||
success = true
|
||||
infra.Response.Success(ctx, newFileResponse(fileObject))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"git.apinb.com/ops/files/internal/impl"
|
||||
"git.apinb.com/ops/files/internal/lifecycle"
|
||||
"git.apinb.com/ops/files/internal/models"
|
||||
"git.apinb.com/ops/files/internal/runtimeinfo"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -37,14 +38,17 @@ func Delete(ctx *gin.Context) {
|
||||
defer cancel()
|
||||
|
||||
if err := impl.StorageService.Delete(operationCtx, fileObject.ObjectKey); err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
|
||||
runtimeinfo.RecordDeleteFailure()
|
||||
infra.Response.Error(ctx, fileerrors.ErrStorageOperation)
|
||||
return
|
||||
}
|
||||
|
||||
if err := lifecycle.FinalizeDeletion(impl.DBService.WithContext(operationCtx), fileObject.ID, lease.Token); err != nil {
|
||||
runtimeinfo.RecordDeleteFailure()
|
||||
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
|
||||
return
|
||||
}
|
||||
|
||||
runtimeinfo.RecordDeleteSuccess()
|
||||
infra.Response.Success(ctx, nil)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,19 @@ func Detail(ctx *gin.Context) {
|
||||
infra.Response.Success(ctx, newFileResponse(fileObject))
|
||||
}
|
||||
|
||||
// Download 通过文件服务鉴权读取文件内容。
|
||||
func Download(ctx *gin.Context) {
|
||||
fileObject, ok := loadOwnedFile(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if fileObject.Status != models.FileStatusReady {
|
||||
infra.Response.Error(ctx, fileerrors.ErrFileNotFound)
|
||||
return
|
||||
}
|
||||
streamFile(ctx, fileObject, normalizeDisposition(ctx.Query("disposition"), "attachment"))
|
||||
}
|
||||
|
||||
func loadOwnedFile(ctx *gin.Context) (models.FileObject, bool) {
|
||||
actor, ok := auth.FromContext(ctx)
|
||||
if !ok {
|
||||
@@ -57,12 +70,10 @@ func loadOwnedFile(ctx *gin.Context) (models.FileObject, bool) {
|
||||
func newFileResponse(fileObject models.FileObject) FileResponse {
|
||||
return FileResponse{
|
||||
FileID: fileObject.Identity,
|
||||
ObjectKey: fileObject.ObjectKey,
|
||||
URL: impl.StorageService.PublicURL(fileObject.ObjectKey),
|
||||
Filename: fileObject.OriginalName,
|
||||
Size: fileObject.ActualSize,
|
||||
ContentType: fileObject.ContentType,
|
||||
ETag: fileObject.ETag,
|
||||
StorageETag: fileObject.StorageETag,
|
||||
Status: fileObject.Status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
fileerrors "git.apinb.com/ops/files/internal/errors"
|
||||
"git.apinb.com/ops/files/internal/impl"
|
||||
"git.apinb.com/ops/files/internal/models"
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -54,34 +56,43 @@ func InitUpload(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
contentType := strings.TrimSpace(request.ContentType)
|
||||
if contentType == "" || utf8.RuneCountInString(contentType) > 255 {
|
||||
if contentType == "" || utf8.RuneCountInString(contentType) > 255 || strings.ContainsAny(contentType, "\r\n") {
|
||||
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
|
||||
return
|
||||
}
|
||||
|
||||
fileID := utils.ULID()
|
||||
objectKey := buildObjectKey(namespaceConfig.Prefix, time.Now(), fileID, extension)
|
||||
upload, err := impl.StorageService.PresignPut(ctx.Request.Context(), objectKey, contentType)
|
||||
expiresAt := time.Now().Add(time.Duration(config.Spec.Storage.AccessTTLSeconds) * time.Second)
|
||||
upload, err := impl.StorageService.PrepareUpload(ctx.Request.Context(), storage.UploadRequest{
|
||||
FileID: fileID, ObjectKey: objectKey, ContentType: contentType,
|
||||
ExpectedSize: request.Size, ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
|
||||
if errors.Is(err, storage.ErrInsufficientSpace) {
|
||||
infra.Response.Error(ctx, fileerrors.ErrStorageSpaceLow)
|
||||
} else {
|
||||
infra.Response.Error(ctx, fileerrors.ErrStorageOperation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fileObject := models.FileObject{
|
||||
Identity: fileID,
|
||||
Namespace: namespace,
|
||||
Provider: config.Spec.ObjectStorage.Provider,
|
||||
Bucket: impl.StorageService.Bucket(),
|
||||
ObjectKey: objectKey,
|
||||
OriginalName: filename,
|
||||
Extension: extension,
|
||||
ContentType: contentType,
|
||||
ExpectedSize: request.Size,
|
||||
Status: models.FileStatusPending,
|
||||
OwnerType: actor.Type,
|
||||
OwnerID: actor.ID,
|
||||
OwnerIdentity: actor.Identity,
|
||||
ExpiresAt: &upload.ExpiresAt,
|
||||
Identity: fileID,
|
||||
Namespace: namespace,
|
||||
Provider: impl.StorageService.Provider(),
|
||||
StorageContainer: impl.StorageService.Container(),
|
||||
ObjectKey: objectKey,
|
||||
OriginalName: filename,
|
||||
Extension: extension,
|
||||
ContentType: contentType,
|
||||
ExpectedSize: request.Size,
|
||||
Status: models.FileStatusPending,
|
||||
Visibility: models.FileVisibilityPrivate,
|
||||
OwnerType: actor.Type,
|
||||
OwnerID: actor.ID,
|
||||
OwnerIdentity: actor.Identity,
|
||||
ExpiresAt: &upload.ExpiresAt,
|
||||
}
|
||||
if err := impl.DBService.Create(&fileObject).Error; err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
|
||||
@@ -89,9 +100,8 @@ func InitUpload(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
infra.Response.Success(ctx, InitUploadResponse{
|
||||
FileID: fileID,
|
||||
ObjectKey: objectKey,
|
||||
Upload: upload,
|
||||
FileID: fileID,
|
||||
Upload: upload,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
78
internal/logic/files/local_upload.go
Normal file
78
internal/logic/files/local_upload.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/ops/files/internal/config"
|
||||
"git.apinb.com/ops/files/internal/impl"
|
||||
"git.apinb.com/ops/files/internal/models"
|
||||
"git.apinb.com/ops/files/internal/signing"
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func LocalUpload(ctx *gin.Context) {
|
||||
receiver, ok := impl.StorageService.(storage.UploadReceiver)
|
||||
if !ok || impl.StorageService.Provider() != "local" {
|
||||
ctx.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
fileID := strings.TrimSpace(ctx.Param("identity"))
|
||||
expiresUnix, err := strconv.ParseInt(ctx.Query("expires"), 10, 64)
|
||||
if err != nil || fileID == "" {
|
||||
ctx.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var fileObject models.FileObject
|
||||
if err := impl.DBService.Where("identity = ?", fileID).First(&fileObject).Error; err != nil {
|
||||
ctx.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
request := storage.UploadRequest{
|
||||
FileID: fileID, ObjectKey: fileObject.ObjectKey, ContentType: fileObject.ContentType,
|
||||
ExpectedSize: fileObject.ExpectedSize, ExpiresAt: time.Unix(expiresUnix, 0),
|
||||
}
|
||||
if !signing.VerifyUpload(config.Spec.Storage.SigningSecret, ctx.Query("signature"), request, time.Now()) ||
|
||||
ctx.GetHeader("Content-Type") != fileObject.ContentType || ctx.Request.ContentLength != fileObject.ExpectedSize {
|
||||
ctx.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
release, err := receiver.AcquireUpload(ctx.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrUploadLocked) {
|
||||
ctx.Status(http.StatusConflict)
|
||||
} else {
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
if err := impl.DBService.Where("id = ?", fileObject.ID).First(&fileObject).Error; err != nil {
|
||||
ctx.Status(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if fileObject.Status != models.FileStatusPending || fileObject.ExpiresAt == nil || !fileObject.ExpiresAt.After(time.Now()) {
|
||||
ctx.Status(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
request.ObjectKey = fileObject.ObjectKey
|
||||
request.ContentType = fileObject.ContentType
|
||||
request.ExpectedSize = fileObject.ExpectedSize
|
||||
if _, err := receiver.ReceiveUpload(ctx.Request.Context(), request, ctx.Request.Body); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientSpace) {
|
||||
ctx.Status(http.StatusInsufficientStorage)
|
||||
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
ctx.Status(http.StatusConflict)
|
||||
} else {
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
52
internal/logic/files/range.go
Normal file
52
internal/logic/files/range.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
)
|
||||
|
||||
func parseByteRange(value string, size int64) (*storage.ByteRange, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if size <= 0 || !strings.HasPrefix(value, "bytes=") {
|
||||
return nil, fmt.Errorf("文件读取范围无效")
|
||||
}
|
||||
rangeValue := strings.TrimPrefix(value, "bytes=")
|
||||
if strings.Contains(rangeValue, ",") {
|
||||
return nil, fmt.Errorf("不支持多段文件读取")
|
||||
}
|
||||
parts := strings.SplitN(rangeValue, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("文件读取范围无效")
|
||||
}
|
||||
if parts[0] == "" {
|
||||
suffixLength, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || suffixLength <= 0 {
|
||||
return nil, fmt.Errorf("文件读取范围无效")
|
||||
}
|
||||
if suffixLength > size {
|
||||
suffixLength = size
|
||||
}
|
||||
return &storage.ByteRange{Start: size - suffixLength, End: size - 1}, nil
|
||||
}
|
||||
start, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || start < 0 || start >= size {
|
||||
return nil, fmt.Errorf("文件读取范围无效")
|
||||
}
|
||||
end := size - 1
|
||||
if parts[1] != "" {
|
||||
end, err = strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || end < start {
|
||||
return nil, fmt.Errorf("文件读取范围无效")
|
||||
}
|
||||
if end >= size {
|
||||
end = size - 1
|
||||
}
|
||||
}
|
||||
return &storage.ByteRange{Start: start, End: end}, nil
|
||||
}
|
||||
59
internal/logic/files/stream.go
Normal file
59
internal/logic/files/stream.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/ops/files/internal/impl"
|
||||
"git.apinb.com/ops/files/internal/models"
|
||||
"git.apinb.com/ops/files/internal/runtimeinfo"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func streamFile(ctx *gin.Context, fileObject models.FileObject, disposition string) {
|
||||
byteRange, err := parseByteRange(ctx.GetHeader("Range"), fileObject.ActualSize)
|
||||
if err != nil {
|
||||
ctx.Header("Content-Range", fmt.Sprintf("bytes */%d", fileObject.ActualSize))
|
||||
ctx.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
body, err := impl.StorageService.Open(ctx.Request.Context(), fileObject.ObjectKey, byteRange)
|
||||
if err != nil {
|
||||
runtimeinfo.RecordReadFailure()
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"message": "文件不存在"})
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
status := http.StatusOK
|
||||
length := fileObject.ActualSize
|
||||
if byteRange != nil {
|
||||
status = http.StatusPartialContent
|
||||
length = byteRange.Length()
|
||||
ctx.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", byteRange.Start, byteRange.End, fileObject.ActualSize))
|
||||
}
|
||||
ctx.Header("Accept-Ranges", "bytes")
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
if fileObject.StorageETag != "" {
|
||||
ctx.Header("ETag", fmt.Sprintf("\"%s\"", strings.ReplaceAll(fileObject.StorageETag, "\"", "")))
|
||||
}
|
||||
if fileObject.CompletedAt != nil {
|
||||
ctx.Header("Last-Modified", fileObject.CompletedAt.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
contentDisposition := mime.FormatMediaType(disposition, map[string]string{"filename": fileObject.OriginalName})
|
||||
ctx.Header("Content-Disposition", contentDisposition)
|
||||
ctx.DataFromReader(status, length, fileObject.ContentType, body, nil)
|
||||
}
|
||||
|
||||
func normalizeDisposition(value, defaultValue string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "inline":
|
||||
return "inline"
|
||||
case "attachment":
|
||||
return "attachment"
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package files
|
||||
import (
|
||||
"git.apinb.com/ops/files/internal/models"
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
"time"
|
||||
)
|
||||
|
||||
type InitUploadRequest struct {
|
||||
@@ -13,18 +14,34 @@ type InitUploadRequest struct {
|
||||
}
|
||||
|
||||
type InitUploadResponse struct {
|
||||
FileID string `json:"file_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Upload storage.UploadInstruction `json:"upload"`
|
||||
FileID string `json:"file_id"`
|
||||
Upload storage.UploadInstruction `json:"upload"`
|
||||
}
|
||||
|
||||
type FileResponse struct {
|
||||
FileID string `json:"file_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
URL string `json:"url"`
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type"`
|
||||
ETag string `json:"etag"`
|
||||
StorageETag string `json:"storage_etag"`
|
||||
Status models.FileStatus `json:"status"`
|
||||
}
|
||||
|
||||
type AccessInstruction struct {
|
||||
FileID string `json:"file_id"`
|
||||
URL string `json:"url"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
Size int64 `json:"size"`
|
||||
Disposition string `json:"disposition"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type BatchAccessRequest struct {
|
||||
FileIDs []string `json:"file_ids" binding:"required"`
|
||||
Disposition string `json:"disposition"`
|
||||
}
|
||||
|
||||
type SetVisibilityRequest struct {
|
||||
Visibility models.FileVisibility `json:"visibility" binding:"required"`
|
||||
}
|
||||
|
||||
39
internal/logic/files/visibility.go
Normal file
39
internal/logic/files/visibility.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
fileerrors "git.apinb.com/ops/files/internal/errors"
|
||||
"git.apinb.com/ops/files/internal/impl"
|
||||
"git.apinb.com/ops/files/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetVisibility(ctx *gin.Context) {
|
||||
fileObject, ok := loadOwnedFile(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request SetVisibilityRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil ||
|
||||
(request.Visibility != models.FileVisibilityPrivate && request.Visibility != models.FileVisibilityPublic) {
|
||||
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
|
||||
return
|
||||
}
|
||||
if fileObject.Namespace != "visual" || fileObject.Status != models.FileStatusReady {
|
||||
infra.Response.Error(ctx, fileerrors.ErrVisibilityNotAllowed)
|
||||
return
|
||||
}
|
||||
result := impl.DBService.Model(&models.FileObject{}).
|
||||
Where("id = ? AND status = ?", fileObject.ID, models.FileStatusReady).
|
||||
Update("visibility", request.Visibility)
|
||||
if result.Error != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict)
|
||||
return
|
||||
}
|
||||
fileObject.Visibility = request.Visibility
|
||||
infra.Response.Success(ctx, newFileResponse(fileObject))
|
||||
}
|
||||
Reference in New Issue
Block a user