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,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
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"
|
||||
"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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue