feat(backend,web): single source of truth for upload-size limits

Second item of the v1.0.6 backlog. The "front 500MB vs back 100MB" mismatch
flagged in the v1.0.5 audit turned out to be a misread — every live pair
was already aligned (tracks 100/100, cloud 500/500, video 500/500). The
real bug is architectural: the same byte values were duplicated in five
places (`track/service.go`, `handlers/upload.go:GetUploadLimits`,
`handlers/education_handler.go`, `upload-modal/constants.ts`, and
`CloudUploadModal.tsx`), drifting silently as soon as anyone tuned one.

Backend — one canonical spec at `internal/config/upload_limits.go`:
  * `AudioLimit`, `ImageLimit`, `VideoLimit` expose `Bytes()`, `MB()`,
    `HumanReadable()`, `AllowedMIMEs` — read lazily from env
    (`MAX_UPLOAD_AUDIO_MB`, `MAX_UPLOAD_IMAGE_MB`, `MAX_UPLOAD_VIDEO_MB`)
    with defaults 100/10/500.
  * Invalid / negative / zero env values fall back to the default;
    unreadable config can't turn the limit off silently.
  * `track.Service.maxFileSize`, `track_upload_handler.go` error string,
    `education_handler.go` video gate, and `upload.go:GetUploadLimits`
    all read from this single source. Changing `MAX_UPLOAD_AUDIO_MB`
    retunes every path at once.

Frontend — new `useUploadLimits()` hook:
  * Fetches GET `/api/v1/upload/limits` via react-query (5 min stale,
    30 min gc), one retry, then silently falls back to baked-in
    defaults that match the backend compile-time defaults so the
    dropzone stays responsive even without the network round-trip.
  * `useUploadModal.ts` replaces its hardcoded `MAX_FILE_SIZE`
    constant with `useUploadLimits().audio.maxBytes`, and surfaces
    `audioMaxHuman` up to `UploadModal` → `UploadModalDropzone` so
    the "max 100 MB" label and the "too large" error toast both
    display the live value.
  * `MAX_FILE_SIZE` constant kept as pure fallback for pre-network
    render (documented as such).

Tests
  * 4 Go tests on `config.UploadLimit` (defaults, env override, invalid
    env → fallback, non-empty MIME lists).
  * 4 Vitest tests on `useUploadLimits` (sync fallback on first render,
    typed mapping from server payload, partial-payload falls back
    per-category, network failure keeps fallback).
  * Existing `trackUpload.integration.test.tsx` (11 cases) still green.

Out of scope (tracked for later):
  * `CloudUploadModal.tsx` still has its own 500MB hardcoded — cloud
    uploads accept audio+zip+midi with a different category semantic
    than the three in `/upload/limits`. Unifying those deserves its
    own design pass, not a drive-by.
  * No runtime refactor of admin-provided custom category limits —
    the current tri-category split covers every upload we ship today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-16 19:37:37 +02:00
parent 9f4c2183a2
commit 7974517c03
11 changed files with 411 additions and 47 deletions

View file

@ -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}
/>
) : (
<UploadModalFileDisplay

View file

@ -4,12 +4,16 @@ export interface UploadModalDropzoneProps {
getRootProps: () => 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 (
<div
@ -30,7 +34,7 @@ export function UploadModalDropzone({
or click to select
</p>
<p className="text-xs text-muted-foreground">
Accepted formats: MP3, WAV, OGG, FLAC, M4A, AAC (max 100 MB)
Accepted formats: MP3, WAV, OGG, FLAC, M4A, AAC (max {audioMaxHuman})
</p>
</div>
);

View file

@ -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,
};
}

View file

@ -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 }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
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);
});
});

View file

@ -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<ServerLimitsResponse>(
'/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 };

View file

@ -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())
}

View file

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

View file

@ -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 {

View file

@ -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"

View file

@ -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
}

View file

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