package handlers import ( "errors" "fmt" "io" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" apperrors "veza-backend-api/internal/errors" "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 { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } var parentID *uuid.UUID if pidStr := c.Query("parent_id"); pidStr != "" { pid, err := uuid.Parse(pidStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("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)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to list folders", err)) return } c.JSON(http.StatusOK, gin.H{"folders": folders}) } func (h *CloudHandler) CreateFolder(c *gin.Context) { userID, err := h.getUserID(c) if err != nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } var req struct { Name string `json:"name" binding:"required"` ParentID *string `json:"parent_id"` } if err := c.ShouldBindJSON(&req); err != nil { RespondWithAppError(c, apperrors.NewValidationError("name is required")) return } var parentID *uuid.UUID if req.ParentID != nil && *req.ParentID != "" { pid, err := uuid.Parse(*req.ParentID) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("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) { RespondWithAppError(c, apperrors.NewNotFoundError("parent folder")) return } h.logger.Error("Failed to create folder", zap.Error(err)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to create folder", err)) return } c.JSON(http.StatusCreated, gin.H{"folder": folder}) } func (h *CloudHandler) RenameFolder(c *gin.Context) { userID, err := h.getUserID(c) if err != nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } folderID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid folder id")) return } var req struct { Name string `json:"name" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { RespondWithAppError(c, apperrors.NewValidationError("name is required")) return } if err := h.cloudService.RenameFolder(c.Request.Context(), userID, folderID, req.Name); err != nil { if errors.Is(err, services.ErrFolderNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("folder")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to rename folder", err)) 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 { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } folderID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid folder id")) return } if err := h.cloudService.DeleteFolder(c.Request.Context(), userID, folderID); err != nil { if errors.Is(err, services.ErrFolderNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("folder")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to delete folder", err)) 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 { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } var folderID *uuid.UUID if fidStr := c.Query("folder_id"); fidStr != "" { fid, err := uuid.Parse(fidStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("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)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to list files", err)) return } c.JSON(http.StatusOK, gin.H{"files": files}) } func (h *CloudHandler) UploadFile(c *gin.Context) { userID, err := h.getUserID(c) if err != nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } file, header, err := c.Request.FormFile("file") if err != nil { RespondWithAppError(c, apperrors.NewValidationError("file is required")) return } defer file.Close() data, err := io.ReadAll(file) if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to read file", err)) 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 { RespondWithAppError(c, apperrors.NewValidationError("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) { RespondWithAppError(c, apperrors.NewForbiddenError("storage quota exceeded")) return } if errors.Is(err, services.ErrInvalidMimeType) { RespondWithAppError(c, apperrors.NewValidationError("invalid file type")) return } if errors.Is(err, services.ErrFileTooLarge) { RespondWithAppError(c, apperrors.NewValidationError("file too large (max 500MB)")) return } h.logger.Error("Failed to upload file", zap.Error(err)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to upload file", err)) return } c.JSON(http.StatusCreated, gin.H{"file": result}) } func (h *CloudHandler) GetFile(c *gin.Context) { userID, err := h.getUserID(c) if err != nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } fileID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid file id")) return } file, err := h.cloudService.GetFileByID(c.Request.Context(), userID, fileID) if err != nil { RespondWithAppError(c, apperrors.NewNotFoundError("file")) return } c.JSON(http.StatusOK, gin.H{"file": file}) } func (h *CloudHandler) DeleteFile(c *gin.Context) { userID, err := h.getUserID(c) if err != nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } fileID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid file id")) return } if err := h.cloudService.DeleteFile(c.Request.Context(), userID, fileID); err != nil { if errors.Is(err, services.ErrFileNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("file")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to delete file", err)) 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 { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } fileID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid file id")) return } data, file, err := h.cloudService.StreamFile(c.Request.Context(), userID, fileID) if err != nil { if errors.Is(err, services.ErrFileNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("file")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to stream file", err)) 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 { RespondWithAppError(c, apperrors.NewValidationError("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 { RespondWithAppError(c, apperrors.NewValidationError("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 { RespondWithAppError(c, apperrors.NewValidationError("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 { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } quota, err := h.cloudService.GetQuota(c.Request.Context(), userID) if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get quota", err)) 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, }, }) } // ListVersions returns version history for a file func (h *CloudHandler) ListVersions(c *gin.Context) { userID, err := h.getUserID(c) if err != nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } fileID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid file id")) return } versions, err := h.cloudService.ListVersions(c.Request.Context(), userID, fileID) if err != nil { if errors.Is(err, services.ErrFileNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("file")) return } h.logger.Error("Failed to list versions", zap.Error(err)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to list versions", err)) return } c.JSON(http.StatusOK, gin.H{"versions": versions}) } // RestoreVersion restores a file to a previous version func (h *CloudHandler) RestoreVersion(c *gin.Context) { userID, err := h.getUserID(c) if err != nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } fileID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid file id")) return } versionStr := c.Param("version") version, err := strconv.Atoi(versionStr) if err != nil || version < 1 { RespondWithAppError(c, apperrors.NewValidationError("invalid version")) return } if err := h.cloudService.RestoreVersion(c.Request.Context(), userID, fileID, version); err != nil { if errors.Is(err, services.ErrFileNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("file")) return } h.logger.Error("Failed to restore version", zap.Error(err)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to restore version", err)) return } c.JSON(http.StatusOK, gin.H{"message": "version restored"}) } // ShareFile creates a share link func (h *CloudHandler) ShareFile(c *gin.Context) { userID, err := h.getUserID(c) if err != nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } fileID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid file id")) return } var req struct { Permissions string `json:"permissions"` ExpiresInHours int `json:"expires_in_hours"` } if err := c.ShouldBindJSON(&req); err != nil { req.Permissions = "read" req.ExpiresInHours = 24 } if req.Permissions == "" { req.Permissions = "read" } if req.ExpiresInHours <= 0 { req.ExpiresInHours = 24 } if req.ExpiresInHours > 168 { req.ExpiresInHours = 168 } share, err := h.cloudService.ShareFile(c.Request.Context(), userID, fileID, req.Permissions, req.ExpiresInHours) if err != nil { if errors.Is(err, services.ErrFileNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("file")) return } h.logger.Error("Failed to share file", zap.Error(err)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to share file", err)) return } baseURL := c.Request.URL.Scheme + "://" + c.Request.URL.Host shareURL := fmt.Sprintf("%s/api/v1/cloud/shared/%s", baseURL, share.Token) c.JSON(http.StatusOK, gin.H{ "share_url": shareURL, "token": share.Token, "expires_at": share.ExpiresAt, }) } // GetSharedFile returns file metadata for a share token (public, no auth) func (h *CloudHandler) GetSharedFile(c *gin.Context) { token := c.Param("token") if token == "" { RespondWithAppError(c, apperrors.NewValidationError("token required")) return } file, err := h.cloudService.GetSharedFile(c.Request.Context(), token) if err != nil { if errors.Is(err, services.ErrShareExpired) || errors.Is(err, services.ErrShareNotFound) || errors.Is(err, services.ErrFileNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("share link expired")) return } RespondWithAppError(c, apperrors.NewNotFoundError("file")) return } c.JSON(http.StatusOK, gin.H{"file": file}) } // CreateVersion creates a version snapshot of the file func (h *CloudHandler) CreateVersion(c *gin.Context) { userID, err := h.getUserID(c) if err != nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } fileID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid file id")) return } version, err := h.cloudService.CreateVersion(c.Request.Context(), userID, fileID) if err != nil { if errors.Is(err, services.ErrFileNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("file")) return } h.logger.Error("Failed to create version", zap.Error(err)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to create version", err)) return } c.JSON(http.StatusCreated, gin.H{"version": version}) } // C1-05: Publish cloud file as track func (h *CloudHandler) PublishAsTrack(c *gin.Context) { userID, err := h.getUserID(c) if err != nil { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return } fileID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid file id")) return } file, err := h.cloudService.GetFileByID(c.Request.Context(), userID, fileID) if err != nil { RespondWithAppError(c, apperrors.NewNotFoundError("file")) return } if !strings.HasPrefix(file.MimeType, "audio/") { RespondWithAppError(c, apperrors.NewValidationError("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, }) }