121 lines
6.8 KiB
Markdown

# File Backend
## Purpose
The file slice handles binary file upload and download. Uploaded files are stored either on
local disk (development) or in Google Cloud Storage (production), selected at request time. The
`file` table records file metadata (name, URLs, owning relation) and is written indirectly by the
file DAL when entity relations are persisted, not by the upload endpoint itself.
## Upload client contract
When a typed frontend upload client is built, it follows the same multipart
contract the reference frontend used (preserved here so it isn't lost):
1. Generate a unique filename: `${uuid}.${ext}` (extension from the picked file).
2. `POST /api/file/upload/:table/:field` as `multipart/form-data` with fields
**`file`** (the binary) and **`filename`** (the generated name).
3. The stored `privateUrl` is `${table}/${field}/${filename}`; the playable/
download URL is `${API_BASE_URL}/file/download?privateUrl=<privateUrl>` (works
for both the local-disk dev backend and the GCloud prod backend).
Downloads are JWT-only by customer decision. The standalone `/file/upload/:table/:field`
path can therefore serve uploaded logos/avatars/files even when it does not create a
tracked `file` row.
## Slice Files (by layer)
- Route: `src/routes/file.ts` (thin wiring; `GET /download`, `POST /upload/:table/:field`).
- Controller: `src/api/controllers/file.controller.ts` (custom — `download` and `upload`). `download`
calls `assertCanDownloadFile` before serving.
- Service (BLL): `src/services/file.ts` (`uploadLocal`, `downloadLocal`, `uploadGCloud`,
`downloadGCloud`, `deleteGCloud`, `initGCloud`) for the storage I/O, plus
`src/services/file-access.ts` (`assertCanDownloadFile`) for download authorization. Both
upload and download require JWT; local handlers reject path traversal. Download no longer
enforces per-file tenant ownership.
- Repository (DAL): `src/db/api/file.ts` (`FileDBApi``replaceRelationFiles`, `_addFiles`,
`_removeLegacyFiles`); persists/removes `file` rows for entity relations. Note: this DAL imports
`@/services/file` to call `deleteGCloud` (see Behavior / Notes).
- Model: `src/db/models/file.ts`.
- Shared used: `api/http/request.ts` (`paramStr`), `middlewares/upload.ts` (`processFile` multer
wrapper), `shared/config.ts` (`config.uploadDir`, `config.gcloud.bucket`, `config.gcloud.hash`),
`shared/errors/validation.ts` (`ValidationError`, in the DAL).
## API
- `GET /api/file/download?privateUrl=<path>` -> downloads the file. **Requires JWT authentication**
(`passport.authenticate('jwt')`). `assertCanDownloadFile` verifies that a current user exists,
then the controller dispatches to `downloadGCloud` when `NODE_ENV === 'production'` or
`NEXT_PUBLIC_BACK_API` is set, otherwise `downloadLocal`.
- Local: missing `privateUrl` -> `404`; a `privateUrl` that escapes the upload dir (path
traversal via `..`) -> `403` (`resolveWithinUploadDir`); otherwise streams via `res.download`
from `config.uploadDir`.
- GCloud: serves `${hash}/${privateUrl}` from the bucket; file missing or error -> `404`
`{ message }`.
- `POST /api/file/upload/:table/:field` -> uploads a single file. Requires JWT authentication
(`passport.authenticate('jwt')`). The folder is computed as `:table/:field`. Dispatches to
`uploadGCloud` when `NODE_ENV === 'production'` or `NEXT_PUBLIC_BACK_API` is set, otherwise
`uploadLocal`. The multipart form field name is `file`; the destination filename is taken from the
request body field `filename`.
- Local: no `req.currentUser` -> `403`; missing file -> `400`; missing `filename` -> `500`;
success -> `200` (empty body). Errors -> `500` with the stringified error.
- GCloud: missing file -> `400` `{ message }`; success -> `200`
`{ message, url }` where `url` is the public `storage.googleapis.com` URL; errors -> `500`
`{ message }`.
## Access Rules
- Upload requires a valid JWT (route-level passport auth). The local upload path additionally
rejects when `req.currentUser` is absent (`403`) and when an `entity` validation is supplied
(`403`); the controller calls `uploadLocal` with `entity: null`, so the entity branch is not
exercised from this endpoint.
- Download requires a valid JWT and performs no per-file ownership check; access is governed by
authentication plus knowledge of the `privateUrl`.
## Tenant Scope
None enforced in this slice. Neither upload nor download filters by organization; files are keyed
by the `:table/:field` folder, the `filename`, and (for GCloud) the configured `hash` prefix. The
`file` model has no `organizationId` column.
## Data Contract
`file` model columns: `id` (UUID, PK), `belongsTo` (string, nullable), `belongsToId` (UUID,
nullable), `belongsToColumn` (string, nullable), `name` (string, required, non-empty),
`sizeInBytes` (integer, nullable), `privateUrl` (string, nullable), `publicUrl` (string, required,
non-empty), `createdAt`, `updatedAt`, `deletedAt` (paranoid soft delete), `createdById` (UUID,
nullable), `updatedById` (UUID, nullable). Associations: `belongsTo` users as `createdBy` and
`updatedBy`.
In the DAL (`replaceRelationFiles` / `_addFiles`), each new file requires `name` and `publicUrl`,
otherwise `ValidationError('iam.errors.fileNameRequired')` is raised.
## Behavior / Notes
- Storage backend is chosen per request from `NODE_ENV` / `NEXT_PUBLIC_BACK_API`: GCloud in
production, local disk otherwise.
- The upload middleware (`src/middlewares/upload.ts`) uses multer memory storage with a 10 MB file
size limit on the field named `file`. The controller passes `maxFileSize: 10 * 1024 * 1024` to
`uploadLocal`, though that value is not consulted by `uploadLocal` (the limit comes from multer).
- `services/file.ts` still operates directly on Express `Request`/`Response` (streaming
upload/download). This is a documented architecture exception: the import-boundaries test
(`src/shared/architecture/import-boundaries.test.ts`) allows the BLL→HTTP dependency only for
`services/file.ts` and `services/auth.ts`.
- `src/db/api/file.ts` imports `@/services/file` (calling `deleteGCloud` when removing legacy
files), which is a DAL→BLL dependency. The same import-boundaries test keeps this as an exact
allowlisted exception; any additional DAL→BLL import fails the architecture test.
- `FileDBApi.replaceRelationFiles` syncs a relation's files: it deletes existing `file` rows not
present in the input (removing the GCloud object first when `privateUrl` is set) and creates rows
for inputs marked `new`.
## Tests
No dedicated `file` unit/e2e test exists. The architecture test
`src/shared/architecture/import-boundaries.test.ts` references this slice by name in its
exact BLL→HTTP and DAL→BLL exception allowlists.
## Related
- `search.md` (the other backend infrastructure slice).
- Architecture contract: `backend/docs/backend-architecture.md`.