2026-06-12 06:55:35 +02:00

6.5 KiB

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 (FileDBApireplaceRelationFiles, _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.

  • search.md (the other backend infrastructure slice).
  • Architecture contract: backend/docs/backend-architecture.md.