veza/veza-backend-api/internal/handlers/cloud_handler.go
senke 9024fa92a0
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
v0.9.8 beta
2026-03-07 00:54:35 +01:00

592 lines
16 KiB
Go

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,
})
}