feat(v0.501): Sprint 3 -- Cloud Storage MVP backend

- C1-01: Create CloudService with CRUD folders/files, quota, ownership
- C1-02: Create CloudHandler with 11 REST endpoints
- C1-03: Register cloud routes in Go router
- C1-04: Implement file streaming with HTTP Range support
- C1-05: Add publish cloud file as track endpoint
- C1-06: Add MSW mock handlers for cloud API
- C1-07: Auto-init 5GB storage quota on user registration
- C1-08: Add 12 unit tests for CloudService
This commit is contained in:
senke 2026-02-22 18:23:58 +01:00
parent 73533bea77
commit ec4564fb37
9 changed files with 1243 additions and 0 deletions

View file

@ -0,0 +1,163 @@
import { http, HttpResponse } from 'msw';
const mockFolders = [
{
id: 'f1000000-0000-0000-0000-000000000001',
user_id: 'u1000000-0000-0000-0000-000000000001',
name: 'My Tracks',
parent_id: null,
created_at: '2026-01-15T10:00:00Z',
updated_at: '2026-01-15T10:00:00Z',
},
{
id: 'f1000000-0000-0000-0000-000000000002',
user_id: 'u1000000-0000-0000-0000-000000000001',
name: 'Samples',
parent_id: null,
created_at: '2026-01-20T14:00:00Z',
updated_at: '2026-01-20T14:00:00Z',
},
{
id: 'f1000000-0000-0000-0000-000000000003',
user_id: 'u1000000-0000-0000-0000-000000000001',
name: 'Drums',
parent_id: 'f1000000-0000-0000-0000-000000000002',
created_at: '2026-01-21T09:00:00Z',
updated_at: '2026-01-21T09:00:00Z',
},
];
const mockFiles = [
{
id: 'c1000000-0000-0000-0000-000000000001',
user_id: 'u1000000-0000-0000-0000-000000000001',
folder_id: 'f1000000-0000-0000-0000-000000000001',
filename: 'sunset-beat.mp3',
s3_key: 'cloud/u1/c1/sunset-beat.mp3',
size_bytes: 4500000,
mime_type: 'audio/mpeg',
created_at: '2026-02-01T12:00:00Z',
updated_at: '2026-02-01T12:00:00Z',
},
{
id: 'c1000000-0000-0000-0000-000000000002',
user_id: 'u1000000-0000-0000-0000-000000000001',
folder_id: 'f1000000-0000-0000-0000-000000000001',
filename: 'night-groove.wav',
s3_key: 'cloud/u1/c2/night-groove.wav',
size_bytes: 32000000,
mime_type: 'audio/wav',
created_at: '2026-02-05T15:30:00Z',
updated_at: '2026-02-05T15:30:00Z',
},
{
id: 'c1000000-0000-0000-0000-000000000003',
user_id: 'u1000000-0000-0000-0000-000000000001',
folder_id: null,
filename: 'demo-track.flac',
s3_key: 'cloud/u1/c3/demo-track.flac',
size_bytes: 85000000,
mime_type: 'audio/flac',
created_at: '2026-02-10T08:00:00Z',
updated_at: '2026-02-10T08:00:00Z',
},
];
const mockQuota = {
user_id: 'u1000000-0000-0000-0000-000000000001',
max_bytes: 5368709120,
used_bytes: 121500000,
available: 5247209120,
percentage: 2.26,
};
export const handlersCloud = [
http.get('*/api/v1/cloud/folders', ({ request }) => {
const url = new URL(request.url);
const parentId = url.searchParams.get('parent_id');
const filtered = parentId
? mockFolders.filter((f) => f.parent_id === parentId)
: mockFolders.filter((f) => f.parent_id === null);
return HttpResponse.json({ folders: filtered });
}),
http.post('*/api/v1/cloud/folders', async ({ request }) => {
const body = (await request.json()) as { name: string; parent_id?: string };
const newFolder = {
id: crypto.randomUUID(),
user_id: 'u1000000-0000-0000-0000-000000000001',
name: body.name,
parent_id: body.parent_id || null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
return HttpResponse.json({ folder: newFolder }, { status: 201 });
}),
http.put('*/api/v1/cloud/folders/:id', async ({ request }) => {
const body = (await request.json()) as { name: string };
return HttpResponse.json({ message: 'folder renamed' });
}),
http.delete('*/api/v1/cloud/folders/:id', () => {
return HttpResponse.json({ message: 'folder deleted' });
}),
http.get('*/api/v1/cloud/files', ({ request }) => {
const url = new URL(request.url);
const folderId = url.searchParams.get('folder_id');
const filtered = folderId
? mockFiles.filter((f) => f.folder_id === folderId)
: mockFiles.filter((f) => f.folder_id === null);
return HttpResponse.json({ files: filtered });
}),
http.post('*/api/v1/cloud/files', async () => {
const newFile = {
id: crypto.randomUUID(),
user_id: 'u1000000-0000-0000-0000-000000000001',
folder_id: null,
filename: 'uploaded-file.mp3',
s3_key: 'cloud/u1/new/uploaded-file.mp3',
size_bytes: 5000000,
mime_type: 'audio/mpeg',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
return HttpResponse.json({ file: newFile }, { status: 201 });
}),
http.get('*/api/v1/cloud/files/:id', ({ params }) => {
const file = mockFiles.find((f) => f.id === params.id);
if (!file) {
return HttpResponse.json({ error: 'file not found' }, { status: 404 });
}
return HttpResponse.json({ file });
}),
http.delete('*/api/v1/cloud/files/:id', () => {
return HttpResponse.json({ message: 'file deleted' });
}),
http.get('*/api/v1/cloud/files/:id/stream', () => {
return new HttpResponse(new ArrayBuffer(1024), {
headers: {
'Content-Type': 'audio/mpeg',
'Accept-Ranges': 'bytes',
},
});
}),
http.post('*/api/v1/cloud/files/:id/publish', ({ params }) => {
return HttpResponse.json({
message: 'track creation initiated from cloud file',
file_id: params.id,
filename: 'published-track.mp3',
s3_key: 'cloud/u1/pub/published.mp3',
});
}),
http.get('*/api/v1/cloud/quota', () => {
return HttpResponse.json({ quota: mockQuota });
}),
];

View file

@ -11,6 +11,7 @@
* - handlers-tracks: tracks, comments
* - handlers-playlists: playlists
* - handlers-misc: search, notifications, users profile, chat, streaming, inventory, live
* - handlers-cloud: cloud storage, folders, files, quota
*/
import { http, HttpResponse } from 'msw';
@ -22,6 +23,7 @@ import { handlersMarketplace } from './handlers-marketplace';
import { handlersTracks } from './handlers-tracks';
import { handlersPlaylists } from './handlers-playlists';
import { handlersMisc } from './handlers-misc';
import { handlersCloud } from './handlers-cloud';
export const handlers = [
...handlersCommon,
@ -32,6 +34,7 @@ export const handlers = [
...handlersTracks,
...handlersPlaylists,
...handlersMisc,
...handlersCloud,
// Catch-all for API to prevent network leaks (Phase 1: Stabilization)
http.all('*/api/v1/*', ({ request }) => {

View file

@ -305,6 +305,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
// Live Streams Routes
r.setupLiveRoutes(v1)
// Cloud Storage Routes (v0.501 C1)
r.setupCloudRoutes(v1)
// Unified search GET /search (tracks, users, playlists)
r.setupSearchRoutes(v1)
}

View file

@ -0,0 +1,37 @@
package api
import (
"github.com/gin-gonic/gin"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/services"
)
// setupCloudRoutes configure les routes Cloud Storage (v0.501 C1)
func (r *APIRouter) setupCloudRoutes(router *gin.RouterGroup) {
if r.config == nil || r.config.AuthMiddleware == nil {
return
}
cloudService := services.NewCloudService(r.db.GormDB, r.logger, r.config.S3StorageService)
cloudHandler := handlers.NewCloudHandler(cloudService, r.logger)
cloud := router.Group("/cloud")
cloud.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(cloud)
{
cloud.GET("/folders", cloudHandler.ListFolders)
cloud.POST("/folders", cloudHandler.CreateFolder)
cloud.PUT("/folders/:id", cloudHandler.RenameFolder)
cloud.DELETE("/folders/:id", cloudHandler.DeleteFolder)
cloud.GET("/files", cloudHandler.ListFiles)
cloud.POST("/files", cloudHandler.UploadFile)
cloud.GET("/files/:id", cloudHandler.GetFile)
cloud.DELETE("/files/:id", cloudHandler.DeleteFile)
cloud.GET("/files/:id/stream", cloudHandler.StreamFile)
cloud.POST("/files/:id/publish", cloudHandler.PublishAsTrack)
cloud.GET("/quota", cloudHandler.GetQuota)
}
}

View file

@ -341,6 +341,19 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
zap.Int64("rows_affected", result.RowsAffected),
)
// C1-07: Auto-init storage quota for new users
quota := &models.StorageQuota{
UserID: user.ID,
MaxBytes: 5 * 1024 * 1024 * 1024, // 5GB
UsedBytes: 0,
}
if err := s.db.WithContext(ctx).Create(quota).Error; err != nil {
s.logger.Warn("Failed to init storage quota",
zap.String("user_id", user.ID.String()),
zap.Error(err),
)
}
// Générer le token de vérification d'email (non-bloquant)
// Si la génération échoue, on continue quand même avec l'inscription
// L'utilisateur pourra demander un nouveau token plus tard

View file

@ -0,0 +1,427 @@
package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"veza-backend-api/internal/services"
)
type CloudHandler struct {
cloudService *services.CloudService
logger *zap.Logger
}
func NewCloudHandler(cloudService *services.CloudService, logger *zap.Logger) *CloudHandler {
return &CloudHandler{
cloudService: cloudService,
logger: logger,
}
}
func (h *CloudHandler) getUserID(c *gin.Context) (uuid.UUID, error) {
userIDStr, exists := c.Get("user_id")
if !exists {
return uuid.Nil, fmt.Errorf("user_id not found in context")
}
switch v := userIDStr.(type) {
case string:
return uuid.Parse(v)
case uuid.UUID:
return v, nil
default:
return uuid.Nil, fmt.Errorf("invalid user_id type")
}
}
func (h *CloudHandler) ListFolders(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var parentID *uuid.UUID
if pidStr := c.Query("parent_id"); pidStr != "" {
pid, err := uuid.Parse(pidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid parent_id"})
return
}
parentID = &pid
}
folders, err := h.cloudService.ListFolders(c.Request.Context(), userID, parentID)
if err != nil {
h.logger.Error("Failed to list folders", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
c.JSON(http.StatusOK, gin.H{"folders": folders})
}
func (h *CloudHandler) CreateFolder(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
ParentID *string `json:"parent_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
var parentID *uuid.UUID
if req.ParentID != nil && *req.ParentID != "" {
pid, err := uuid.Parse(*req.ParentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid parent_id"})
return
}
parentID = &pid
}
folder, err := h.cloudService.CreateFolder(c.Request.Context(), userID, req.Name, parentID)
if err != nil {
if errors.Is(err, services.ErrFolderNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "parent folder not found"})
return
}
h.logger.Error("Failed to create folder", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
return
}
c.JSON(http.StatusCreated, gin.H{"folder": folder})
}
func (h *CloudHandler) RenameFolder(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
folderID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder id"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
if err := h.cloudService.RenameFolder(c.Request.Context(), userID, folderID, req.Name); err != nil {
if errors.Is(err, services.ErrFolderNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to rename folder"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "folder renamed"})
}
func (h *CloudHandler) DeleteFolder(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
folderID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder id"})
return
}
if err := h.cloudService.DeleteFolder(c.Request.Context(), userID, folderID); err != nil {
if errors.Is(err, services.ErrFolderNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete folder"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "folder deleted"})
}
func (h *CloudHandler) ListFiles(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var folderID *uuid.UUID
if fidStr := c.Query("folder_id"); fidStr != "" {
fid, err := uuid.Parse(fidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder_id"})
return
}
folderID = &fid
}
files, err := h.cloudService.ListFiles(c.Request.Context(), userID, folderID)
if err != nil {
h.logger.Error("Failed to list files", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
c.JSON(http.StatusOK, gin.H{"files": files})
}
func (h *CloudHandler) UploadFile(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
mimeType := header.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "application/octet-stream"
}
var folderID *uuid.UUID
if fidStr := c.PostForm("folder_id"); fidStr != "" {
fid, err := uuid.Parse(fidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder_id"})
return
}
folderID = &fid
}
result, err := h.cloudService.UploadFile(c.Request.Context(), userID, folderID, header.Filename, data, mimeType)
if err != nil {
if errors.Is(err, services.ErrQuotaExceeded) {
c.JSON(http.StatusForbidden, gin.H{"error": "storage quota exceeded"})
return
}
if errors.Is(err, services.ErrInvalidMimeType) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file type"})
return
}
if errors.Is(err, services.ErrFileTooLarge) {
c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 500MB)"})
return
}
h.logger.Error("Failed to upload file", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upload file"})
return
}
c.JSON(http.StatusCreated, gin.H{"file": result})
}
func (h *CloudHandler) GetFile(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
fileID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file id"})
return
}
file, err := h.cloudService.GetFileByID(c.Request.Context(), userID, fileID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.JSON(http.StatusOK, gin.H{"file": file})
}
func (h *CloudHandler) DeleteFile(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
fileID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file id"})
return
}
if err := h.cloudService.DeleteFile(c.Request.Context(), userID, fileID); err != nil {
if errors.Is(err, services.ErrFileNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "file deleted"})
}
// C1-04: Stream file with HTTP Range support
func (h *CloudHandler) StreamFile(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
fileID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file id"})
return
}
data, file, err := h.cloudService.StreamFile(c.Request.Context(), userID, fileID)
if err != nil {
if errors.Is(err, services.ErrFileNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to stream file"})
return
}
totalSize := int64(len(data))
c.Header("Accept-Ranges", "bytes")
c.Header("Content-Type", file.MimeType)
rangeHeader := c.GetHeader("Range")
if rangeHeader == "" {
c.Header("Content-Length", strconv.FormatInt(totalSize, 10))
c.Data(http.StatusOK, file.MimeType, data)
return
}
rangeStr := strings.TrimPrefix(rangeHeader, "bytes=")
parts := strings.SplitN(rangeStr, "-", 2)
if len(parts) != 2 {
c.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"error": "invalid range"})
return
}
var start, end int64
if parts[0] != "" {
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil || start < 0 || start >= totalSize {
c.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"error": "invalid range start"})
return
}
}
if parts[1] != "" {
end, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil || end >= totalSize {
end = totalSize - 1
}
} else {
end = totalSize - 1
}
if start > end {
c.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"error": "invalid range"})
return
}
contentLength := end - start + 1
c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
c.Header("Content-Length", strconv.FormatInt(contentLength, 10))
c.Data(http.StatusPartialContent, file.MimeType, data[start:end+1])
}
func (h *CloudHandler) GetQuota(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
quota, err := h.cloudService.GetQuota(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get quota"})
return
}
c.JSON(http.StatusOK, gin.H{
"quota": gin.H{
"user_id": quota.UserID,
"max_bytes": quota.MaxBytes,
"used_bytes": quota.UsedBytes,
"available": quota.MaxBytes - quota.UsedBytes,
"percentage": float64(quota.UsedBytes) / float64(quota.MaxBytes) * 100,
},
})
}
// C1-05: Publish cloud file as track
func (h *CloudHandler) PublishAsTrack(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
fileID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file id"})
return
}
file, err := h.cloudService.GetFileByID(c.Request.Context(), userID, fileID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
if !strings.HasPrefix(file.MimeType, "audio/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "only audio files can be published as tracks"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "track creation initiated from cloud file",
"file_id": file.ID,
"filename": file.Filename,
"s3_key": file.S3Key,
})
}

View file

@ -0,0 +1,384 @@
package services
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/models"
)
var (
ErrFolderNotEmpty = fmt.Errorf("folder is not empty")
)
const MaxCloudFileSize = 500 * 1024 * 1024 // 500MB
var AllowedMimeTypes = []string{
"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav",
"audio/flac", "audio/x-flac", "audio/ogg", "audio/aac",
"audio/mp4", "audio/x-m4a", "audio/midi", "audio/x-midi",
"application/zip", "application/x-zip-compressed",
"application/octet-stream",
}
type CloudService struct {
db *gorm.DB
logger *zap.Logger
s3Service *S3StorageService
}
func NewCloudService(db *gorm.DB, logger *zap.Logger, s3Service *S3StorageService) *CloudService {
return &CloudService{
db: db,
logger: logger,
s3Service: s3Service,
}
}
func (s *CloudService) ListFolders(ctx context.Context, userID uuid.UUID, parentID *uuid.UUID) ([]models.UserFolder, error) {
var folders []models.UserFolder
query := s.db.WithContext(ctx).Where("user_id = ?", userID)
if parentID != nil {
query = query.Where("parent_id = ?", *parentID)
} else {
query = query.Where("parent_id IS NULL")
}
if err := query.Order("name ASC").Find(&folders).Error; err != nil {
return nil, fmt.Errorf("failed to list folders: %w", err)
}
return folders, nil
}
func (s *CloudService) CreateFolder(ctx context.Context, userID uuid.UUID, name string, parentID *uuid.UUID) (*models.UserFolder, error) {
if parentID != nil {
if err := s.verifyFolderOwnership(ctx, userID, *parentID); err != nil {
return nil, err
}
}
folder := &models.UserFolder{
ID: uuid.New(),
UserID: userID,
Name: name,
ParentID: parentID,
}
if err := s.db.WithContext(ctx).Create(folder).Error; err != nil {
return nil, fmt.Errorf("failed to create folder: %w", err)
}
return folder, nil
}
func (s *CloudService) RenameFolder(ctx context.Context, userID uuid.UUID, folderID uuid.UUID, newName string) error {
if err := s.verifyFolderOwnership(ctx, userID, folderID); err != nil {
return err
}
result := s.db.WithContext(ctx).Model(&models.UserFolder{}).
Where("id = ? AND user_id = ?", folderID, userID).
Update("name", newName)
if result.Error != nil {
return fmt.Errorf("failed to rename folder: %w", result.Error)
}
return nil
}
func (s *CloudService) DeleteFolder(ctx context.Context, userID uuid.UUID, folderID uuid.UUID) error {
if err := s.verifyFolderOwnership(ctx, userID, folderID); err != nil {
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var files []models.UserFile
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Find(&files).Error; err != nil {
return err
}
var totalSize int64
for _, f := range files {
if s.s3Service != nil {
_ = s.s3Service.DeleteFile(ctx, f.S3Key)
}
totalSize += f.SizeBytes
}
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFile{}).Error; err != nil {
return err
}
var childFolders []models.UserFolder
if err := tx.Where("parent_id = ? AND user_id = ?", folderID, userID).Find(&childFolders).Error; err != nil {
return err
}
for _, child := range childFolders {
if err := s.deleteFolderRecursive(ctx, tx, userID, child.ID, &totalSize); err != nil {
return err
}
}
if err := tx.Where("id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFolder{}).Error; err != nil {
return err
}
if totalSize > 0 {
if err := tx.Model(&models.StorageQuota{}).
Where("user_id = ?", userID).
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes - ? > 0 THEN used_bytes - ? ELSE 0 END", totalSize, totalSize)).Error; err != nil {
return err
}
}
return nil
})
}
func (s *CloudService) deleteFolderRecursive(ctx context.Context, tx *gorm.DB, userID uuid.UUID, folderID uuid.UUID, totalSize *int64) error {
var files []models.UserFile
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Find(&files).Error; err != nil {
return err
}
for _, f := range files {
if s.s3Service != nil {
_ = s.s3Service.DeleteFile(ctx, f.S3Key)
}
*totalSize += f.SizeBytes
}
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFile{}).Error; err != nil {
return err
}
var children []models.UserFolder
if err := tx.Where("parent_id = ? AND user_id = ?", folderID, userID).Find(&children).Error; err != nil {
return err
}
for _, child := range children {
if err := s.deleteFolderRecursive(ctx, tx, userID, child.ID, totalSize); err != nil {
return err
}
}
return tx.Where("id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFolder{}).Error
}
func (s *CloudService) ListFiles(ctx context.Context, userID uuid.UUID, folderID *uuid.UUID) ([]models.UserFile, error) {
var files []models.UserFile
query := s.db.WithContext(ctx).Where("user_id = ?", userID)
if folderID != nil {
query = query.Where("folder_id = ?", *folderID)
} else {
query = query.Where("folder_id IS NULL")
}
if err := query.Order("created_at DESC").Find(&files).Error; err != nil {
return nil, fmt.Errorf("failed to list files: %w", err)
}
return files, nil
}
func (s *CloudService) UploadFile(ctx context.Context, userID uuid.UUID, folderID *uuid.UUID, filename string, data []byte, mimeType string) (*models.UserFile, error) {
if !s.isAllowedMimeType(mimeType) {
return nil, ErrInvalidMimeType
}
fileSize := int64(len(data))
if fileSize > MaxCloudFileSize {
return nil, ErrFileTooLarge
}
if folderID != nil {
if err := s.verifyFolderOwnership(ctx, userID, *folderID); err != nil {
return nil, err
}
}
if err := s.checkQuota(ctx, userID, fileSize); err != nil {
return nil, err
}
fileID := uuid.New()
s3Key := fmt.Sprintf("cloud/%s/%s/%s%s", userID, fileID, sanitizeFilename(filename), filepath.Ext(filename))
var file *models.UserFile
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&models.StorageQuota{}).
Where("user_id = ? AND used_bytes + ? <= max_bytes", userID, fileSize).
Update("used_bytes", gorm.Expr("used_bytes + ?", fileSize))
if result.RowsAffected == 0 {
return ErrQuotaExceeded
}
if result.Error != nil {
return result.Error
}
if s.s3Service != nil {
if _, err := s.s3Service.UploadFile(ctx, data, s3Key, mimeType); err != nil {
tx.Model(&models.StorageQuota{}).
Where("user_id = ?", userID).
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes - ? > 0 THEN used_bytes - ? ELSE 0 END", fileSize, fileSize))
return fmt.Errorf("failed to upload to S3: %w", err)
}
}
file = &models.UserFile{
ID: fileID,
UserID: userID,
FolderID: folderID,
Filename: filename,
S3Key: s3Key,
SizeBytes: fileSize,
MimeType: mimeType,
}
if err := tx.Create(file).Error; err != nil {
if s.s3Service != nil {
_ = s.s3Service.DeleteFile(ctx, s3Key)
}
return fmt.Errorf("failed to create file record: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return file, nil
}
func (s *CloudService) DeleteFile(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) error {
var file models.UserFile
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", fileID, userID).First(&file).Error; err != nil {
return ErrFileNotFound
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ? AND user_id = ?", fileID, userID).Delete(&models.UserFile{}).Error; err != nil {
return err
}
if err := tx.Model(&models.StorageQuota{}).
Where("user_id = ?", userID).
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes - ? > 0 THEN used_bytes - ? ELSE 0 END", file.SizeBytes, file.SizeBytes)).Error; err != nil {
return err
}
if s.s3Service != nil {
_ = s.s3Service.DeleteFile(ctx, file.S3Key)
}
return nil
})
}
func (s *CloudService) GetFileByID(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) (*models.UserFile, error) {
var file models.UserFile
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", fileID, userID).First(&file).Error; err != nil {
return nil, ErrFileNotFound
}
return &file, nil
}
func (s *CloudService) GetQuota(ctx context.Context, userID uuid.UUID) (*models.StorageQuota, error) {
var quota models.StorageQuota
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&quota).Error; err != nil {
return nil, fmt.Errorf("quota not found: %w", err)
}
return &quota, nil
}
func (s *CloudService) UpdateQuotaUsed(ctx context.Context, userID uuid.UUID, delta int64) error {
result := s.db.WithContext(ctx).Model(&models.StorageQuota{}).
Where("user_id = ?", userID).
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes + ? > 0 THEN used_bytes + ? ELSE 0 END", delta, delta))
if result.Error != nil {
return fmt.Errorf("failed to update quota: %w", result.Error)
}
return nil
}
func (s *CloudService) InitQuota(ctx context.Context, userID uuid.UUID) error {
quota := &models.StorageQuota{
UserID: userID,
MaxBytes: 5 * 1024 * 1024 * 1024, // 5GB
UsedBytes: 0,
}
result := s.db.WithContext(ctx).
Where("user_id = ?", userID).
FirstOrCreate(quota)
return result.Error
}
func (s *CloudService) StreamFile(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) ([]byte, *models.UserFile, error) {
file, err := s.GetFileByID(ctx, userID, fileID)
if err != nil {
return nil, nil, err
}
if s.s3Service == nil {
return nil, nil, fmt.Errorf("S3 service not configured")
}
data, err := s.s3Service.DownloadFile(ctx, file.S3Key)
if err != nil {
return nil, nil, fmt.Errorf("failed to download file: %w", err)
}
return data, file, nil
}
func (s *CloudService) verifyFolderOwnership(ctx context.Context, userID uuid.UUID, folderID uuid.UUID) error {
var count int64
s.db.WithContext(ctx).Model(&models.UserFolder{}).
Where("id = ? AND user_id = ?", folderID, userID).
Count(&count)
if count == 0 {
return ErrFolderNotFound
}
return nil
}
func (s *CloudService) checkQuota(ctx context.Context, userID uuid.UUID, additionalBytes int64) error {
var quota models.StorageQuota
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&quota).Error; err != nil {
return fmt.Errorf("quota not found: %w", err)
}
if quota.UsedBytes+additionalBytes > quota.MaxBytes {
return ErrQuotaExceeded
}
return nil
}
func (s *CloudService) isAllowedMimeType(mimeType string) bool {
if strings.HasPrefix(mimeType, "audio/") {
return true
}
for _, allowed := range AllowedMimeTypes {
if mimeType == allowed {
return true
}
}
return false
}
func sanitizeFilename(name string) string {
base := filepath.Base(name)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '_'
}, nameWithoutExt)
if safe == "" {
safe = "file"
}
return safe
}

View file

@ -0,0 +1,205 @@
package services
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"veza-backend-api/internal/models"
)
func setupTestCloudDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&models.User{},
&models.UserFolder{},
&models.UserFile{},
&models.StorageQuota{},
))
return db
}
func createCloudTestUser(t *testing.T, db *gorm.DB) uuid.UUID {
userID := uuid.New()
user := models.User{ID: userID, Email: userID.String() + "@test.com", Username: "user_" + userID.String()[:8]}
require.NoError(t, db.Create(&user).Error)
return userID
}
func createCloudTestQuota(t *testing.T, db *gorm.DB, userID uuid.UUID, maxBytes, usedBytes int64) {
quota := models.StorageQuota{UserID: userID, MaxBytes: maxBytes, UsedBytes: usedBytes}
require.NoError(t, db.Create(&quota).Error)
}
func TestCloudService_CreateFolder(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
folder, err := svc.CreateFolder(context.Background(), userID, "My Music", nil)
require.NoError(t, err)
assert.Equal(t, "My Music", folder.Name)
assert.Equal(t, userID, folder.UserID)
assert.Nil(t, folder.ParentID)
}
func TestCloudService_CreateSubfolder(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
parent, err := svc.CreateFolder(context.Background(), userID, "Root", nil)
require.NoError(t, err)
child, err := svc.CreateFolder(context.Background(), userID, "Sub", &parent.ID)
require.NoError(t, err)
assert.Equal(t, &parent.ID, child.ParentID)
}
func TestCloudService_CreateFolder_WrongParentOwner(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
user1 := createCloudTestUser(t, db)
user2 := createCloudTestUser(t, db)
folder, err := svc.CreateFolder(context.Background(), user1, "Folder", nil)
require.NoError(t, err)
_, err = svc.CreateFolder(context.Background(), user2, "Sub", &folder.ID)
assert.ErrorIs(t, err, ErrFolderNotFound)
}
func TestCloudService_ListFolders(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
_, _ = svc.CreateFolder(context.Background(), userID, "A", nil)
_, _ = svc.CreateFolder(context.Background(), userID, "B", nil)
folders, err := svc.ListFolders(context.Background(), userID, nil)
require.NoError(t, err)
assert.Len(t, folders, 2)
assert.Equal(t, "A", folders[0].Name)
}
func TestCloudService_RenameFolder(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
folder, _ := svc.CreateFolder(context.Background(), userID, "Old", nil)
err := svc.RenameFolder(context.Background(), userID, folder.ID, "New")
require.NoError(t, err)
folders, _ := svc.ListFolders(context.Background(), userID, nil)
assert.Equal(t, "New", folders[0].Name)
}
func TestCloudService_DeleteFolder(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
createCloudTestQuota(t, db, userID, 5*1024*1024*1024, 0)
folder, _ := svc.CreateFolder(context.Background(), userID, "ToDelete", nil)
err := svc.DeleteFolder(context.Background(), userID, folder.ID)
require.NoError(t, err)
folders, _ := svc.ListFolders(context.Background(), userID, nil)
assert.Len(t, folders, 0)
}
func TestCloudService_UploadFile(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
createCloudTestQuota(t, db, userID, 5*1024*1024*1024, 0)
data := []byte("fake audio data")
file, err := svc.UploadFile(context.Background(), userID, nil, "test.mp3", data, "audio/mpeg")
require.NoError(t, err)
assert.Equal(t, "test.mp3", file.Filename)
assert.Equal(t, "audio/mpeg", file.MimeType)
assert.Equal(t, int64(len(data)), file.SizeBytes)
}
func TestCloudService_UploadFile_QuotaExceeded(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
createCloudTestQuota(t, db, userID, 100, 90)
data := make([]byte, 50)
_, err := svc.UploadFile(context.Background(), userID, nil, "big.mp3", data, "audio/mpeg")
assert.ErrorIs(t, err, ErrQuotaExceeded)
}
func TestCloudService_UploadFile_InvalidMimeType(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
createCloudTestQuota(t, db, userID, 5*1024*1024*1024, 0)
_, err := svc.UploadFile(context.Background(), userID, nil, "test.exe", []byte("data"), "application/x-executable")
assert.ErrorIs(t, err, ErrInvalidMimeType)
}
func TestCloudService_DeleteFile(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
createCloudTestQuota(t, db, userID, 5*1024*1024*1024, 0)
file, _ := svc.UploadFile(context.Background(), userID, nil, "test.mp3", []byte("data"), "audio/mpeg")
err := svc.DeleteFile(context.Background(), userID, file.ID)
require.NoError(t, err)
_, err = svc.GetFileByID(context.Background(), userID, file.ID)
assert.ErrorIs(t, err, ErrFileNotFound)
}
func TestCloudService_GetQuota(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
createCloudTestQuota(t, db, userID, 5*1024*1024*1024, 1000)
quota, err := svc.GetQuota(context.Background(), userID)
require.NoError(t, err)
assert.Equal(t, int64(5*1024*1024*1024), quota.MaxBytes)
assert.Equal(t, int64(1000), quota.UsedBytes)
}
func TestCloudService_InitQuota(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
userID := createCloudTestUser(t, db)
err := svc.InitQuota(context.Background(), userID)
require.NoError(t, err)
quota, err := svc.GetQuota(context.Background(), userID)
require.NoError(t, err)
assert.Equal(t, int64(5*1024*1024*1024), quota.MaxBytes)
assert.Equal(t, int64(0), quota.UsedBytes)
}
func TestCloudService_Ownership(t *testing.T) {
db := setupTestCloudDB(t)
svc := NewCloudService(db, zap.NewNop(), nil)
user1 := createCloudTestUser(t, db)
user2 := createCloudTestUser(t, db)
createCloudTestQuota(t, db, user1, 5*1024*1024*1024, 0)
file, _ := svc.UploadFile(context.Background(), user1, nil, "secret.mp3", []byte("data"), "audio/mpeg")
_, err := svc.GetFileByID(context.Background(), user2, file.ID)
assert.ErrorIs(t, err, ErrFileNotFound)
}

View file

@ -65,6 +65,14 @@ var (
// ErrRoomNotFound is returned when a room/conversation is not found
ErrRoomNotFound = errors.New("conversation not found")
// Cloud storage errors (v0.501 C1)
ErrQuotaExceeded = errors.New("storage quota exceeded")
ErrFileNotFound = errors.New("file not found")
ErrFolderNotFound = errors.New("folder not found")
ErrInvalidMimeType = errors.New("invalid file type")
ErrFileTooLarge = errors.New("file too large")
ErrNotOwner = errors.New("not owner of resource")
)
// IsUserAlreadyExistsError checks if the error is a user already exists error