diff --git a/apps/web/src/mocks/handlers-cloud.ts b/apps/web/src/mocks/handlers-cloud.ts new file mode 100644 index 000000000..02bf78244 --- /dev/null +++ b/apps/web/src/mocks/handlers-cloud.ts @@ -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 }); + }), +]; diff --git a/apps/web/src/mocks/handlers.ts b/apps/web/src/mocks/handlers.ts index 88faac73c..cea289619 100644 --- a/apps/web/src/mocks/handlers.ts +++ b/apps/web/src/mocks/handlers.ts @@ -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 }) => { diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index 2756dabcc..3569ef598 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -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) } diff --git a/veza-backend-api/internal/api/routes_cloud.go b/veza-backend-api/internal/api/routes_cloud.go new file mode 100644 index 000000000..ed0b1c314 --- /dev/null +++ b/veza-backend-api/internal/api/routes_cloud.go @@ -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) + } +} diff --git a/veza-backend-api/internal/core/auth/service.go b/veza-backend-api/internal/core/auth/service.go index 57e165870..0faa0a052 100644 --- a/veza-backend-api/internal/core/auth/service.go +++ b/veza-backend-api/internal/core/auth/service.go @@ -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 diff --git a/veza-backend-api/internal/handlers/cloud_handler.go b/veza-backend-api/internal/handlers/cloud_handler.go new file mode 100644 index 000000000..2a3e507ab --- /dev/null +++ b/veza-backend-api/internal/handlers/cloud_handler.go @@ -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, + }) +} diff --git a/veza-backend-api/internal/services/cloud_service.go b/veza-backend-api/internal/services/cloud_service.go new file mode 100644 index 000000000..b05962635 --- /dev/null +++ b/veza-backend-api/internal/services/cloud_service.go @@ -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("a).Error; err != nil { + return nil, fmt.Errorf("quota not found: %w", err) + } + return "a, 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("a).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 +} diff --git a/veza-backend-api/internal/services/cloud_service_test.go b/veza-backend-api/internal/services/cloud_service_test.go new file mode 100644 index 000000000..93e0941ed --- /dev/null +++ b/veza-backend-api/internal/services/cloud_service_test.go @@ -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("a).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) +} diff --git a/veza-backend-api/internal/services/errors.go b/veza-backend-api/internal/services/errors.go index b5db67531..efb01bd84 100644 --- a/veza-backend-api/internal/services/errors.go +++ b/veza-backend-api/internal/services/errors.go @@ -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