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:
parent
9f4c2183a2
commit
7974517c03
11 changed files with 411 additions and 47 deletions
|
|
@ -35,6 +35,7 @@ export function UploadModal({ open, onClose }: UploadModalProps) {
|
||||||
handleRetry,
|
handleRetry,
|
||||||
handleRemoveFile,
|
handleRemoveFile,
|
||||||
isRateLimited,
|
isRateLimited,
|
||||||
|
audioMaxHuman,
|
||||||
} = useUploadModal({ open, onClose });
|
} = useUploadModal({ open, onClose });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -57,6 +58,7 @@ export function UploadModal({ open, onClose }: UploadModalProps) {
|
||||||
getRootProps={getRootProps}
|
getRootProps={getRootProps}
|
||||||
getInputProps={getInputProps}
|
getInputProps={getInputProps}
|
||||||
isDragActive={isDragActive}
|
isDragActive={isDragActive}
|
||||||
|
audioMaxHuman={audioMaxHuman}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UploadModalFileDisplay
|
<UploadModalFileDisplay
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,16 @@ export interface UploadModalDropzoneProps {
|
||||||
getRootProps: () => object;
|
getRootProps: () => object;
|
||||||
getInputProps: () => object;
|
getInputProps: () => object;
|
||||||
isDragActive: boolean;
|
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({
|
export function UploadModalDropzone({
|
||||||
getRootProps,
|
getRootProps,
|
||||||
getInputProps,
|
getInputProps,
|
||||||
isDragActive,
|
isDragActive,
|
||||||
|
audioMaxHuman = '100MB',
|
||||||
}: UploadModalDropzoneProps) {
|
}: UploadModalDropzoneProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -30,7 +34,7 @@ export function UploadModalDropzone({
|
||||||
or click to select
|
or click to select
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
MAX_RETRY_ATTEMPTS,
|
MAX_RETRY_ATTEMPTS,
|
||||||
type UploadFormData,
|
type UploadFormData,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
import { useUploadLimits } from '@/features/upload/hooks/useUploadLimits';
|
||||||
|
|
||||||
export interface UseUploadModalProps {
|
export interface UseUploadModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -141,16 +142,23 @@ export function useUploadModal({ onClose }: UseUploadModalProps) {
|
||||||
[setValue, formData.title],
|
[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({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
accept: ACCEPTED_AUDIO_TYPES,
|
accept: ACCEPTED_AUDIO_TYPES,
|
||||||
maxSize: MAX_FILE_SIZE,
|
maxSize: audioMaxBytes,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
onError: (err) => setError(`Erreur lors de la sélection du fichier: ${err.message}`),
|
onError: (err) => setError(`Erreur lors de la sélection du fichier: ${err.message}`),
|
||||||
onDropRejected: (fileRejections) => {
|
onDropRejected: (fileRejections) => {
|
||||||
const rejection = fileRejections[0];
|
const rejection = fileRejections[0];
|
||||||
if (rejection?.errors[0]?.code === 'file-too-large') {
|
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') {
|
} else if (rejection?.errors[0]?.code === 'file-invalid-type') {
|
||||||
setError(
|
setError(
|
||||||
'Format de fichier non supporté. Formats acceptés: MP3, WAV, OGG, FLAC, M4A, AAC',
|
'Format de fichier non supporté. Formats acceptés: MP3, WAV, OGG, FLAC, M4A, AAC',
|
||||||
|
|
@ -226,5 +234,6 @@ export function useUploadModal({ onClose }: UseUploadModalProps) {
|
||||||
handleRetry,
|
handleRetry,
|
||||||
handleRemoveFile,
|
handleRemoveFile,
|
||||||
isRateLimited,
|
isRateLimited,
|
||||||
|
audioMaxHuman,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
apps/web/src/features/upload/hooks/useUploadLimits.test.tsx
Normal file
110
apps/web/src/features/upload/hooks/useUploadLimits.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
116
apps/web/src/features/upload/hooks/useUploadLimits.ts
Normal file
116
apps/web/src/features/upload/hooks/useUploadLimits.ts
Normal 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 };
|
||||||
87
veza-backend-api/internal/config/upload_limits.go
Normal file
87
veza-backend-api/internal/config/upload_limits.go
Normal 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())
|
||||||
|
}
|
||||||
52
veza-backend-api/internal/config/upload_limits_test.go
Normal file
52
veza-backend-api/internal/config/upload_limits_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time" // MOD-P2-008: Ajouté pour timeout asynchrone
|
"time" // MOD-P2-008: Ajouté pour timeout asynchrone
|
||||||
|
|
||||||
|
"veza-backend-api/internal/config"
|
||||||
"veza-backend-api/internal/core/discover"
|
"veza-backend-api/internal/core/discover"
|
||||||
"veza-backend-api/internal/database"
|
"veza-backend-api/internal/database"
|
||||||
"veza-backend-api/internal/models"
|
"veza-backend-api/internal/models"
|
||||||
|
|
@ -90,7 +91,7 @@ func NewTrackService(db *gorm.DB, logger *zap.Logger, uploadDir string) *TrackSe
|
||||||
readDB: nil,
|
readDB: nil,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
uploadDir: uploadDir,
|
uploadDir: uploadDir,
|
||||||
maxFileSize: 100 * 1024 * 1024, // 100MB
|
maxFileSize: config.AudioLimit.Bytes(),
|
||||||
batchService: NewTrackBatchService(db, logger),
|
batchService: NewTrackBatchService(db, logger),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +106,7 @@ func NewTrackServiceWithDB(db *database.Database, logger *zap.Logger, uploadDir
|
||||||
readDB: db.ForRead(),
|
readDB: db.ForRead(),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
uploadDir: uploadDir,
|
uploadDir: uploadDir,
|
||||||
maxFileSize: 100 * 1024 * 1024, // 100MB
|
maxFileSize: config.AudioLimit.Bytes(),
|
||||||
batchService: NewTrackBatchService(db.GormDB, logger),
|
batchService: NewTrackBatchService(db.GormDB, logger),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +132,7 @@ func (s *TrackService) SetDiscoverService(d *discover.Service) {
|
||||||
func (s *TrackService) ValidateTrackFile(fileHeader *multipart.FileHeader) error {
|
func (s *TrackService) ValidateTrackFile(fileHeader *multipart.FileHeader) error {
|
||||||
// Valider la taille
|
// Valider la taille
|
||||||
if fileHeader.Size > s.maxFileSize {
|
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 {
|
if fileHeader.Size == 0 {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"veza-backend-api/internal/common"
|
"veza-backend-api/internal/common"
|
||||||
|
"veza-backend-api/internal/config"
|
||||||
"veza-backend-api/internal/handlers"
|
"veza-backend-api/internal/handlers"
|
||||||
"veza-backend-api/internal/models"
|
"veza-backend-api/internal/models"
|
||||||
"veza-backend-api/internal/response"
|
"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"
|
return "Invalid file format. Allowed formats: MP3, FLAC, WAV, OGG"
|
||||||
}
|
}
|
||||||
if strings.Contains(errStr, "file size exceeds") || strings.Contains(errStr, "too large") {
|
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") {
|
if strings.Contains(errStr, "file is empty") {
|
||||||
return "The uploaded file is empty"
|
return "The uploaded file is empty"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"veza-backend-api/internal/config"
|
||||||
"veza-backend-api/internal/core/education"
|
"veza-backend-api/internal/core/education"
|
||||||
apperrors "veza-backend-api/internal/errors"
|
apperrors "veza-backend-api/internal/errors"
|
||||||
|
|
||||||
|
|
@ -697,10 +698,11 @@ func (h *EducationHandler) UploadLessonVideo(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file size (max 500MB)
|
// v1.0.6: uses the canonical video limit from config (honors
|
||||||
const maxVideoSize = 500 * 1024 * 1024
|
// MAX_UPLOAD_VIDEO_MB env override, matches GET /upload/limits).
|
||||||
if fileHeader.Size > maxVideoSize {
|
if fileHeader.Size > config.VideoLimit.Bytes() {
|
||||||
RespondWithAppError(c, apperrors.NewValidationError("Video file too large (max 500MB)"))
|
RespondWithAppError(c, apperrors.NewValidationError(
|
||||||
|
"Video file too large (max "+config.VideoLimit.HumanReadable()+")"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"veza-backend-api/internal/config"
|
||||||
apperrors "veza-backend-api/internal/errors"
|
apperrors "veza-backend-api/internal/errors"
|
||||||
"veza-backend-api/internal/services"
|
"veza-backend-api/internal/services"
|
||||||
"veza-backend-api/internal/upload"
|
"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 {
|
func (uh *UploadHandler) GetUploadLimits() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
limits := map[string]interface{}{
|
categoryJSON := func(l config.UploadLimitMB) map[string]interface{} {
|
||||||
"audio": map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"max_size": "100MB",
|
"max_size": l.HumanReadable(),
|
||||||
"max_size_bytes": 100 * 1024 * 1024,
|
"max_size_bytes": l.Bytes(),
|
||||||
"allowed_types": []string{
|
"allowed_types": l.AllowedMIMEs,
|
||||||
"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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RespondSuccess(c, http.StatusOK, gin.H{
|
RespondSuccess(c, http.StatusOK, gin.H{
|
||||||
"limits": limits,
|
"limits": map[string]interface{}{
|
||||||
|
"audio": categoryJSON(config.AudioLimit),
|
||||||
|
"image": categoryJSON(config.ImageLimit),
|
||||||
|
"video": categoryJSON(config.VideoLimit),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue