Add file module for uploads and storage

- File model + migration: file metadata, content lives in object storage
- IFileStorage port with local disk and S3-compatible (RustFS/MinIO) adapters
- POST /files, GET /files/:id/download, DELETE /files/:id
- MIME sniffed from content; only safe images served inline, nosniff header
- FileOwnerRegistry: owner modules register to grant visibility and keep
  their files from GC; FileGcService removes orphaned files hourly
- TransformResponseInterceptor passes StreamableFile through

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vadimandClaude Opus 5.5 committed 2026-10-02 09:17:55 +03:00
1 parent 5cfc8986d7
commit f32c6e348d
49 files changed
+1992 -2

No files matched your search

+1
View File
@@ -10,3 +10,4 @@ coverage
*.log *.log
tsconfig.build.tsbuildinfo tsconfig.build.tsbuildinfo
src/generated src/generated
storage
+3
View File
@@ -58,3 +58,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/src/generated/prisma /src/generated/prisma
/src/schema.gql /src/schema.gql
# Local file storage (FILE_STORAGE=local)
/storage
+457
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@apollo/server": "^5.5.1", "@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2", "@as-integrations/express5": "^1.1.2",
"@aws-sdk/client-s3": "^3.1145.0",
"@better-auth/prisma-adapter": "^1.7.2", "@better-auth/prisma-adapter": "^1.7.2",
"@nestjs/apollo": "^14.0.0", "@nestjs/apollo": "^14.0.0",
"@nestjs/common": "^12.0.1", "@nestjs/common": "^12.0.1",
@@ -19,6 +20,7 @@
"@nestjs/event-emitter": "^12.0.1", "@nestjs/event-emitter": "^12.0.1",
"@nestjs/graphql": "^14.0.0", "@nestjs/graphql": "^14.0.0",
"@nestjs/platform-express": "^12.0.1", "@nestjs/platform-express": "^12.0.1",
"@nestjs/schedule": "^12.0.2",
"@nestjs/typeorm": "^12.0.1", "@nestjs/typeorm": "^12.0.1",
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
@@ -26,6 +28,7 @@
"better-auth": "^1.7.2", "better-auth": "^1.7.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"file-type": "^22.1.1",
"graphql": "^16.14.2", "graphql": "^16.14.2",
"nodemailer": "^10.0.0", "nodemailer": "^10.0.0",
"pg": "^8.23.0", "pg": "^8.23.0",
@@ -40,6 +43,7 @@
"@nestjs/schematics": "^12.0.0", "@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1", "@nestjs/testing": "^12.0.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^2.3.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
@@ -371,6 +375,314 @@
"express": "^5.0.0" "express": "^5.0.0"
} }
}, },
"node_modules/@aws-sdk/checksums": {
"version": "3.1001.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1145.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1145.0.tgz",
"integrity": "sha512-MPKAQdx8qCZW1/ChDPILNehtAn/BMP4GNTnaH/xhGCWg3v50ADVq1K2WzxIrFfeANdWUyjwiY0CXDBhclsNWgQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1001.1",
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-node": "^3.972.84",
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.978.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@aws-sdk/xml-builder": "^3.972.41",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.35.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-login": "^3.972.79",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.79",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.84",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-ini": "^3.973.17",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/token-providers": "3.1138.0",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.77",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1138.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -1501,6 +1813,22 @@
"@nestjs/core": "^12.0.0" "@nestjs/core": "^12.0.0"
} }
}, },
"node_modules/@nestjs/schedule": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.2.tgz",
"integrity": "sha512-5iAvJEtk0njbfTrG4JQr+SXw8ZrqgDM1wJH9nUs6tVLCX5pWGd1LJ0a8b089UT1FDeDWaThRRfSLixFz9zztcA==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0 || ^12.0.0",
"@nestjs/core": "^11.0.0 || ^12.0.0"
}
},
"node_modules/@nestjs/schematics": { "node_modules/@nestjs/schematics": {
"version": "12.0.5", "version": "12.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.5.tgz", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.5.tgz",
@@ -2806,6 +3134,87 @@
"url": "https://github.com/sindresorhus/is?sponsor=1" "url": "https://github.com/sindresorhus/is?sponsor=1"
} }
}, },
"node_modules/@smithy/core": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz",
"integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@sqltools/formatter": { "node_modules/@sqltools/formatter": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
@@ -3072,6 +3481,12 @@
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/luxon": {
"version": "3.7.6",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
"license": "MIT"
},
"node_modules/@types/methods": { "node_modules/@types/methods": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -3079,6 +3494,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/multer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-i7STIm9V4K2MPH4ZYMtrZAwNxs3kglk2LgleaTuB9pUXgADBcQYQuYMd7+6xgTIxfoggIkFA/DkkvycdZpIARA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.6", "version": "24.13.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
@@ -3900,6 +4325,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.12", "version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
@@ -4451,6 +4882,23 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-inspect": { "node_modules/cross-inspect": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz",
@@ -6467,6 +6915,15 @@
"url": "https://github.com/sponsors/wellwelwel" "url": "https://github.com/sponsors/wellwelwel"
} }
}, },
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
+4
View File
@@ -24,6 +24,7 @@
"dependencies": { "dependencies": {
"@apollo/server": "^5.5.1", "@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2", "@as-integrations/express5": "^1.1.2",
"@aws-sdk/client-s3": "^3.1145.0",
"@better-auth/prisma-adapter": "^1.7.2", "@better-auth/prisma-adapter": "^1.7.2",
"@nestjs/apollo": "^14.0.0", "@nestjs/apollo": "^14.0.0",
"@nestjs/common": "^12.0.1", "@nestjs/common": "^12.0.1",
@@ -32,6 +33,7 @@
"@nestjs/event-emitter": "^12.0.1", "@nestjs/event-emitter": "^12.0.1",
"@nestjs/graphql": "^14.0.0", "@nestjs/graphql": "^14.0.0",
"@nestjs/platform-express": "^12.0.1", "@nestjs/platform-express": "^12.0.1",
"@nestjs/schedule": "^12.0.2",
"@nestjs/typeorm": "^12.0.1", "@nestjs/typeorm": "^12.0.1",
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
@@ -39,6 +41,7 @@
"better-auth": "^1.7.2", "better-auth": "^1.7.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"file-type": "^22.1.1",
"graphql": "^16.14.2", "graphql": "^16.14.2",
"nodemailer": "^10.0.0", "nodemailer": "^10.0.0",
"pg": "^8.23.0", "pg": "^8.23.0",
@@ -53,6 +56,7 @@
"@nestjs/schematics": "^12.0.0", "@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1", "@nestjs/testing": "^12.0.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^2.3.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "file" (
"id" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"uploaderId" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "file_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "file_uploaderId_idx" ON "file"("uploaderId");
-- CreateIndex
CREATE INDEX "file_createdAt_idx" ON "file"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "file_storageKey_key" ON "file"("storageKey");
-- AddForeignKey
ALTER TABLE "file" ADD CONSTRAINT "file_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+21
View File
@@ -25,6 +25,7 @@ model User {
notificationPreferences NotificationPreference[] notificationPreferences NotificationPreference[]
notificationMessages NotificationMessage[] notificationMessages NotificationMessage[]
comments Comment[] comments Comment[]
uploadedFiles File[]
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
@@ -209,3 +210,23 @@ model Comment {
@@index([authorId]) @@index([authorId])
@@map("comment") @@map("comment")
} }
/// Загруженный файл. Ничего не знает о владельце: к задачам, комментариям и т.п.
/// файлы привязываются через таблицы связей в модулях-владельцах.
/// Файл, на который никто не ссылается, считается сиротой и удаляется GC.
/// storageKey — ключ объекта в хранилище, имя от пользователя в него не попадает.
model File {
id String @id @default(uuid(7))
storageKey String
fileName String
mimeType String
size Int
uploaderId String?
uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @db.Timestamptz(3)
@@unique([storageKey])
@@index([uploaderId])
@@index([createdAt])
@@map("file")
}
+5
View File
@@ -7,10 +7,12 @@ import { UserModule } from './modules/user/user.module.js';
import { StatusModule } from './modules/status/status.module.js'; import { StatusModule } from './modules/status/status.module.js';
import { NotificationModule } from './modules/notification/notification.module.js'; import { NotificationModule } from './modules/notification/notification.module.js';
import { CommentModule } from './modules/comment/comment.module.js'; import { CommentModule } from './modules/comment/comment.module.js';
import { FileModule } from './modules/file/file.module.js';
import { AuthModule } from '@thallesp/nestjs-better-auth'; import { AuthModule } from '@thallesp/nestjs-better-auth';
import { auth } from './lib/auth.js'; import { auth } from './lib/auth.js';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { GraphQLModule } from '@nestjs/graphql'; import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo'; import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -24,6 +26,8 @@ import { join } from 'node:path';
// Внутрипроцессная шина доменных событий. Notification-модуль слушает // Внутрипроцессная шина доменных событий. Notification-модуль слушает
// события через неё, не завязываясь на модули, которые их порождают. // события через неё, не завязываясь на модули, которые их порождают.
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
// Периодические задачи (@Cron), например сборка файлов-сирот в FileModule
ScheduleModule.forRoot(),
GraphQLModule.forRoot<ApolloDriverConfig>({ GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver, driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'), autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
@@ -48,6 +52,7 @@ import { join } from 'node:path';
StatusModule, StatusModule,
NotificationModule, NotificationModule,
CommentModule, CommentModule,
FileModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
@@ -1,4 +1,4 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { CallHandler, ExecutionContext, Injectable, NestInterceptor, StreamableFile } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { SSE_METADATA } from '@nestjs/common/constants.js'; import { SSE_METADATA } from '@nestjs/common/constants.js';
@@ -46,6 +46,11 @@ export class TransformResponseInterceptor<T> implements NestInterceptor<
return next.handle().pipe( return next.handle().pipe(
map((result) => { map((result) => {
// Файл отдаётся потоком как есть — обёртка {data, code} сломала бы скачивание
if (result instanceof StreamableFile) {
return result;
}
if (isPaginatedResult<T>(result)) { if (isPaginatedResult<T>(result)) {
return { return {
data: result.data ?? null, data: result.data ?? null,
@@ -0,0 +1,9 @@
export class FileDto {
id: string;
fileName: string;
mimeType: string;
size: number;
/** null — загрузивший удалил аккаунт */
uploaderId: string | null;
createdAt: Date;
}
@@ -0,0 +1,16 @@
import { StoredFile } from '../../domain/entities/stored-file.entity.js';
import { FileDto } from '../dto/file.dto.js';
export class FileApplicationMapper {
/** storageKey — деталь хранилища, наружу не отдаём */
static toDto(file: StoredFile): FileDto {
return {
id: file.id as string,
fileName: file.fileName,
mimeType: file.mimeType,
size: file.size,
uploaderId: file.uploaderId,
createdAt: file.createdAt,
};
}
}
@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IFileRepository } from '../../domain/repositories/file.repository.interface.js';
import { FileForbiddenException } from '../../domain/exceptions/file-forbidden.exception.js';
import { FileNotFoundException } from '../../domain/exceptions/file-not-found.exception.js';
import { FileAccessService } from './file-access.service.js';
import { buildFile, mockFileRepository, registryWithOwner } from '../use-cases/test-helpers.js';
describe('FileAccessService.getAttachableFile', () => {
let fileRepository: IFileRepository;
let service: FileAccessService;
beforeEach(() => {
fileRepository = mockFileRepository();
service = new FileAccessService(fileRepository, registryWithOwner({ visibleTo: { 'user-2': ['file-1'] } }));
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
});
it('returns an own file', async () => {
await expect(service.getAttachableFile('file-1', 'user-1')).resolves.toMatchObject({ id: 'file-1' });
});
it('forbids attaching a visible file uploaded by someone else', async () => {
await expect(service.getAttachableFile('file-1', 'user-2')).rejects.toThrow(FileForbiddenException);
});
it('hides an invisible file', async () => {
await expect(service.getAttachableFile('file-1', 'user-3')).rejects.toThrow(FileNotFoundException);
});
});
@@ -0,0 +1,44 @@
import { Inject, Injectable } from '@nestjs/common';
import { FILE_REPOSITORY, type IFileRepository } from '../../domain/repositories/file.repository.interface.js';
import { StoredFile } from '../../domain/entities/stored-file.entity.js';
import { FileNotFoundException } from '../../domain/exceptions/file-not-found.exception.js';
import { FileOwnerRegistry } from './file-owner.registry.js';
import { FileDto } from '../dto/file.dto.js';
import { FileApplicationMapper } from '../mappers/file-application.mapper.js';
@Injectable()
export class FileAccessService {
constructor(
@Inject(FILE_REPOSITORY)
private readonly fileRepository: IFileRepository,
private readonly fileOwners: FileOwnerRegistry,
) {}
/**
* Файл виден загрузившему и всем, кому виден хоть один его владелец
* (например, задача, к которой он прикреплён). Иначе — FileNotFoundException,
* чтобы не раскрывать существование чужих файлов.
*/
async getVisibleFile(id: string, userId: string): Promise<StoredFile> {
const file = await this.fileRepository.findById(id);
if (!file) {
throw new FileNotFoundException(id);
}
if (file.isUploadedBy(userId) || (await this.fileOwners.isVisibleThroughAnyOwner(id, userId))) {
return file;
}
throw new FileNotFoundException(id);
}
/**
* Файл, который пользователь может прикрепить к задаче/комментарию:
* невидимый — FileNotFoundException, видимый, но чужой — FileForbiddenException.
*/
async getAttachableFile(id: string, userId: string): Promise<FileDto> {
const file = await this.getVisibleFile(id, userId);
file.assertOwnedBy(userId);
return FileApplicationMapper.toDto(file);
}
}
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigService } from '@nestjs/config';
import type { IFileRepository } from '../../domain/repositories/file.repository.interface.js';
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
import { FileGcService } from './file-gc.service.js';
import { buildFile, mockFileRepository, mockFileStorage, registryWithOwner } from '../use-cases/test-helpers.js';
describe('FileGcService', () => {
let fileRepository: IFileRepository;
let fileStorage: IFileStorage;
const config = new ConfigService({ FILE_ORPHAN_TTL_HOURS: '24' });
const now = new Date('2026-01-10T00:00:00.000Z');
beforeEach(() => {
fileRepository = mockFileRepository();
fileStorage = mockFileStorage();
});
it('removes only unreferenced files older than the TTL', async () => {
const gc = new FileGcService(fileRepository, fileStorage, registryWithOwner({ referenced: ['kept'] }), config);
vi.mocked(fileRepository.findCreatedBefore).mockResolvedValueOnce([
buildFile({ id: 'kept' }),
buildFile({ id: 'orphan' }),
]);
const removed = await gc.collect(now);
expect(fileRepository.findCreatedBefore).toHaveBeenCalledWith({
before: new Date('2026-01-09T00:00:00.000Z'),
limit: 500,
cursor: undefined,
});
expect(removed).toBe(1);
expect(fileStorage.delete).toHaveBeenCalledExactlyOnceWith('files/orphan');
expect(fileRepository.delete).toHaveBeenCalledExactlyOnceWith('orphan');
});
it('keeps the row when the object cannot be deleted and goes on', async () => {
const gc = new FileGcService(fileRepository, fileStorage, registryWithOwner(), config);
vi.mocked(fileRepository.findCreatedBefore).mockResolvedValueOnce([buildFile({ id: 'a' }), buildFile({ id: 'b' })]);
vi.mocked(fileStorage.delete).mockRejectedValueOnce(new Error('s3 down'));
const removed = await gc.collect(now);
expect(removed).toBe(1);
expect(fileRepository.delete).toHaveBeenCalledExactlyOnceWith('b');
});
it('pages through files by cursor', async () => {
const gc = new FileGcService(fileRepository, fileStorage, registryWithOwner(), config);
const fullBatch = Array.from({ length: 500 }, (_, i) => buildFile({ id: `f${i}` }));
vi.mocked(fileRepository.findCreatedBefore)
.mockResolvedValueOnce(fullBatch)
.mockResolvedValueOnce([buildFile({ id: 'last' })]);
const removed = await gc.collect(now);
expect(removed).toBe(501);
expect(vi.mocked(fileRepository.findCreatedBefore).mock.calls[1]![0]).toMatchObject({ cursor: 'f499' });
});
});
@@ -0,0 +1,90 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { FILE_REPOSITORY, type IFileRepository } from '../../domain/repositories/file.repository.interface.js';
import { FILE_STORAGE, type IFileStorage } from '../../domain/ports/file-storage.port.js';
import { FileOwnerRegistry } from './file-owner.registry.js';
const BATCH_SIZE = 500;
/**
* Удаляет файлы-сироты — на которые не ссылается ни один владелец:
* загруженные, но так и не прикреплённые, и те, чьи задачи/комментарии
* удалены (каскад в БД убирает связь, но не сам файл).
*
* TTL нужен, чтобы не удалить файл, который пользователь только что загрузил
* и вот-вот прикрепит (например, пока пишет комментарий).
*
* Сначала удаляется объект, потом строка: если упадёт удаление строки,
* файл останется сиротой и будет подобран следующим запуском
* (удаление объекта идемпотентно). Обратный порядок терял бы объекты навсегда.
*
* Несколько инстансов приложения могут запустить GC одновременно — это
* безопасно, все операции идемпотентны.
*/
@Injectable()
export class FileGcService {
private readonly logger = new Logger(FileGcService.name);
private readonly orphanTtlMs: number;
private running = false;
constructor(
@Inject(FILE_REPOSITORY)
private readonly fileRepository: IFileRepository,
@Inject(FILE_STORAGE)
private readonly fileStorage: IFileStorage,
private readonly fileOwners: FileOwnerRegistry,
config: ConfigService,
) {
this.orphanTtlMs = Number(config.get('FILE_ORPHAN_TTL_HOURS', 24)) * 60 * 60 * 1000;
}
@Cron(CronExpression.EVERY_HOUR)
async handleCron(): Promise<void> {
if (this.running) return;
this.running = true;
try {
const removed = await this.collect();
if (removed > 0) {
this.logger.log(`Удалено файлов-сирот: ${removed}`);
}
} catch (error) {
this.logger.error('Сборка файлов-сирот упала', error instanceof Error ? error.stack : error);
} finally {
this.running = false;
}
}
/** Возвращает количество удалённых файлов */
async collect(now = new Date()): Promise<number> {
const before = new Date(now.getTime() - this.orphanTtlMs);
let cursor: string | undefined;
let removed = 0;
for (;;) {
const batch = await this.fileRepository.findCreatedBefore({ before, limit: BATCH_SIZE, cursor });
if (batch.length === 0) break;
const ids = batch.map((file) => file.id as string);
const referenced = await this.fileOwners.findReferenced(ids);
for (const file of batch) {
if (referenced.has(file.id as string)) continue;
try {
await this.fileStorage.delete(file.storageKey);
await this.fileRepository.delete(file.id as string);
removed++;
} catch (error) {
this.logger.warn(`Не удалось удалить файл id=${file.id}: ${error instanceof Error ? error.message : error}`);
}
}
if (batch.length < BATCH_SIZE) break;
cursor = ids[ids.length - 1];
}
return removed;
}
}
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
/**
* Модуль, к сущностям которого привязываются файлы (задачи, комментарии...).
* Связи хранятся в его таблицах (issue_attachment и т.п.), file-модуль
* о них не знает и спрашивает через этот интерфейс.
*/
export interface FileOwner {
/** Для логов */
readonly name: string;
/** Подмножество fileIds, на которые ссылаются сущности этого владельца (для GC) */
findReferenced(fileIds: string[]): Promise<string[]>;
/** Виден ли файл пользователю через сущность этого владельца */
isVisibleThrough(fileId: string, userId: string): Promise<boolean>;
}
/**
* Реестр владельцев файлов. Модуль-владелец регистрируется в onModuleInit:
*
* constructor(private readonly fileOwners: FileOwnerRegistry) {}
* onModuleInit() { this.fileOwners.register(this); }
*
* Важно: файл, на который не ссылается ни один зарегистрированный владелец,
* GC считает сиротой. Новый владелец ОБЯЗАН зарегистрироваться.
*/
@Injectable()
export class FileOwnerRegistry {
private readonly owners: FileOwner[] = [];
register(owner: FileOwner): void {
this.owners.push(owner);
}
async findReferenced(fileIds: string[]): Promise<Set<string>> {
if (fileIds.length === 0) return new Set();
const results = await Promise.all(this.owners.map((owner) => owner.findReferenced(fileIds)));
return new Set(results.flat());
}
async isVisibleThroughAnyOwner(fileId: string, userId: string): Promise<boolean> {
for (const owner of this.owners) {
if (await owner.isVisibleThrough(fileId, userId)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,6 @@
export class DeleteFileCommand {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import type { IFileStorage } from '../../../domain/ports/file-storage.port.js';
import { FileForbiddenException } from '../../../domain/exceptions/file-forbidden.exception.js';
import { FileNotFoundException } from '../../../domain/exceptions/file-not-found.exception.js';
import { FileAccessService } from '../../services/file-access.service.js';
import { DeleteFileUseCase } from './delete-file.use-case.js';
import { DeleteFileCommand } from './delete-file.command.js';
import { buildFile, mockFileRepository, mockFileStorage, registryWithOwner } from '../test-helpers.js';
describe('DeleteFileUseCase', () => {
let fileRepository: IFileRepository;
let fileStorage: IFileStorage;
let useCase: DeleteFileUseCase;
beforeEach(() => {
fileRepository = mockFileRepository();
fileStorage = mockFileStorage();
const registry = registryWithOwner({ visibleTo: { 'user-2': ['file-1'] } });
useCase = new DeleteFileUseCase(fileRepository, fileStorage, new FileAccessService(fileRepository, registry));
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
});
it('deletes the object first, then the row', async () => {
const order: string[] = [];
vi.mocked(fileStorage.delete).mockImplementation(async () => void order.push('storage'));
vi.mocked(fileRepository.delete).mockImplementation(async () => void order.push('row'));
await useCase.execute(new DeleteFileCommand('file-1', 'user-1'));
expect(fileStorage.delete).toHaveBeenCalledWith('files/file-1');
expect(fileRepository.delete).toHaveBeenCalledWith('file-1');
expect(order).toEqual(['storage', 'row']);
});
it('keeps the row when deleting the object fails, so a retry can finish', async () => {
vi.mocked(fileStorage.delete).mockRejectedValue(new Error('s3 down'));
await expect(useCase.execute(new DeleteFileCommand('file-1', 'user-1'))).rejects.toThrow('s3 down');
expect(fileRepository.delete).not.toHaveBeenCalled();
});
it('forbids deleting a visible file uploaded by someone else', async () => {
await expect(useCase.execute(new DeleteFileCommand('file-1', 'user-2'))).rejects.toThrow(FileForbiddenException);
expect(fileStorage.delete).not.toHaveBeenCalled();
});
it('returns not found for an invisible file', async () => {
await expect(useCase.execute(new DeleteFileCommand('file-1', 'user-3'))).rejects.toThrow(FileNotFoundException);
});
});
@@ -0,0 +1,29 @@
import { Inject, Injectable } from '@nestjs/common';
import { FILE_REPOSITORY, type IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import { FILE_STORAGE, type IFileStorage } from '../../../domain/ports/file-storage.port.js';
import { DeleteFileCommand } from './delete-file.command.js';
import { FileAccessService } from '../../services/file-access.service.js';
/**
* Удаляет файл целиком; связи с владельцами уходят каскадом в БД.
* Порядок как в FileGcService: сначала объект, потом строка — при сбое
* повторный DELETE доведёт дело до конца, объект не потеряется.
*/
@Injectable()
export class DeleteFileUseCase {
constructor(
@Inject(FILE_REPOSITORY)
private readonly fileRepository: IFileRepository,
@Inject(FILE_STORAGE)
private readonly fileStorage: IFileStorage,
private readonly fileAccess: FileAccessService,
) {}
async execute(command: DeleteFileCommand): Promise<void> {
const file = await this.fileAccess.getVisibleFile(command.id, command.userId);
file.assertOwnedBy(command.userId);
await this.fileStorage.delete(file.storageKey);
await this.fileRepository.delete(command.id);
}
}
@@ -0,0 +1,6 @@
export class DownloadFileQuery {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Readable } from 'node:stream';
import type { IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import type { IFileStorage } from '../../../domain/ports/file-storage.port.js';
import { FileNotFoundException } from '../../../domain/exceptions/file-not-found.exception.js';
import { FileAccessService } from '../../services/file-access.service.js';
import { DownloadFileUseCase } from './download-file.use-case.js';
import { DownloadFileQuery } from './download-file.query.js';
import { buildFile, mockFileRepository, mockFileStorage, registryWithOwner } from '../test-helpers.js';
describe('DownloadFileUseCase', () => {
let fileRepository: IFileRepository;
let fileStorage: IFileStorage;
let useCase: DownloadFileUseCase;
const stream = {} as Readable;
beforeEach(() => {
fileRepository = mockFileRepository();
fileStorage = mockFileStorage();
const registry = registryWithOwner({ visibleTo: { 'user-2': ['file-1'] } });
useCase = new DownloadFileUseCase(fileStorage, new FileAccessService(fileRepository, registry));
vi.mocked(fileStorage.get).mockResolvedValue(stream);
});
it('lets the uploader download the file', async () => {
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
const result = await useCase.execute(new DownloadFileQuery('file-1', 'user-1'));
expect(fileStorage.get).toHaveBeenCalledWith('files/file-1');
expect(result).toMatchObject({ file: { id: 'file-1' }, inline: true, stream });
});
it('lets a user who sees an owner of the file download it', async () => {
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
await expect(useCase.execute(new DownloadFileQuery('file-1', 'user-2'))).resolves.toMatchObject({
file: { id: 'file-1' },
});
});
it('marks non-image files as attachment-only', async () => {
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile({ mimeType: 'application/octet-stream' }));
const result = await useCase.execute(new DownloadFileQuery('file-1', 'user-1'));
expect(result.inline).toBe(false);
});
it('hides the file from everyone else', async () => {
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
await expect(useCase.execute(new DownloadFileQuery('file-1', 'user-3'))).rejects.toThrow(FileNotFoundException);
expect(fileStorage.get).not.toHaveBeenCalled();
});
it('throws FileNotFoundException for a missing file', async () => {
vi.mocked(fileRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new DownloadFileQuery('file-1', 'user-1'))).rejects.toThrow(FileNotFoundException);
});
});
@@ -0,0 +1,34 @@
import type { Readable } from 'node:stream';
import { Inject, Injectable } from '@nestjs/common';
import { FILE_STORAGE, type IFileStorage } from '../../../domain/ports/file-storage.port.js';
import { DownloadFileQuery } from './download-file.query.js';
import { FileDto } from '../../dto/file.dto.js';
import { FileApplicationMapper } from '../../mappers/file-application.mapper.js';
import { FileAccessService } from '../../services/file-access.service.js';
export interface DownloadFileResult {
file: FileDto;
/** Можно ли показывать в браузере inline, иначе — только скачивание */
inline: boolean;
stream: Readable;
}
@Injectable()
export class DownloadFileUseCase {
constructor(
@Inject(FILE_STORAGE)
private readonly fileStorage: IFileStorage,
private readonly fileAccess: FileAccessService,
) {}
async execute(query: DownloadFileQuery): Promise<DownloadFileResult> {
const file = await this.fileAccess.getVisibleFile(query.id, query.userId);
const stream = await this.fileStorage.get(file.storageKey);
return {
file: FileApplicationMapper.toDto(file),
inline: file.isInlineViewable,
stream,
};
}
}
@@ -0,0 +1,3 @@
export class GetFilesQuery {
constructor(public readonly ids: string[]) {}
}
@@ -0,0 +1,24 @@
import { Inject, Injectable } from '@nestjs/common';
import { FILE_REPOSITORY, type IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import { GetFilesQuery } from './get-files.query.js';
import { FileDto } from '../../dto/file.dto.js';
import { FileApplicationMapper } from '../../mappers/file-application.mapper.js';
/**
* Метаданные файлов по id — для модулей-владельцев, раскрывающих свои связи
* в FileDto (по аналогии с GetUsersUseCase). Доступ проверяет вызывающий:
* он отдаёт только файлы своих сущностей, видимых пользователю.
*/
@Injectable()
export class GetFilesUseCase {
constructor(
@Inject(FILE_REPOSITORY)
private readonly fileRepository: IFileRepository,
) {}
async execute(query: GetFilesQuery): Promise<FileDto[]> {
const ids = [...new Set(query.ids)];
const files = await this.fileRepository.findByIds(ids);
return files.map(FileApplicationMapper.toDto);
}
}
@@ -0,0 +1,46 @@
import { vi } from 'vitest';
import type { IFileRepository } from '../../domain/repositories/file.repository.interface.js';
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
import { StoredFile } from '../../domain/entities/stored-file.entity.js';
import { FileOwnerRegistry, type FileOwner } from '../services/file-owner.registry.js';
export const buildFile = (
overrides: Partial<{ id: string; uploaderId: string | null; mimeType: string; createdAt: Date }> = {},
) =>
StoredFile.reconstitute({
id: overrides.id ?? 'file-1',
storageKey: `files/${overrides.id ?? 'file-1'}`,
fileName: 'photo.png',
mimeType: overrides.mimeType ?? 'image/png',
size: 10,
uploaderId: overrides.uploaderId === undefined ? 'user-1' : overrides.uploaderId,
createdAt: overrides.createdAt ?? new Date('2026-01-01T00:00:00.000Z'),
});
export const mockFileRepository = (): IFileRepository => ({
findById: vi.fn(),
findByIds: vi.fn(),
findCreatedBefore: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
});
export const mockFileStorage = (): IFileStorage => ({
put: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
});
/** Реестр с одним владельцем, которому видны/принадлежат перечисленные файлы */
export const registryWithOwner = (params: { referenced?: string[]; visibleTo?: Record<string, string[]> } = {}) => {
const owner: FileOwner = {
name: 'test',
findReferenced: vi.fn(async (ids: string[]) => ids.filter((id) => params.referenced?.includes(id))),
isVisibleThrough: vi.fn(async (fileId: string, userId: string) =>
Boolean(params.visibleTo?.[userId]?.includes(fileId)),
),
};
const registry = new FileOwnerRegistry();
registry.register(owner);
return registry;
};
@@ -0,0 +1,9 @@
export class UploadFileCommand {
constructor(
public readonly uploaderId: string,
/** Временный файл, куда multer сохранил загрузку. Удаляет его вызывающий */
public readonly tempPath: string,
public readonly originalName: string,
public readonly size: number,
) {}
}
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import type { IFileStorage } from '../../../domain/ports/file-storage.port.js';
import type { IFileTypeDetector } from '../../../domain/ports/file-type-detector.port.js';
import { StoredFile } from '../../../domain/entities/stored-file.entity.js';
import { UploadFileUseCase } from './upload-file.use-case.js';
import { UploadFileCommand } from './upload-file.command.js';
import { mockFileRepository, mockFileStorage } from '../test-helpers.js';
vi.mock('node:fs', () => ({ createReadStream: vi.fn(() => 'stream') }));
describe('UploadFileUseCase', () => {
let fileRepository: IFileRepository;
let fileStorage: IFileStorage;
let detector: IFileTypeDetector;
let useCase: UploadFileUseCase;
beforeEach(() => {
fileRepository = mockFileRepository();
fileStorage = mockFileStorage();
detector = { detect: vi.fn() };
useCase = new UploadFileUseCase(fileRepository, fileStorage, detector);
vi.mocked(fileRepository.create).mockImplementation(async (file: StoredFile) =>
StoredFile.reconstitute({
id: 'file-1',
storageKey: file.storageKey,
fileName: file.fileName,
mimeType: file.mimeType,
size: file.size,
uploaderId: file.uploaderId,
createdAt: file.createdAt,
}),
);
});
it('stores the file under a generated key with the sniffed MIME type', async () => {
vi.mocked(detector.detect).mockResolvedValue('image/png');
const dto = await useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'photo.png', 10));
const [key, , meta] = vi.mocked(fileStorage.put).mock.calls[0]!;
expect(key).toMatch(/^files\/[0-9a-f-]{36}$/);
expect(meta).toEqual({ contentType: 'image/png', size: 10 });
expect(dto).toEqual({
id: 'file-1',
fileName: 'photo.png',
mimeType: 'image/png',
size: 10,
uploaderId: 'user-1',
createdAt: expect.any(Date),
});
expect(dto).not.toHaveProperty('storageKey');
});
it('falls back to octet-stream for unrecognized content, ignoring the name', async () => {
vi.mocked(detector.detect).mockResolvedValue(null);
const dto = await useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'evil.html', 10));
expect(dto.mimeType).toBe('application/octet-stream');
});
it('removes the stored object when saving the row fails', async () => {
vi.mocked(detector.detect).mockResolvedValue('image/png');
vi.mocked(fileRepository.create).mockRejectedValue(new Error('db down'));
await expect(useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'photo.png', 10))).rejects.toThrow(
'db down',
);
const [key] = vi.mocked(fileStorage.put).mock.calls[0]!;
expect(fileStorage.delete).toHaveBeenCalledWith(key);
});
});
@@ -0,0 +1,64 @@
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { FILE_REPOSITORY, type IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import { FILE_STORAGE, type IFileStorage } from '../../../domain/ports/file-storage.port.js';
import { FILE_TYPE_DETECTOR, type IFileTypeDetector } from '../../../domain/ports/file-type-detector.port.js';
import { StoredFile } from '../../../domain/entities/stored-file.entity.js';
import { UploadFileCommand } from './upload-file.command.js';
import { FileDto } from '../../dto/file.dto.js';
import { FileApplicationMapper } from '../../mappers/file-application.mapper.js';
const UNKNOWN_MIME_TYPE = 'application/octet-stream';
/**
* Загружает файл без привязки к владельцу. Прикрепление к задаче/комментарию —
* отдельный шаг в модуле-владельце; не прикреплённый файл удалит GC.
*/
@Injectable()
export class UploadFileUseCase {
private readonly logger = new Logger(UploadFileUseCase.name);
constructor(
@Inject(FILE_REPOSITORY)
private readonly fileRepository: IFileRepository,
@Inject(FILE_STORAGE)
private readonly fileStorage: IFileStorage,
@Inject(FILE_TYPE_DETECTOR)
private readonly fileTypeDetector: IFileTypeDetector,
) {}
async execute(command: UploadFileCommand): Promise<FileDto> {
// Заголовку Content-Type от клиента не доверяем. Нераспознанное
// (в т.ч. HTML/SVG) хранится как octet-stream и отдаётся только на скачивание.
const mimeType = (await this.fileTypeDetector.detect(command.tempPath)) ?? UNKNOWN_MIME_TYPE;
const storageKey = `files/${randomUUID()}`;
await this.fileStorage.put(storageKey, createReadStream(command.tempPath), {
contentType: mimeType,
size: command.size,
});
try {
const file = await this.fileRepository.create(
StoredFile.create({
storageKey,
fileName: command.originalName,
mimeType,
size: command.size,
uploaderId: command.uploaderId,
}),
);
return FileApplicationMapper.toDto(file);
} catch (error) {
// Строки нет — GC этот объект никогда не найдёт, убираем сразу
try {
await this.fileStorage.delete(storageKey);
} catch (cleanupError) {
this.logger.warn(`Не удалось удалить объект ${storageKey}: ${String(cleanupError)}`);
}
throw error;
}
}
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { StoredFile } from './stored-file.entity.js';
import { FileForbiddenException } from '../exceptions/file-forbidden.exception.js';
const build = (mimeType = 'image/png', uploaderId: string | null = 'user-1') =>
StoredFile.reconstitute({
id: 'file-1',
storageKey: 'files/key',
fileName: 'photo.png',
mimeType,
size: 10,
uploaderId,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
});
describe('StoredFile', () => {
it('creates a new file with sanitized name and no id', () => {
const file = StoredFile.create({
storageKey: 'files/key',
fileName: '../secret/report.pdf',
mimeType: 'application/pdf',
size: 100,
uploaderId: 'user-1',
});
expect(file.id).toBeNull();
expect(file.fileName).toBe('report.pdf');
expect(file.uploaderId).toBe('user-1');
});
it('allows inline view only for safe image types', () => {
expect(build('image/png').isInlineViewable).toBe(true);
expect(build('image/svg+xml').isInlineViewable).toBe(false);
expect(build('text/html').isInlineViewable).toBe(false);
expect(build('application/octet-stream').isInlineViewable).toBe(false);
});
it('treats only the uploader as the owner', () => {
expect(() => build().assertOwnedBy('user-1')).not.toThrow();
expect(() => build().assertOwnedBy('user-2')).toThrow(FileForbiddenException);
expect(() => build('image/png', null).assertOwnedBy('user-1')).toThrow(FileForbiddenException);
});
});
@@ -0,0 +1,112 @@
import { FileName } from '../value-objects/file-name.vo.js';
import { FileForbiddenException } from '../exceptions/file-forbidden.exception.js';
interface StoredFileProps {
id: string | null;
storageKey: string;
fileName: FileName;
mimeType: string;
size: number;
/** null — загрузивший удалил аккаунт */
uploaderId: string | null;
createdAt: Date;
}
/**
* Типы, которые безопасно показывать в браузере inline. Всё остальное
* (в т.ч. HTML и SVG — источник stored XSS) отдаётся только на скачивание.
*/
const INLINE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
/**
* Загруженный файл (не путать с глобальным `File` из Web API).
* О владельцах (задача, комментарий...) ничего не знает — связи живут
* в модулях-владельцах, см. FileOwnerRegistry.
*
* `id` (uuid v7) генерирует Prisma, поэтому у нового файла он null до сохранения.
*/
export class StoredFile {
private constructor(private readonly props: StoredFileProps) {}
/** Фабричный метод для НОВОГО файла, уже положенного в хранилище */
static create(params: {
storageKey: string;
fileName: string;
mimeType: string;
size: number;
uploaderId: string;
}): StoredFile {
return new StoredFile({
id: null,
storageKey: params.storageKey,
fileName: FileName.create(params.fileName),
mimeType: params.mimeType,
size: params.size,
uploaderId: params.uploaderId,
createdAt: new Date(),
});
}
/** Восстановление сущности из персистентного слоя */
static reconstitute(props: {
id: string;
storageKey: string;
fileName: string;
mimeType: string;
size: number;
uploaderId: string | null;
createdAt: Date;
}): StoredFile {
return new StoredFile({
...props,
fileName: FileName.create(props.fileName),
});
}
get id(): string | null {
return this.props.id;
}
get storageKey(): string {
return this.props.storageKey;
}
get fileName(): string {
return this.props.fileName.value;
}
get mimeType(): string {
return this.props.mimeType;
}
get size(): number {
return this.props.size;
}
get uploaderId(): string | null {
return this.props.uploaderId;
}
get createdAt(): Date {
return this.props.createdAt;
}
get isInlineViewable(): boolean {
return INLINE_MIME_TYPES.has(this.props.mimeType);
}
isUploadedBy(userId: string): boolean {
return this.props.uploaderId === userId;
}
/**
* Удалить файл или прикрепить его к сущности может только тот, кто его загрузил.
* Иначе, увидев файл через одну задачу, можно было бы «перенести» его
* в другую и раскрыть людям, которым он не предназначался.
*/
assertOwnedBy(userId: string): void {
if (!this.isUploadedBy(userId)) {
throw new FileForbiddenException(this.props.id ?? 'new');
}
}
}
@@ -0,0 +1,6 @@
export class FileForbiddenException extends Error {
constructor(id: string) {
super(`Нет прав на это действие с файлом id=${id}`);
this.name = 'FileForbiddenException';
}
}
@@ -0,0 +1,6 @@
export class FileNotFoundException extends Error {
constructor(id: string) {
super(`Файл с id=${id} не найден`);
this.name = 'FileNotFoundException';
}
}
@@ -0,0 +1,13 @@
import type { Readable } from 'node:stream';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const FILE_STORAGE = Symbol('FILE_STORAGE');
/** Объектное хранилище содержимого файлов (S3-совместимое или локальный диск) */
export interface IFileStorage {
put(key: string, body: Readable, meta: { contentType: string; size: number }): Promise<void>;
/** Бросает ошибку сразу, если объекта нет, — до того как ответ начнёт отправляться */
get(key: string): Promise<Readable>;
/** Идемпотентно: отсутствие объекта не ошибка */
delete(key: string): Promise<void>;
}
@@ -0,0 +1,8 @@
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const FILE_TYPE_DETECTOR = Symbol('FILE_TYPE_DETECTOR');
/** Определяет MIME-тип по содержимому файла, а не по заголовку от клиента */
export interface IFileTypeDetector {
/** null — тип не распознан (в т.ч. все текстовые форматы: HTML, SVG, CSV...) */
detect(path: string): Promise<string | null>;
}
@@ -0,0 +1,16 @@
import { StoredFile } from '../entities/stored-file.entity.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const FILE_REPOSITORY = Symbol('FILE_REPOSITORY');
export interface IFileRepository {
findById(id: string): Promise<StoredFile | null>;
findByIds(ids: string[]): Promise<StoredFile[]>;
/**
* Файлы, созданные раньше `before`, по возрастанию id (uuid v7 ≈ время создания).
* Курсорная пагинация для GC: cursor — id последнего полученного файла.
*/
findCreatedBefore(params: { before: Date; limit: number; cursor?: string }): Promise<StoredFile[]>;
create(file: StoredFile): Promise<StoredFile>;
delete(id: string): Promise<void>;
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { FileName } from './file-name.vo.js';
describe('FileName', () => {
it('keeps a regular name, including cyrillic', () => {
expect(FileName.create('Отчёт за май.pdf').value).toBe('Отчёт за май.pdf');
});
it('strips directories from the client-supplied path', () => {
expect(FileName.create('C:\\Users\\me\\photo.png').value).toBe('photo.png');
expect(FileName.create('../../etc/passwd').value).toBe('passwd');
});
it('removes control characters', () => {
expect(FileName.create('evil\r\nname\u0000.txt').value).toBe('evilname.txt');
});
it('falls back when nothing is left', () => {
expect(FileName.create('').value).toBe(FileName.FALLBACK);
expect(FileName.create(' ').value).toBe(FileName.FALLBACK);
expect(FileName.create('dir/..').value).toBe(FileName.FALLBACK);
});
it('truncates long names keeping the extension', () => {
const name = FileName.create(`${'a'.repeat(300)}.docx`).value;
expect([...name]).toHaveLength(FileName.MAX_LENGTH);
expect(name.endsWith('.docx')).toBe(true);
});
});
@@ -0,0 +1,52 @@
/**
* Оригинальное имя файла — только для отображения и Content-Disposition.
* В ключ хранилища не попадает, поэтому достаточно убрать путь и
* управляющие символы, а не экранировать всё подряд.
*/
export class FileName {
static readonly MAX_LENGTH = 255;
static readonly FALLBACK = 'file';
private constructor(private readonly _value: string) {}
static create(value: string): FileName {
const baseName = (value ?? '').split(/[/\\]/).pop() ?? '';
const cleaned = Array.from(baseName.normalize('NFC'))
.filter((char) => !FileName.isControl(char))
.join('')
.trim();
if (!cleaned || cleaned === '.' || cleaned === '..') {
return new FileName(FileName.FALLBACK);
}
return new FileName(FileName.truncate(cleaned));
}
/** C0-управляющие символы и DEL: переводы строк ломают заголовки, \0 — пути */
private static isControl(char: string): boolean {
const code = char.codePointAt(0)!;
return code <= 0x1f || code === 0x7f;
}
/** Обрезает длинное имя, сохраняя расширение */
private static truncate(name: string): string {
const chars = Array.from(name);
if (chars.length <= FileName.MAX_LENGTH) {
return name;
}
const dot = name.lastIndexOf('.');
// Array.from, а не split(''): длина в code points, суррогатные пары не рвём
const ext = dot > 0 ? Array.from(name.slice(dot)) : [];
if (ext.length === 0 || ext.length > 16) {
return chars.slice(0, FileName.MAX_LENGTH).join('');
}
return chars.slice(0, FileName.MAX_LENGTH - ext.length).join('') + ext.join('');
}
get value(): string {
return this._value;
}
}
+67
View File
@@ -0,0 +1,67 @@
import { tmpdir } from 'node:os';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MulterModule } from '@nestjs/platform-express';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { FILE_REPOSITORY } from './domain/repositories/file.repository.interface.js';
import { FILE_STORAGE } from './domain/ports/file-storage.port.js';
import { FILE_TYPE_DETECTOR } from './domain/ports/file-type-detector.port.js';
import { FilePrismaRepository } from './infrastructure/persistence/prisma/repositories/file.prisma-repository.js';
import { createFileStorage } from './infrastructure/storage/file-storage.factory.js';
import { FileTypeDetector } from './infrastructure/storage/file-type.detector.js';
import { FileController } from './presentation/controllers/file.controller.js';
import { UploadFileUseCase } from './application/use-cases/upload-file/upload-file.use-case.js';
import { DownloadFileUseCase } from './application/use-cases/download-file/download-file.use-case.js';
import { DeleteFileUseCase } from './application/use-cases/delete-file/delete-file.use-case.js';
import { GetFilesUseCase } from './application/use-cases/get-files/get-files.use-case.js';
import { FileAccessService } from './application/services/file-access.service.js';
import { FileGcService } from './application/services/file-gc.service.js';
import { FileOwnerRegistry } from './application/services/file-owner.registry.js';
@Module({
imports: [
PrismaModule,
MulterModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
// Загрузка пишется во временный файл, а не в память: тип определяем
// по содержимому, размер известен заранее, память не зависит от размера файла
dest: tmpdir(),
// Без этого multer декодирует имя файла как latin1 и ломает кириллицу
defParamCharset: 'utf8',
limits: {
fileSize: Number(config.get('FILE_MAX_SIZE_MB', 25)) * 1024 * 1024,
files: 1,
},
}),
}),
],
controllers: [FileController],
providers: [
UploadFileUseCase,
DownloadFileUseCase,
DeleteFileUseCase,
GetFilesUseCase,
FileAccessService,
FileGcService,
FileOwnerRegistry,
{
// Domain видит только интерфейсы, конкретные реализации подставляет DI-контейнер здесь
provide: FILE_REPOSITORY,
useClass: FilePrismaRepository,
},
{
provide: FILE_STORAGE,
inject: [ConfigService],
useFactory: createFileStorage,
},
{
provide: FILE_TYPE_DETECTOR,
useClass: FileTypeDetector,
},
],
// Модули-владельцы (issue, comment) регистрируются в реестре,
// проверяют прикрепляемые файлы и раскрывают связи в FileDto
exports: [FileOwnerRegistry, FileAccessService, GetFilesUseCase],
})
export class FileModule {}
@@ -0,0 +1,27 @@
import type { File as PrismaFile, Prisma } from '../../../../../../generated/prisma/client.js';
import { StoredFile } from '../../../../domain/entities/stored-file.entity.js';
export class FilePrismaMapper {
static toDomain(record: PrismaFile): StoredFile {
return StoredFile.reconstitute({
id: record.id,
storageKey: record.storageKey,
fileName: record.fileName,
mimeType: record.mimeType,
size: record.size,
uploaderId: record.uploaderId,
createdAt: record.createdAt,
});
}
static toCreateInput(file: StoredFile): Prisma.FileUncheckedCreateInput {
return {
storageKey: file.storageKey,
fileName: file.fileName,
mimeType: file.mimeType,
size: file.size,
uploaderId: file.uploaderId,
createdAt: file.createdAt,
};
}
}
@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import { StoredFile } from '../../../../domain/entities/stored-file.entity.js';
import { IFileRepository } from '../../../../domain/repositories/file.repository.interface.js';
import { FilePrismaMapper } from '../mappers/file.mapper.js';
@Injectable()
export class FilePrismaRepository implements IFileRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: string): Promise<StoredFile | null> {
const record = await this.prisma.file.findUnique({ where: { id } });
return record ? FilePrismaMapper.toDomain(record) : null;
}
async findByIds(ids: string[]): Promise<StoredFile[]> {
if (ids.length === 0) return [];
const records = await this.prisma.file.findMany({ where: { id: { in: ids } } });
return records.map(FilePrismaMapper.toDomain);
}
async findCreatedBefore(params: { before: Date; limit: number; cursor?: string }): Promise<StoredFile[]> {
const records = await this.prisma.file.findMany({
where: { createdAt: { lt: params.before } },
orderBy: { id: 'asc' },
take: Math.max(1, params.limit),
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
});
return records.map(FilePrismaMapper.toDomain);
}
async create(file: StoredFile): Promise<StoredFile> {
const record = await this.prisma.file.create({
data: FilePrismaMapper.toCreateInput(file),
});
return FilePrismaMapper.toDomain(record);
}
async delete(id: string): Promise<void> {
// deleteMany не падает, если строку уже удалил параллельный запрос/GC
await this.prisma.file.deleteMany({ where: { id } });
}
}
@@ -0,0 +1,26 @@
import { ConfigService } from '@nestjs/config';
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
import { LocalFileStorage } from './local-file-storage.js';
import { S3FileStorage } from './s3-file-storage.js';
/** FILE_STORAGE=s3 — S3-совместимое хранилище, иначе локальный диск */
export function createFileStorage(config: ConfigService): IFileStorage {
const driver = config.get<string>('FILE_STORAGE', 'local');
if (driver === 's3') {
return new S3FileStorage({
endpoint: config.get<string>('S3_ENDPOINT') || undefined,
region: config.get<string>('S3_REGION', 'us-east-1'),
bucket: config.getOrThrow<string>('S3_BUCKET'),
accessKeyId: config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
secretAccessKey: config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
forcePathStyle: config.get<string>('S3_FORCE_PATH_STYLE', 'true') !== 'false',
});
}
if (driver !== 'local') {
throw new Error(`Неизвестный FILE_STORAGE=${driver}, ожидается local | s3`);
}
return new LocalFileStorage(config.get<string>('FILE_STORAGE_LOCAL_DIR', './storage'));
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { fileTypeFromFile } from 'file-type';
import type { IFileTypeDetector } from '../../domain/ports/file-type-detector.port.js';
/** Определение типа по сигнатуре (magic bytes) через file-type */
@Injectable()
export class FileTypeDetector implements IFileTypeDetector {
async detect(path: string): Promise<string | null> {
return (await fileTypeFromFile(path))?.mime ?? null;
}
}
@@ -0,0 +1,38 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { text } from 'node:stream/consumers';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalFileStorage } from './local-file-storage.js';
describe('LocalFileStorage', () => {
let root: string;
let storage: LocalFileStorage;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'ulitka-files-'));
storage = new LocalFileStorage(root);
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it('puts, gets and deletes an object', async () => {
await storage.put('files/a', Readable.from(['hello']));
expect(await text(await storage.get('files/a'))).toBe('hello');
await storage.delete('files/a');
await expect(storage.get('files/a')).rejects.toThrow();
});
it('treats deleting a missing object as success', async () => {
await expect(storage.delete('files/missing')).resolves.toBeUndefined();
});
it('rejects keys escaping the root', async () => {
await expect(storage.put('../escape', Readable.from(['x']))).rejects.toThrow('Недопустимый ключ');
});
});
@@ -0,0 +1,40 @@
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, rm, stat } from 'node:fs/promises';
import { dirname, resolve, sep } from 'node:path';
import type { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
/** Хранилище на локальном диске — для разработки и тестов */
export class LocalFileStorage implements IFileStorage {
private readonly root: string;
constructor(root: string) {
this.root = resolve(root);
}
async put(key: string, body: Readable): Promise<void> {
const path = this.resolveKey(key);
await mkdir(dirname(path), { recursive: true });
await pipeline(body, createWriteStream(path));
}
async get(key: string): Promise<Readable> {
const path = this.resolveKey(key);
await stat(path);
return createReadStream(path);
}
async delete(key: string): Promise<void> {
await rm(this.resolveKey(key), { force: true });
}
/** Ключи генерирует приложение, но выход за пределы root всё равно запрещаем */
private resolveKey(key: string): string {
const path = resolve(this.root, key);
if (!path.startsWith(this.root + sep)) {
throw new Error(`Недопустимый ключ хранилища: ${key}`);
}
return path;
}
}
@@ -0,0 +1,60 @@
import type { Readable } from 'node:stream';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
export interface S3FileStorageOptions {
/** Для MinIO/RustFS — адрес сервера, для AWS можно не задавать */
endpoint?: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
/** MinIO/RustFS обычно требуют path-style (http://host/bucket/key) */
forcePathStyle: boolean;
}
/**
* S3-совместимое хранилище (AWS S3, MinIO, RustFS...).
* Бакет должен существовать заранее — приложение его не создаёт.
*/
export class S3FileStorage implements IFileStorage {
private readonly client: S3Client;
private readonly bucket: string;
constructor(options: S3FileStorageOptions) {
this.bucket = options.bucket;
this.client = new S3Client({
endpoint: options.endpoint,
region: options.region,
forcePathStyle: options.forcePathStyle,
credentials: {
accessKeyId: options.accessKeyId,
secretAccessKey: options.secretAccessKey,
},
});
}
async put(key: string, body: Readable, meta: { contentType: string; size: number }): Promise<void> {
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: body,
ContentType: meta.contentType,
// Без длины SDK не может отправить поток одним PUT
ContentLength: meta.size,
}),
);
}
async get(key: string): Promise<Readable> {
const response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
// В Node.js Body — это Readable (IncomingMessage с примесями SDK)
return response.Body as Readable;
}
async delete(key: string): Promise<void> {
// DeleteObject в S3 идемпотентен — отсутствие объекта не ошибка
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
}
}
@@ -0,0 +1,101 @@
import { rm } from 'node:fs/promises';
import {
BadRequestException,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Post,
Res,
StreamableFile,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import type { Response } from 'express';
import { UploadFileUseCase } from '../../application/use-cases/upload-file/upload-file.use-case.js';
import { DownloadFileUseCase } from '../../application/use-cases/download-file/download-file.use-case.js';
import { DeleteFileUseCase } from '../../application/use-cases/delete-file/delete-file.use-case.js';
import { UploadFileCommand } from '../../application/use-cases/upload-file/upload-file.command.js';
import { DownloadFileQuery } from '../../application/use-cases/download-file/download-file.query.js';
import { DeleteFileCommand } from '../../application/use-cases/delete-file/delete-file.command.js';
import { FileNotFoundException } from '../../domain/exceptions/file-not-found.exception.js';
import { FileForbiddenException } from '../../domain/exceptions/file-forbidden.exception.js';
import { contentDisposition } from '../helpers/content-disposition.js';
@Controller('files')
export class FileController {
constructor(
private readonly uploadFileUseCase: UploadFileUseCase,
private readonly downloadFileUseCase: DownloadFileUseCase,
private readonly deleteFileUseCase: DeleteFileUseCase,
) {}
/**
* multipart/form-data с полем `file`. Лимиты и временный каталог задаёт
* MulterModule в FileModule; превышение размера — 413 от Nest.
*/
@Post()
@UseInterceptors(FileInterceptor('file'))
async upload(@UploadedFile() file: Express.Multer.File | undefined, @Session() session: UserSession) {
if (!file) {
throw new BadRequestException('Поле file обязательно');
}
try {
return await this.uploadFileUseCase.execute(
new UploadFileCommand(session.user.id, file.path, file.originalname, file.size),
);
} finally {
await rm(file.path, { force: true });
}
}
@Get(':id/download')
async download(
@Param('id') id: string,
@Session() session: UserSession,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const { file, inline, stream } = await this.handleErrors(() =>
this.downloadFileUseCase.execute(new DownloadFileQuery(id, session.user.id)),
);
// Браузер не должен «угадывать» тип: иначе octet-stream с HTML внутри
// может исполниться как страница на нашем домене
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return new StreamableFile(stream, {
type: file.mimeType,
length: file.size,
disposition: contentDisposition(inline ? 'inline' : 'attachment', file.fileName),
});
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param('id') id: string, @Session() session: UserSession) {
await this.handleErrors(() => this.deleteFileUseCase.execute(new DeleteFileCommand(id, session.user.id)));
}
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof FileNotFoundException) {
throw new NotFoundException(error.message);
}
if (error instanceof FileForbiddenException) {
throw new ForbiddenException(error.message);
}
throw error;
}
}
}
@@ -0,0 +1,23 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
/** Метаданные файла. Содержимое — REST: GET /api/files/:id/download */
@ObjectType('File')
export class FileType {
@Field()
id: string;
@Field()
fileName: string;
@Field()
mimeType: string;
@Field(() => Int)
size: number;
@Field(() => String, { nullable: true })
uploaderId: string | null;
@Field(() => Date)
createdAt: Date;
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { contentDisposition } from './content-disposition.js';
describe('contentDisposition', () => {
it('encodes a plain ASCII name', () => {
expect(contentDisposition('attachment', 'report.pdf')).toBe(
`attachment; filename="report.pdf"; filename*=UTF-8''report.pdf`,
);
});
it('encodes cyrillic with an ASCII fallback', () => {
expect(contentDisposition('inline', 'Отчёт.png')).toBe(
`inline; filename="_____.png"; filename*=UTF-8''%D0%9E%D1%82%D1%87%D1%91%D1%82.png`,
);
});
it('escapes quotes and RFC 5987 special characters', () => {
expect(contentDisposition('attachment', `a"b'(c).txt`)).toBe(
`attachment; filename="a_b'(c).txt"; filename*=UTF-8''a%22b%27%28c%29.txt`,
);
});
});
@@ -0,0 +1,13 @@
/**
* Заголовок Content-Disposition с именем файла по RFC 6266/5987:
* `filename` — ASCII-фолбэк для старых клиентов, `filename*` — полное UTF-8 имя.
*/
export function contentDisposition(type: 'inline' | 'attachment', fileName: string): string {
const fallback = fileName.replace(/[^\x20-\x7e]|["\\%]/g, '_');
const encoded = encodeURIComponent(fileName).replace(
/['()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
}
+1 -1
View File
@@ -17,6 +17,6 @@
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"strictPropertyInitialization": false, "strictPropertyInitialization": false,
"types": ["vitest/globals", "node"] "types": ["vitest/globals", "node", "multer"]
} }
} }