111 lines
6.5 KiB
Markdown
111 lines
6.5 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`). `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 the per-file authorization. Both
|
|
upload and download require JWT; local handlers reject path traversal. Download enforces a
|
|
per-file tenant/ownership check: the file's owning organization (resolved from its `privateUrl`
|
|
via the uploader `createdById`) must match the requester's organization, unless the requester
|
|
has global access; files with no tracked row are denied. (Upload-side per-file ownership and a
|
|
typed frontend upload client are still open — tracked in the file workstream.)
|
|
- 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')`) **and per-file ownership**: `assertCanDownloadFile` resolves the
|
|
file's owning organization (via `FileDBApi.findOwnerOrganizationIdByPrivateUrl`, which reads the
|
|
uploader `createdById`) and returns `403` (`ForbiddenError`) unless the requester has global
|
|
access or shares that organization; an untracked `privateUrl` is also `403`. The controller then
|
|
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 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`.
|