61
internal/logic/files/complete.go
Normal file
61
internal/logic/files/complete.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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 CompleteUpload(ctx *gin.Context) {
|
||||
fileObject, ok := loadOwnedFile(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if fileObject.Status != models.FileStatusPending {
|
||||
infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
if fileObject.ExpiresAt == nil || !fileObject.ExpiresAt.After(time.Now()) {
|
||||
infra.Response.Error(ctx, fileerrors.ErrUploadExpired)
|
||||
return
|
||||
}
|
||||
|
||||
objectInfo, err := impl.StorageService.Stat(ctx.Request.Context(), fileObject.ObjectKey)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
|
||||
return
|
||||
}
|
||||
if objectInfo.Size != fileObject.ExpectedSize {
|
||||
infra.Response.Error(ctx, fileerrors.ErrObjectInfoMismatch)
|
||||
return
|
||||
}
|
||||
|
||||
completedAt := time.Now()
|
||||
result := impl.DBService.Model(&models.FileObject{}).
|
||||
Where("id = ? AND status = ? AND expires_at > ?", fileObject.ID, models.FileStatusPending, completedAt).
|
||||
Updates(map[string]any{
|
||||
"status": models.FileStatusReady,
|
||||
"actual_size": objectInfo.Size,
|
||||
"etag": objectInfo.ETag,
|
||||
"completed_at": completedAt,
|
||||
})
|
||||
if result.Error != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
fileObject.Status = models.FileStatusReady
|
||||
fileObject.ActualSize = objectInfo.Size
|
||||
fileObject.ETag = objectInfo.ETag
|
||||
fileObject.CompletedAt = &completedAt
|
||||
infra.Response.Success(ctx, newFileResponse(fileObject))
|
||||
}
|
||||
50
internal/logic/files/delete.go
Normal file
50
internal/logic/files/delete.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"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/lifecycle"
|
||||
"git.apinb.com/ops/files/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Delete(ctx *gin.Context) {
|
||||
fileObject, ok := loadOwnedFile(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deletableStatuses := []models.FileStatus{
|
||||
models.FileStatusPending,
|
||||
models.FileStatusReady,
|
||||
models.FileStatusExpired,
|
||||
}
|
||||
lease, claimed, err := lifecycle.ClaimDeletion(impl.DBService, fileObject.ID, deletableStatuses, time.Now())
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
|
||||
return
|
||||
}
|
||||
if !claimed {
|
||||
infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
operationCtx, cancel := context.WithTimeout(ctx.Request.Context(), lifecycle.DeleteOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := impl.StorageService.Delete(operationCtx, fileObject.ObjectKey); err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
|
||||
return
|
||||
}
|
||||
|
||||
if err := lifecycle.FinalizeDeletion(impl.DBService.WithContext(operationCtx), fileObject.ID, lease.Token); err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
|
||||
return
|
||||
}
|
||||
|
||||
infra.Response.Success(ctx, nil)
|
||||
}
|
||||
68
internal/logic/files/detail.go
Normal file
68
internal/logic/files/detail.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/ops/files/internal/auth"
|
||||
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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Detail(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, newFileResponse(fileObject))
|
||||
}
|
||||
|
||||
func loadOwnedFile(ctx *gin.Context) (models.FileObject, bool) {
|
||||
actor, ok := auth.FromContext(ctx)
|
||||
if !ok {
|
||||
infra.Response.Error(ctx, fileerrors.ErrUnauthorizedOperation)
|
||||
return models.FileObject{}, false
|
||||
}
|
||||
|
||||
identity := strings.TrimSpace(ctx.Param("identity"))
|
||||
if identity == "" {
|
||||
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
|
||||
return models.FileObject{}, false
|
||||
}
|
||||
|
||||
var fileObject models.FileObject
|
||||
if err := impl.DBService.
|
||||
Where("identity = ? AND owner_type = ? AND owner_identity = ?", identity, actor.Type, actor.Identity).
|
||||
First(&fileObject).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
infra.Response.Error(ctx, fileerrors.ErrFileNotFound)
|
||||
} else {
|
||||
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
|
||||
}
|
||||
return models.FileObject{}, false
|
||||
}
|
||||
|
||||
return fileObject, true
|
||||
}
|
||||
|
||||
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,
|
||||
Status: fileObject.Status,
|
||||
}
|
||||
}
|
||||
139
internal/logic/files/init.go
Normal file
139
internal/logic/files/init.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/bsm-sdk/core/utils"
|
||||
"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"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const bytesPerMB int64 = 1024 * 1024
|
||||
|
||||
func InitUpload(ctx *gin.Context) {
|
||||
var request InitUploadRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
|
||||
return
|
||||
}
|
||||
|
||||
actor, ok := auth.FromContext(ctx)
|
||||
if !ok {
|
||||
infra.Response.Error(ctx, fileerrors.ErrUnauthorizedOperation)
|
||||
return
|
||||
}
|
||||
|
||||
namespace, namespaceConfig, err := validateNamespace(request.Namespace)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
filename, extension, err := validateFilename(request.Filename, namespaceConfig)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(filename) > 255 {
|
||||
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
|
||||
return
|
||||
}
|
||||
|
||||
if !validFileSize(request.Size, namespaceConfig.MaxSizeMB) {
|
||||
infra.Response.Error(ctx, fileerrors.ErrInvalidFileSize)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := strings.TrimSpace(request.ContentType)
|
||||
if contentType == "" || utf8.RuneCountInString(contentType) > 255 {
|
||||
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)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
|
||||
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,
|
||||
}
|
||||
if err := impl.DBService.Create(&fileObject).Error; err != nil {
|
||||
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
|
||||
return
|
||||
}
|
||||
|
||||
infra.Response.Success(ctx, InitUploadResponse{
|
||||
FileID: fileID,
|
||||
ObjectKey: objectKey,
|
||||
Upload: upload,
|
||||
})
|
||||
}
|
||||
|
||||
func validateNamespace(value string) (string, config.NamespaceConf, error) {
|
||||
namespace := strings.TrimSpace(value)
|
||||
namespaceConfig, ok := config.Spec.Namespaces[namespace]
|
||||
if !ok {
|
||||
return "", config.NamespaceConf{}, fileerrors.ErrNamespaceNotAllowed
|
||||
}
|
||||
return namespace, namespaceConfig, nil
|
||||
}
|
||||
|
||||
func validateFilename(value string, namespaceConfig config.NamespaceConf) (string, string, error) {
|
||||
filename := strings.TrimSpace(value)
|
||||
if strings.ContainsRune(filename, 0) {
|
||||
return "", "", fileerrors.ErrInvalidParameter
|
||||
}
|
||||
|
||||
filename = filepath.Base(filename)
|
||||
if filename == "" || filename == "." || filename == ".." {
|
||||
return "", "", fileerrors.ErrInvalidParameter
|
||||
}
|
||||
|
||||
extension := strings.ToLower(filepath.Ext(filename))
|
||||
if extension == "" || !containsExtension(namespaceConfig.AllowedExtensions, extension) {
|
||||
return "", "", fileerrors.ErrExtensionNotAllowed
|
||||
}
|
||||
return filename, extension, nil
|
||||
}
|
||||
|
||||
func containsExtension(extensions []string, extension string) bool {
|
||||
for _, allowedExtension := range extensions {
|
||||
if allowedExtension == extension {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validFileSize(size, maxSizeMB int64) bool {
|
||||
if size < 1 || maxSizeMB < 1 || maxSizeMB > math.MaxInt64/bytesPerMB {
|
||||
return false
|
||||
}
|
||||
return size <= maxSizeMB*bytesPerMB
|
||||
}
|
||||
10
internal/logic/files/object_key.go
Normal file
10
internal/logic/files/object_key.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
func buildObjectKey(prefix string, now time.Time, fileID, extension string) string {
|
||||
return path.Join(prefix, now.Format("2006"), now.Format("01"), fileID+extension)
|
||||
}
|
||||
30
internal/logic/files/types.go
Normal file
30
internal/logic/files/types.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"git.apinb.com/ops/files/internal/models"
|
||||
"git.apinb.com/ops/files/internal/storage"
|
||||
)
|
||||
|
||||
type InitUploadRequest struct {
|
||||
Namespace string `json:"namespace" binding:"required"`
|
||||
Filename string `json:"filename" binding:"required"`
|
||||
Size int64 `json:"size" binding:"required"`
|
||||
ContentType string `json:"content_type" binding:"required"`
|
||||
}
|
||||
|
||||
type InitUploadResponse struct {
|
||||
FileID string `json:"file_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
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"`
|
||||
Status models.FileStatus `json:"status"`
|
||||
}
|
||||
11
internal/logic/ping/hello.go
Normal file
11
internal/logic/ping/hello.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package ping
|
||||
|
||||
import (
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Hello(ctx *gin.Context) {
|
||||
infra.Response.Success(ctx, "Files Service is running!")
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user