veza/veza-backend-api/internal/config/upload_limits.go
senke 7974517c03 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>
2026-04-16 19:37:37 +02:00

87 lines
2.6 KiB
Go

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