# 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=` -> 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`.