veza/veza-backend-api/internal/handlers/chat_attachment_handler.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.

The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
2026-04-14 12:22:14 +02:00

146 lines
4 KiB
Go

package handlers
import (
"fmt"
"io"
"net/http"
"path/filepath"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/services"
chatws "veza-backend-api/internal/websocket/chat"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
const chatAttachmentMaxSize = 50 * 1024 * 1024 // 50MB
// ChatAttachmentHandler handles chat file uploads (v0.9.7)
type ChatAttachmentHandler struct {
s3Service *services.S3StorageService
permissions *chatws.PermissionService
validator UploadValidatorInterface
logger *zap.Logger
urlExpirySec int64
}
// NewChatAttachmentHandler creates a new ChatAttachmentHandler
func NewChatAttachmentHandler(
s3Service *services.S3StorageService,
permissions *chatws.PermissionService,
validator UploadValidatorInterface,
logger *zap.Logger,
) *ChatAttachmentHandler {
if logger == nil {
logger = zap.NewNop()
}
return &ChatAttachmentHandler{
s3Service: s3Service,
permissions: permissions,
validator: validator,
logger: logger,
urlExpirySec: 86400, // 24h
}
}
// UploadChatAttachment handles POST /api/v1/chat/rooms/:roomId/attachments
// Accepts: audio (mp3, wav, ogg), image (jpg, png), PDF. Max 50MB.
func (h *ChatAttachmentHandler) UploadChatAttachment(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
roomID, err := uuid.Parse(c.Param("roomId"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid room ID"))
return
}
if !h.permissions.CanRead(c.Request.Context(), userID, roomID) {
RespondWithAppError(c, apperrors.NewForbiddenError("Not allowed to access this conversation"))
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("No file provided"))
return
}
if fileHeader.Size > chatAttachmentMaxSize {
RespondWithAppError(c, apperrors.NewValidationError("File too large. Max 50MB"))
return
}
if fileHeader.Size == 0 {
RespondWithAppError(c, apperrors.NewValidationError("Empty file"))
return
}
fileType := h.validator.GetFileTypeFromPath(fileHeader.Filename)
if fileType == "unknown" {
RespondWithAppError(c, apperrors.NewValidationError("Unsupported file type. Allowed: mp3, wav, ogg, jpg, png, pdf"))
return
}
validationResult, err := h.validator.ValidateFile(c.Request.Context(), fileHeader, fileType)
if err != nil {
h.logger.Warn("Chat attachment validation failed", zap.Error(err), zap.String("filename", fileHeader.Filename))
RespondWithAppError(c, apperrors.NewValidationError("File validation failed: "+err.Error()))
return
}
if !validationResult.Valid {
RespondWithAppError(c, apperrors.NewValidationError(validationResult.Error))
return
}
if validationResult.Quarantined {
RespondWithAppError(c, apperrors.NewForbiddenError("File rejected: virus detected"))
return
}
file, err := fileHeader.Open()
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to read file", err))
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to read file", err))
return
}
ext := filepath.Ext(fileHeader.Filename)
if ext == "" {
ext = ".bin"
}
key := fmt.Sprintf("chat/%s/%s/%s%s", roomID, userID, uuid.New().String(), ext)
_, err = h.s3Service.UploadFile(c.Request.Context(), data, key, fileHeader.Header.Get("Content-Type"))
if err != nil {
h.logger.Error("Failed to upload chat attachment", zap.Error(err), zap.String("key", key))
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to upload file", err))
return
}
// Return presigned URL for download (24h)
signedURL, err := h.s3Service.GetPresignedURL(c.Request.Context(), key)
if err != nil {
// Fallback to public URL if presigned fails (e.g. public bucket)
signedURL = h.s3Service.GetPublicURL(key)
}
RespondSuccess(c, http.StatusCreated, gin.H{
"url": signedURL,
"file_id": uuid.New().String(),
"file_name": fileHeader.Filename,
"file_size": fileHeader.Size,
"file_type": fileType,
})
}