Skip to content

feat(backend): implement file upload service for dispute evidence (#412)#452

Open
Jaydams wants to merge 1 commit into
StayLitCodes:mainfrom
Jaydams:feat/file-upload-service
Open

feat(backend): implement file upload service for dispute evidence (#412)#452
Jaydams wants to merge 1 commit into
StayLitCodes:mainfrom
Jaydams:feat/file-upload-service

Conversation

@Jaydams

@Jaydams Jaydams commented Jun 29, 2026

Copy link
Copy Markdown

Closes #412

Dispute resolution had no mechanism for parties to submit supporting
evidence files. This PR adds a complete, self-contained UploadModule
that handles everything from ingestion through storage, thumbnail
generation, access control, and deletion.


What Changed

New Module: apps/backend/src/modules/upload/

File Purpose
entities/dispute-evidence.entity.ts dispute_evidence table — stores all file metadata
interfaces/storage-adapter.interface.ts StorageAdapter contract (save / stream / delete / exists)
interfaces/virus-scanner.interface.ts VirusScanner contract (scan → {clean, threat})
adapters/local-storage.adapter.ts Default local filesystem implementation
adapters/s3-storage.adapter.ts S3 stub — bind STORAGE_ADAPTER token to swap in
adapters/noop-virus-scanner.adapter.ts ClamAV hook stub — bind VIRUS_SCANNER token to activate
guards/dispute-access.guard.ts Verifies caller is a party to the dispute's escrow
guards/upload-rate-limit.guard.ts In-memory 20 uploads / hour / user window
upload.service.ts Core business logic (validation, storage, thumbnail, scan)
upload.controller.ts Four REST endpoints under /disputes/:id/evidence
upload.module.ts Wires providers; swap adapters here without touching service
upload.service.spec.ts 12 unit tests

Supporting Changes

File Change
src/migrations/1780400000000-AddDisputeEvidence.ts Creates dispute_evidence table + two indices
src/app.module.ts Registers UploadModule and DisputeEvidence entity
package.json Adds sharp ^0.33.5 + @types/sharp ^0.31.1
test/e2e/upload.e2e-spec.ts E2E tests against in-memory SQLite

Endpoints

POST /disputes/:id/evidence

  • Guards: AuthGuardDisputeAccessGuardUploadRateLimitGuard
  • Multer memoryStorage, 10 MB hard limit
  • Runs magic-bytes MIME check → virus scan → 10-file cap → UUID rename → save → optional thumbnail
  • Returns EvidenceResponseDto

GET /disputes/:id/evidence

  • Guards: AuthGuardDisputeAccessGuard
  • Returns { data: EvidenceResponseDto[], total: number } ordered by createdAt ASC

GET /disputes/:id/evidence/:evidenceId/download

  • Guards: AuthGuardDisputeAccessGuard
  • Streams file with Content-Type, Content-Disposition: attachment, and Content-Length headers

DELETE /disputes/:id/evidence/:evidenceId

  • Guards: AuthGuardDisputeAccessGuardAdminGuard
  • Soft-deletes the DB record (sets isDeleted, deletedByUserId, deletedAt)
  • Immediately removes physical file and thumbnail from storage

Security

  • Magic-bytes validation — MIME type is detected from file header bytes, not the client-supplied Content-Type or file extension. This prevents extension-spoofing attacks.
  • UUID filenames — all files are stored under {uuid}{ext}, making the original name irrelevant on disk and eliminating path-traversal via crafted filenames.
  • Rate limiting — 20 uploads / hour / user via UploadRateLimitGuard. Keyed on authenticated user ID, not IP.
  • Access controlDisputeAccessGuard resolves the dispute → escrow link and verifies the caller is a party. Outsiders receive 403.
  • Admin-only deleteAdminGuard enforces ADMIN or SUPER_ADMIN role on the DELETE route, and the service double-checks isUserAdmin() before proceeding.
  • Virus scanning hook — every upload passes through the VirusScanner interface before being written to storage. The default NoopVirusScannerAdapter is a safe placeholder; replace with a ClamAV adapter by re-binding the VIRUS_SCANNER token in UploadModule.

Storage & Thumbnail

Local storage (default): files saved to ./uploads/evidence/{uuid}{ext}. Thumbnails saved to ./uploads/thumbnails/{uuid}_thumb.jpg.

S3 (future): implement StorageAdapter and provide via:

{ provide: STORAGE_ADAPTER, useClass: S3StorageAdapter }

sharp is used for 200×200 cover thumbnails on image uploads. If sharp is not installed the upload still succeeds  thumbnail generation is wrapped in a try/catch and the thumbnailPath column is stored as null.

---
How to Activate ClamAV

1. npm install clamdjs (or equivalent)
2. Implement VirusScanner in a ClamAvAdapter
3. In upload.module.ts replace:
{ provide: VIRUS_SCANNER, useClass: NoopVirusScannerAdapter }
3. with:
{ provide: VIRUS_SCANNER, useClass: ClamAvAdapter }

---
Test Plan

- [ ] Run unit tests: cd apps/backend && npm test -- upload
- [ ] Run E2E tests: cd apps/backend && npm run test:e2e -- --testPathPattern=upload
- [ ] POST /disputes/:id/evidence with a valid PNG  expect 201 + metadata JSON
- [ ] POST with an .exe (null-byte binary)  expect 422
- [ ] POST with a file >10 MB  expect 400
- [ ] POST 11 files to the same dispute  11th call returns 400
- [ ] GET /disputes/:id/evidence  lists all non-deleted files
- [ ] GET /disputes/:id/evidence/:evidenceId/download  streams binary with correct headers
- [ ] DELETE as non-admin  expect 403
- [ ] DELETE as admin  expect 204 and file removed from uploads/evidence/
- [ ] POST 21 files within an hour as the same user  21st returns 429
- [ ] Unauthenticated request to any endpoint  expect 401
- [ ] User with no party relationship to the dispute  expect 403
- [ ] Run migration on staging DB: npm run migration:run
- [ ] Install sharp after merging: npm install (sharp is native, verify build on CI)

…ayLitCodes#412)

Add UploadModule providing a complete evidence file management system
for disputes. The module is self-contained and uses abstract adapter
interfaces so storage (local → S3) and virus scanning (noop → ClamAV)
can be swapped without touching business logic.

New endpoints:
  POST   /disputes/:id/evidence                   upload a file
  GET    /disputes/:id/evidence                   list evidence
  GET    /disputes/:id/evidence/:evidenceId/download  stream download
  DELETE /disputes/:id/evidence/:evidenceId       admin-only soft delete

Key implementation details:
- Magic-bytes MIME validation (not extension trust) for PNG, JPEG,
  WebP, PDF, TXT, DOCX
- Max 10 MB per file enforced at multer + service layers
- Max 10 active files per dispute enforced at service layer
- UUID filenames on disk prevent path-traversal attacks
- Image thumbnails (200×200 JPEG) via sharp with graceful fallback
- LocalStorageAdapter (default) + S3StorageAdapter stub ready to wire
- VirusScanner interface with NoopVirusScannerAdapter; replace with
  ClamAV adapter by binding VIRUS_SCANNER token
- UploadRateLimitGuard: 20 uploads / hour / user (in-memory window)
- DisputeAccessGuard: checks caller is a party to the dispute's escrow
- Admin-only DELETE with soft-delete + physical file removal
- cleanOrphanedFiles() utility for scheduled cleanup
- Migration 1780400000000-AddDisputeEvidence adds dispute_evidence
  table with FK cascade and indices on disputeId / uploadedByUserId
- 12 unit tests covering all happy paths and error branches
- E2E tests against in-memory SQLite covering upload, list, download,
  access control, and error cases
@drips-wave

drips-wave Bot commented Jun 29, 2026

Copy link
Copy Markdown

@Jaydams Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(backend): Implement file upload service for dispute evidence

1 participant