Upload files from the browser to Backblaze B2 with real-time progress tracking.
- UI:
/uploadpage, upload form component - API:
POST /upload
apps/web/src/components/upload/upload-form.tsx— orchestrates dropzone + progress + upload stateapps/web/src/components/upload/dropzone.tsx— drag-and-drop viareact-dropzoneapps/web/src/components/upload/upload-progress.tsx— per-file progress barsapps/web/src/lib/api-client.ts—uploadFile()using XHR for progress eventsservices/api/app/runtime/upload.py— HTTP handler, reads file chunksservices/api/app/service/upload.py— validates and orchestrates uploadservices/api/app/repo/b2_client.py—upload_file()via boto3put_objectservices/api/app/service/metadata.py—extract_metadata()after upload
- Upload handler pattern:
services/api/app/runtime/upload.py - Service orchestration pattern:
services/api/app/service/upload.py - Frontend upload flow:
apps/web/src/components/upload/upload-form.tsx
- file:
File(from browser, multipart form data) - content_type: string (from file MIME type)
FileUploadResponse: key, filename, size, content_type, uploaded_at, url, metadata- Side effects: file stored in B2 bucket under
uploads/{sanitized_filename}
- User drops or selects files in dropzone
- Client validates file size (max 100MB) and type — rejected files show toast with reason
- XHR sends multipart POST to
/uploadwith progress events - API checks
Content-Lengthheader early to reject oversized requests before reading body - API validates content type against allowlist
- API sanitizes filename (strips path components, null bytes, unsafe chars, limits to 200 chars)
- API validates file extension matches declared MIME type
- API reads file in 1MB chunks with streaming size enforcement (max 100MB)
- API rejects empty files
- API uses key:
uploads/{sanitized_filename} - API calls
put_objectto B2 - API extracts file metadata (checksums, image dimensions, PDF info)
- API returns
FileUploadResponse - Client shows toast and updates progress state
- File exceeds 100MB → client-side rejection toast + API returns 413 if bypassed
- File type not in allowlist → API returns 415
- File extension mismatches MIME type → API returns 415
- No filename provided → API returns 400
- Empty file → API returns 400
- Duplicate filename → B2 creates a new version (buckets are always versioned)
- B2 unreachable → API returns 500
- Upload aborted by user → XHR abort, error state in UI
- Empty: dropzone with instructions
- Loading: per-file progress bars with spinner icon
- Error: red status icon, error message per file
- Complete: green checkmark, "Clear completed" button
- Test files:
services/api/tests/test_upload_conflict.py,services/api/tests/test_error_handling.py - Required cases: successful upload, oversized file rejection, disallowed type rejection, missing filename, empty file, duplicate filename allowed
- Quick verify command:
pnpm test:api - Full verify command:
pnpm lint && pnpm lint:api && pnpm test:api && pnpm check:structure - Pass criteria: all pytest tests green, no ruff violations