diff --git a/apps/web/src/features/upload/components/upload-modal/UploadModal.tsx b/apps/web/src/features/upload/components/upload-modal/UploadModal.tsx index f5da9af95..3460fcd75 100644 --- a/apps/web/src/features/upload/components/upload-modal/UploadModal.tsx +++ b/apps/web/src/features/upload/components/upload-modal/UploadModal.tsx @@ -35,6 +35,7 @@ export function UploadModal({ open, onClose }: UploadModalProps) { handleRetry, handleRemoveFile, isRateLimited, + audioMaxHuman, } = useUploadModal({ open, onClose }); return ( @@ -57,6 +58,7 @@ export function UploadModal({ open, onClose }: UploadModalProps) { getRootProps={getRootProps} getInputProps={getInputProps} isDragActive={isDragActive} + audioMaxHuman={audioMaxHuman} /> ) : ( object; getInputProps: () => object; isDragActive: boolean; + // v1.0.6: the human-readable size comes from the backend via useUploadLimits + // so the message tracks MAX_UPLOAD_AUDIO_MB without redeploying the front. + audioMaxHuman?: string; } export function UploadModalDropzone({ getRootProps, getInputProps, isDragActive, + audioMaxHuman = '100MB', }: UploadModalDropzoneProps) { return (

- Accepted formats: MP3, WAV, OGG, FLAC, M4A, AAC (max 100 MB) + Accepted formats: MP3, WAV, OGG, FLAC, M4A, AAC (max {audioMaxHuman})

); diff --git a/apps/web/src/features/upload/components/upload-modal/useUploadModal.ts b/apps/web/src/features/upload/components/upload-modal/useUploadModal.ts index 8ebaaa5e0..0e5a92998 100644 --- a/apps/web/src/features/upload/components/upload-modal/useUploadModal.ts +++ b/apps/web/src/features/upload/components/upload-modal/useUploadModal.ts @@ -10,6 +10,7 @@ import { MAX_RETRY_ATTEMPTS, type UploadFormData, } from './constants'; +import { useUploadLimits } from '@/features/upload/hooks/useUploadLimits'; export interface UseUploadModalProps { open: boolean; @@ -141,16 +142,23 @@ export function useUploadModal({ onClose }: UseUploadModalProps) { [setValue, formData.title], ); + // v1.0.6: pull the live audio upload limit from the backend + // (GET /api/v1/upload/limits). Falls back to the MAX_FILE_SIZE constant + // while the request is in flight so the dropzone stays responsive. + const uploadLimits = useUploadLimits(); + const audioMaxBytes = uploadLimits.audio.maxBytes || MAX_FILE_SIZE; + const audioMaxHuman = uploadLimits.audio.humanReadable; + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: ACCEPTED_AUDIO_TYPES, - maxSize: MAX_FILE_SIZE, + maxSize: audioMaxBytes, multiple: false, onError: (err) => setError(`Erreur lors de la sélection du fichier: ${err.message}`), onDropRejected: (fileRejections) => { const rejection = fileRejections[0]; if (rejection?.errors[0]?.code === 'file-too-large') { - setError('Le fichier est trop volumineux (max 100 MB)'); + setError(`Le fichier est trop volumineux (max ${audioMaxHuman})`); } else if (rejection?.errors[0]?.code === 'file-invalid-type') { setError( 'Format de fichier non supporté. Formats acceptés: MP3, WAV, OGG, FLAC, M4A, AAC', @@ -226,5 +234,6 @@ export function useUploadModal({ onClose }: UseUploadModalProps) { handleRetry, handleRemoveFile, isRateLimited, + audioMaxHuman, }; } diff --git a/apps/web/src/features/upload/hooks/useUploadLimits.test.tsx b/apps/web/src/features/upload/hooks/useUploadLimits.test.tsx new file mode 100644 index 000000000..a1c9aaf01 --- /dev/null +++ b/apps/web/src/features/upload/hooks/useUploadLimits.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +import { + FALLBACK_LIMITS, + useUploadLimits, +} from './useUploadLimits'; + +const mockGet = vi.fn(); + +vi.mock('@/services/api/client', () => ({ + apiClient: { + get: (url: string, opts?: unknown) => mockGet(url, opts), + }, +})); + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe('useUploadLimits', () => { + beforeEach(() => { + mockGet.mockReset(); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns fallbacks synchronously on first render', () => { + // Never resolve — we only want the initial (pre-fetch) return value. + mockGet.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useUploadLimits(), { wrapper: makeWrapper() }); + + expect(result.current.audio.maxBytes).toBe(FALLBACK_LIMITS.audio.maxBytes); + expect(result.current.audio.humanReadable).toBe('100MB'); + }); + + it('maps the /upload/limits response to typed categories', async () => { + mockGet.mockResolvedValueOnce({ + data: { + data: { + limits: { + audio: { + max_size: '250MB', + max_size_bytes: 250 * 1024 * 1024, + allowed_types: ['audio/mpeg'], + }, + image: { + max_size: '20MB', + max_size_bytes: 20 * 1024 * 1024, + allowed_types: ['image/png'], + }, + video: { + max_size: '1GB', + max_size_bytes: 1024 * 1024 * 1024, + allowed_types: ['video/mp4'], + }, + }, + }, + }, + }); + const { result } = renderHook(() => useUploadLimits(), { wrapper: makeWrapper() }); + + await waitFor(() => expect(result.current.audio.maxBytes).toBe(250 * 1024 * 1024)); + expect(result.current.audio.humanReadable).toBe('250MB'); + expect(result.current.audio.allowedMimeTypes).toEqual(['audio/mpeg']); + expect(result.current.image.maxBytes).toBe(20 * 1024 * 1024); + expect(result.current.video.maxBytes).toBe(1024 * 1024 * 1024); + }); + + it('falls back to defaults when server payload is partial', async () => { + mockGet.mockResolvedValueOnce({ + data: { + data: { + limits: { + // audio missing entirely, image partial, video broken + image: { max_size: '15MB', max_size_bytes: 15 * 1024 * 1024 }, + video: { max_size_bytes: -1 }, + }, + }, + }, + }); + const { result } = renderHook(() => useUploadLimits(), { wrapper: makeWrapper() }); + + await waitFor(() => expect(result.current.image.maxBytes).toBe(15 * 1024 * 1024)); + expect(result.current.audio.maxBytes).toBe(FALLBACK_LIMITS.audio.maxBytes); + // video had an invalid max_size_bytes (-1) → must keep the fallback + expect(result.current.video.maxBytes).toBe(FALLBACK_LIMITS.video.maxBytes); + // and inherit the fallback allowed_types list + expect(result.current.image.allowedMimeTypes).toEqual(FALLBACK_LIMITS.image.allowedMimeTypes); + }); + + it('keeps returning the fallback when the request fails', async () => { + mockGet.mockRejectedValueOnce(new Error('network down')); + const { result } = renderHook(() => useUploadLimits(), { wrapper: makeWrapper() }); + + // Wait long enough for the retry+failure to settle. + await new Promise((r) => setTimeout(r, 20)); + expect(result.current.audio.maxBytes).toBe(FALLBACK_LIMITS.audio.maxBytes); + expect(result.current.image.maxBytes).toBe(FALLBACK_LIMITS.image.maxBytes); + expect(result.current.video.maxBytes).toBe(FALLBACK_LIMITS.video.maxBytes); + }); +}); diff --git a/apps/web/src/features/upload/hooks/useUploadLimits.ts b/apps/web/src/features/upload/hooks/useUploadLimits.ts new file mode 100644 index 000000000..20050e466 --- /dev/null +++ b/apps/web/src/features/upload/hooks/useUploadLimits.ts @@ -0,0 +1,116 @@ +/** + * useUploadLimits — v1.0.6 single source of truth for upload caps. + * + * Fetches GET /api/v1/upload/limits once per session (5 min TTL) and exposes + * the per-category byte limits + allowed MIME types. Every upload modal + * (tracks, cloud, education, covers) should consume this instead of its own + * hardcoded constant so a single backend env var (`MAX_UPLOAD_AUDIO_MB`, + * `MAX_UPLOAD_VIDEO_MB`, `MAX_UPLOAD_IMAGE_MB`) can retune all limits. + * + * The hook returns safe defaults while the network request is in flight so + * the dropzone dimensions and validators stay correct even on first render. + */ + +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/services/api/client'; + +export interface UploadLimitCategory { + maxBytes: number; + humanReadable: string; + allowedMimeTypes: string[]; +} + +export interface UploadLimits { + audio: UploadLimitCategory; + image: UploadLimitCategory; + video: UploadLimitCategory; +} + +// Defaults must stay in sync with `internal/config/upload_limits.go`. They're +// only ever used when the API is unreachable on first render. +const FALLBACK_LIMITS: UploadLimits = { + audio: { + maxBytes: 100 * 1024 * 1024, + humanReadable: '100MB', + allowedMimeTypes: [ + 'audio/mpeg', + 'audio/mp3', + 'audio/wav', + 'audio/flac', + 'audio/aac', + 'audio/ogg', + 'audio/m4a', + ], + }, + image: { + maxBytes: 10 * 1024 * 1024, + humanReadable: '10MB', + allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + }, + video: { + maxBytes: 500 * 1024 * 1024, + humanReadable: '500MB', + allowedMimeTypes: ['video/mp4', 'video/webm', 'video/ogg', 'video/avi'], + }, +}; + +interface ServerLimitCategory { + max_size?: string; + max_size_bytes?: number; + allowed_types?: string[]; +} + +interface ServerLimitsResponse { + data?: { + limits?: { + audio?: ServerLimitCategory; + image?: ServerLimitCategory; + video?: ServerLimitCategory; + }; + }; +} + +function toCategory( + server: ServerLimitCategory | undefined, + fallback: UploadLimitCategory, +): UploadLimitCategory { + if (!server) return fallback; + return { + maxBytes: + typeof server.max_size_bytes === 'number' && server.max_size_bytes > 0 + ? server.max_size_bytes + : fallback.maxBytes, + humanReadable: server.max_size ?? fallback.humanReadable, + allowedMimeTypes: + Array.isArray(server.allowed_types) && server.allowed_types.length > 0 + ? server.allowed_types + : fallback.allowedMimeTypes, + }; +} + +export const UPLOAD_LIMITS_QUERY_KEY = ['upload', 'limits'] as const; + +export function useUploadLimits(): UploadLimits { + const { data } = useQuery({ + queryKey: UPLOAD_LIMITS_QUERY_KEY, + queryFn: async ({ signal }) => { + const response = await apiClient.get( + '/upload/limits', + { signal }, + ); + const payload = response.data as ServerLimitsResponse; + return { + audio: toCategory(payload?.data?.limits?.audio, FALLBACK_LIMITS.audio), + image: toCategory(payload?.data?.limits?.image, FALLBACK_LIMITS.image), + video: toCategory(payload?.data?.limits?.video, FALLBACK_LIMITS.video), + } satisfies UploadLimits; + }, + staleTime: 5 * 60 * 1000, // 5 min — limits change at most once per deploy + gcTime: 30 * 60 * 1000, + retry: 1, // one retry, then fall back silently — dropzone must stay responsive + }); + + return data ?? FALLBACK_LIMITS; +} + +export { FALLBACK_LIMITS }; diff --git a/veza-backend-api/internal/config/upload_limits.go b/veza-backend-api/internal/config/upload_limits.go new file mode 100644 index 000000000..281a375e4 --- /dev/null +++ b/veza-backend-api/internal/config/upload_limits.go @@ -0,0 +1,87 @@ +package config + +import ( + "fmt" + "os" + "strconv" +) + +// v1.0.6: single source of truth for upload-size limits. Previously the same +// values were duplicated in track/service.go, handlers/upload.go, +// handlers/education_handler.go, apps/web/.../upload-modal/constants.ts, and +// apps/web/.../CloudUploadModal.tsx — a drift waiting to happen. The frontend +// now consumes these via GET /api/v1/upload/limits and the backend reads them +// from env with sane defaults. + +const ( + // DefaultAudioMaxMB is the default upper bound for uploaded audio tracks. + DefaultAudioMaxMB = 100 + // DefaultImageMaxMB is the default upper bound for cover art / avatars. + DefaultImageMaxMB = 10 + // DefaultVideoMaxMB is the default upper bound for education videos. + DefaultVideoMaxMB = 500 +) + +// UploadLimitMB represents a single category's limit and its environment +// variable override. The bytes form is the authoritative value used for +// validation; the human form is what we send back to the UI. +type UploadLimitMB struct { + Category string + EnvVar string + DefaultMB int + AllowedMIMEs []string +} + +// AudioLimit, ImageLimit, VideoLimit expose the category definitions used by +// both the upload handler and the track service. Values are resolved lazily +// from the environment (no global state) so config changes take effect on +// the next request without a rebuild. +var ( + AudioLimit = UploadLimitMB{ + Category: "audio", + EnvVar: "MAX_UPLOAD_AUDIO_MB", + DefaultMB: DefaultAudioMaxMB, + AllowedMIMEs: []string{ + "audio/mpeg", + "audio/mp3", + "audio/wav", + "audio/flac", + "audio/aac", + "audio/ogg", + "audio/m4a", + }, + } + ImageLimit = UploadLimitMB{ + Category: "image", + EnvVar: "MAX_UPLOAD_IMAGE_MB", + DefaultMB: DefaultImageMaxMB, + AllowedMIMEs: []string{"image/jpeg", "image/png", "image/gif", "image/webp"}, + } + VideoLimit = UploadLimitMB{ + Category: "video", + EnvVar: "MAX_UPLOAD_VIDEO_MB", + DefaultMB: DefaultVideoMaxMB, + AllowedMIMEs: []string{"video/mp4", "video/webm", "video/ogg", "video/avi"}, + } +) + +// Bytes returns the category's current byte limit, honoring the env override. +// Invalid or negative env values fall back to the compile-time default. +func (l UploadLimitMB) Bytes() int64 { + return int64(l.MB()) * 1024 * 1024 +} + +// MB returns the category's current megabyte limit, honoring the env override. +func (l UploadLimitMB) MB() int { + if raw := os.Getenv(l.EnvVar); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + return v + } + } + return l.DefaultMB +} + +// HumanReadable renders the limit in "%dMB" form for UI consumption. +func (l UploadLimitMB) HumanReadable() string { + return fmt.Sprintf("%dMB", l.MB()) +} diff --git a/veza-backend-api/internal/config/upload_limits_test.go b/veza-backend-api/internal/config/upload_limits_test.go new file mode 100644 index 000000000..c77f4568d --- /dev/null +++ b/veza-backend-api/internal/config/upload_limits_test.go @@ -0,0 +1,52 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUploadLimit_DefaultBytesAndMB(t *testing.T) { + t.Setenv(AudioLimit.EnvVar, "") + t.Setenv(ImageLimit.EnvVar, "") + t.Setenv(VideoLimit.EnvVar, "") + + assert.Equal(t, 100, AudioLimit.MB(), "audio default must match historical 100MB value") + assert.Equal(t, int64(100*1024*1024), AudioLimit.Bytes()) + assert.Equal(t, "100MB", AudioLimit.HumanReadable()) + + assert.Equal(t, 10, ImageLimit.MB()) + assert.Equal(t, int64(10*1024*1024), ImageLimit.Bytes()) + assert.Equal(t, "10MB", ImageLimit.HumanReadable()) + + assert.Equal(t, 500, VideoLimit.MB()) + assert.Equal(t, int64(500*1024*1024), VideoLimit.Bytes()) +} + +func TestUploadLimit_EnvOverride(t *testing.T) { + t.Setenv(AudioLimit.EnvVar, "250") + + assert.Equal(t, 250, AudioLimit.MB()) + assert.Equal(t, int64(250*1024*1024), AudioLimit.Bytes()) + assert.Equal(t, "250MB", AudioLimit.HumanReadable()) +} + +func TestUploadLimit_InvalidEnvFallsBackToDefault(t *testing.T) { + t.Setenv(AudioLimit.EnvVar, "not-a-number") + assert.Equal(t, DefaultAudioMaxMB, AudioLimit.MB(), + "non-numeric env must fall back to default") + + t.Setenv(AudioLimit.EnvVar, "-50") + assert.Equal(t, DefaultAudioMaxMB, AudioLimit.MB(), + "negative env must fall back to default") + + t.Setenv(AudioLimit.EnvVar, "0") + assert.Equal(t, DefaultAudioMaxMB, AudioLimit.MB(), + "zero env must fall back to default") +} + +func TestUploadLimit_AllowedMIMEsAreNonEmpty(t *testing.T) { + for _, l := range []UploadLimitMB{AudioLimit, ImageLimit, VideoLimit} { + assert.NotEmpty(t, l.AllowedMIMEs, "category %s must declare its MIME list", l.Category) + } +} diff --git a/veza-backend-api/internal/core/track/service.go b/veza-backend-api/internal/core/track/service.go index 59143a3e8..35daf2515 100644 --- a/veza-backend-api/internal/core/track/service.go +++ b/veza-backend-api/internal/core/track/service.go @@ -13,6 +13,7 @@ import ( "strings" "time" // MOD-P2-008: Ajouté pour timeout asynchrone + "veza-backend-api/internal/config" "veza-backend-api/internal/core/discover" "veza-backend-api/internal/database" "veza-backend-api/internal/models" @@ -90,7 +91,7 @@ func NewTrackService(db *gorm.DB, logger *zap.Logger, uploadDir string) *TrackSe readDB: nil, logger: logger, uploadDir: uploadDir, - maxFileSize: 100 * 1024 * 1024, // 100MB + maxFileSize: config.AudioLimit.Bytes(), batchService: NewTrackBatchService(db, logger), } } @@ -105,7 +106,7 @@ func NewTrackServiceWithDB(db *database.Database, logger *zap.Logger, uploadDir readDB: db.ForRead(), logger: logger, uploadDir: uploadDir, - maxFileSize: 100 * 1024 * 1024, // 100MB + maxFileSize: config.AudioLimit.Bytes(), batchService: NewTrackBatchService(db.GormDB, logger), } } @@ -131,7 +132,7 @@ func (s *TrackService) SetDiscoverService(d *discover.Service) { func (s *TrackService) ValidateTrackFile(fileHeader *multipart.FileHeader) error { // Valider la taille if fileHeader.Size > s.maxFileSize { - return fmt.Errorf("%w: file size exceeds maximum allowed size of 100MB", ErrTrackTooLarge) + return fmt.Errorf("%w: file size exceeds maximum allowed size of %s", ErrTrackTooLarge, config.AudioLimit.HumanReadable()) } if fileHeader.Size == 0 { diff --git a/veza-backend-api/internal/core/track/track_upload_handler.go b/veza-backend-api/internal/core/track/track_upload_handler.go index 28e5e8e71..e90b68fa9 100644 --- a/veza-backend-api/internal/core/track/track_upload_handler.go +++ b/veza-backend-api/internal/core/track/track_upload_handler.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" "veza-backend-api/internal/common" + "veza-backend-api/internal/config" "veza-backend-api/internal/handlers" "veza-backend-api/internal/models" "veza-backend-api/internal/response" @@ -476,7 +477,7 @@ func (h *TrackHandler) mapTrackError(err error) string { return "Invalid file format. Allowed formats: MP3, FLAC, WAV, OGG" } if strings.Contains(errStr, "file size exceeds") || strings.Contains(errStr, "too large") { - return "File size exceeds maximum allowed size of 100MB" + return "File size exceeds maximum allowed size of " + config.AudioLimit.HumanReadable() } if strings.Contains(errStr, "file is empty") { return "The uploaded file is empty" diff --git a/veza-backend-api/internal/handlers/education_handler.go b/veza-backend-api/internal/handlers/education_handler.go index 12670e701..8a6391545 100644 --- a/veza-backend-api/internal/handlers/education_handler.go +++ b/veza-backend-api/internal/handlers/education_handler.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" + "veza-backend-api/internal/config" "veza-backend-api/internal/core/education" apperrors "veza-backend-api/internal/errors" @@ -697,10 +698,11 @@ func (h *EducationHandler) UploadLessonVideo(c *gin.Context) { return } - // Validate file size (max 500MB) - const maxVideoSize = 500 * 1024 * 1024 - if fileHeader.Size > maxVideoSize { - RespondWithAppError(c, apperrors.NewValidationError("Video file too large (max 500MB)")) + // v1.0.6: uses the canonical video limit from config (honors + // MAX_UPLOAD_VIDEO_MB env override, matches GET /upload/limits). + if fileHeader.Size > config.VideoLimit.Bytes() { + RespondWithAppError(c, apperrors.NewValidationError( + "Video file too large (max "+config.VideoLimit.HumanReadable()+")")) return } diff --git a/veza-backend-api/internal/handlers/upload.go b/veza-backend-api/internal/handlers/upload.go index 0aefbe271..dfe1e5f7d 100644 --- a/veza-backend-api/internal/handlers/upload.go +++ b/veza-backend-api/internal/handlers/upload.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "veza-backend-api/internal/config" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" "veza-backend-api/internal/upload" @@ -462,47 +463,26 @@ func (uh *UploadHandler) ValidateFileType() gin.HandlerFunc { } } -// GetUploadLimits récupère les limites d'upload +// GetUploadLimits returns the current upload size limits by category. +// v1.0.6: values come from config.AudioLimit/ImageLimit/VideoLimit which +// read env vars at request time so a single canonical source is served +// to every caller (backend services + frontend dropzone). func (uh *UploadHandler) GetUploadLimits() gin.HandlerFunc { return func(c *gin.Context) { - limits := map[string]interface{}{ - "audio": map[string]interface{}{ - "max_size": "100MB", - "max_size_bytes": 100 * 1024 * 1024, - "allowed_types": []string{ - "audio/mpeg", - "audio/mp3", - "audio/wav", - "audio/flac", - "audio/aac", - "audio/ogg", - "audio/m4a", - }, - }, - "image": map[string]interface{}{ - "max_size": "10MB", - "max_size_bytes": 10 * 1024 * 1024, - "allowed_types": []string{ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - }, - }, - "video": map[string]interface{}{ - "max_size": "500MB", - "max_size_bytes": 500 * 1024 * 1024, - "allowed_types": []string{ - "video/mp4", - "video/webm", - "video/ogg", - "video/avi", - }, - }, + categoryJSON := func(l config.UploadLimitMB) map[string]interface{} { + return map[string]interface{}{ + "max_size": l.HumanReadable(), + "max_size_bytes": l.Bytes(), + "allowed_types": l.AllowedMIMEs, + } } RespondSuccess(c, http.StatusOK, gin.H{ - "limits": limits, + "limits": map[string]interface{}{ + "audio": categoryJSON(config.AudioLimit), + "image": categoryJSON(config.ImageLimit), + "video": categoryJSON(config.VideoLimit), + }, }) } }