592 lines
16 KiB
Go
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,
|
|
})
|
|
}
|