6.8 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.
Upload client contract
When a typed frontend upload client is built, it follows the same multipart contract the reference frontend used (preserved here so it isn't lost):
- Generate a unique filename:
${uuid}.${ext}(extension from the picked file). POST /api/file/upload/:table/:fieldasmultipart/form-datawith fieldsfile(the binary) andfilename(the generated name).- The stored
privateUrlis${table}/${field}/${filename}; the playable/ download URL is${API_BASE_URL}/file/download?privateUrl=<privateUrl>(works for both the local-disk dev backend and the GCloud prod backend).
Downloads are JWT-only by customer decision. The standalone /file/upload/:table/:field
path can therefore serve uploaded logos/avatars/files even when it does not create a
tracked file row.
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 download authorization. Both upload and download require JWT; local handlers reject path traversal. Download no longer enforces per-file tenant ownership. - 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')).assertCanDownloadFileverifies that a current user exists, then the controller 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 requires a valid JWT and performs no per-file ownership check; access is governed by
authentication plus knowledge of 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 keeps this as an exact allowlisted exception; any additional DAL→BLL import fails the architecture test.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
exact BLL→HTTP and DAL→BLL exception allowlists.
Related
search.md(the other backend infrastructure slice).- Architecture contract:
backend/docs/backend-architecture.md.