2026-06-10 18:27:19 +02:00

99 lines
5.4 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.
## 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`).
- Service (BLL): `src/services/file.ts` (`uploadLocal`, `downloadLocal`, `uploadGCloud`,
`downloadGCloud`, `deleteGCloud`, `initGCloud`).
- 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. No authentication middleware on
this route. The controller dispatches to `downloadGCloud` when `NODE_ENV === 'production'` or
`NEXT_PUBLIC_BACK_API` is set, otherwise `downloadLocal`.
- Local: missing `privateUrl` -> `404`; 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 has no authentication middleware and performs no ownership check; access is governed
solely by knowing 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 caps DAL→BLL violations at
one, and this file is the allowed one.
- `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
BLL→HTTP and DAL→BLL debt-ceiling assertions.
## Related
- `search.md` (the other backend infrastructure slice).
- Architecture contract: `backend/docs/backend-architecture.md`.