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 —downloadandupload).downloadcallsassertCanDownloadFilebefore serving. - Service (BLL):
src/services/file.ts(uploadLocal,downloadLocal,uploadGCloud,downloadGCloud,deleteGCloud,initGCloud) for the storage I/O, plussrc/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 itsprivateUrlvia the uploadercreatedById) 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/removesfilerows for entity relations. Note: this DAL imports@/services/fileto calldeleteGCloud(see Behavior / Notes). - Model:
src/db/models/file.ts. - Shared used:
api/http/request.ts(paramStr),middlewares/upload.ts(processFilemulter 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:assertCanDownloadFileresolves the file's owning organization (viaFileDBApi.findOwnerOrganizationIdByPrivateUrl, which reads the uploadercreatedById) and returns403(ForbiddenError) unless the requester has global access or shares that organization; an untrackedprivateUrlis also403. The controller then dispatches todownloadGCloudwhenNODE_ENV === 'production'orNEXT_PUBLIC_BACK_APIis set, otherwisedownloadLocal.- Local: missing
privateUrl->404; aprivateUrlthat escapes the upload dir (path traversal via..) ->403(resolveWithinUploadDir); otherwise streams viares.downloadfromconfig.uploadDir. - GCloud: serves
${hash}/${privateUrl}from the bucket; file missing or error ->404{ message }.
- Local: missing
POST /api/file/upload/:table/:field-> uploads a single file. Requires JWT authentication (passport.authenticate('jwt')). The folder is computed as:table/:field. Dispatches touploadGCloudwhenNODE_ENV === 'production'orNEXT_PUBLIC_BACK_APIis set, otherwiseuploadLocal. The multipart form field name isfile; the destination filename is taken from the request body fieldfilename.- Local: no
req.currentUser->403; missing file ->400; missingfilename->500; success ->200(empty body). Errors ->500with the stringified error. - GCloud: missing file ->
400{ message }; success ->200{ message, url }whereurlis the publicstorage.googleapis.comURL; errors ->500{ message }.
- Local: no
Access Rules
- Upload requires a valid JWT (route-level passport auth). The local upload path additionally
rejects when
req.currentUseris absent (403) and when anentityvalidation is supplied (403); the controller callsuploadLocalwithentity: 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 namedfile. The controller passesmaxFileSize: 10 * 1024 * 1024touploadLocal, though that value is not consulted byuploadLocal(the limit comes from multer). services/file.tsstill operates directly on ExpressRequest/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 forservices/file.tsandservices/auth.ts.src/db/api/file.tsimports@/services/file(callingdeleteGCloudwhen 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.replaceRelationFilessyncs a relation's files: it deletes existingfilerows not present in the input (removing the GCloud object first whenprivateUrlis set) and creates rows for inputs markednew.
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.