diff --git a/.dockerignore b/.dockerignore index 07ff2b4..4876e3c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,4 @@ coverage *.log tsconfig.build.tsbuildinfo src/generated +storage diff --git a/.gitignore b/.gitignore index 3d6ea3e..4f56534 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json /src/generated/prisma /src/schema.gql + +# Local file storage (FILE_STORAGE=local) +/storage diff --git a/package-lock.json b/package-lock.json index c8c2ab2..d6cd3f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@apollo/server": "^5.5.1", "@as-integrations/express5": "^1.1.2", + "@aws-sdk/client-s3": "^3.1145.0", "@better-auth/prisma-adapter": "^1.7.2", "@nestjs/apollo": "^14.0.0", "@nestjs/common": "^12.0.1", @@ -19,6 +20,7 @@ "@nestjs/event-emitter": "^12.0.1", "@nestjs/graphql": "^14.0.0", "@nestjs/platform-express": "^12.0.1", + "@nestjs/schedule": "^12.0.2", "@nestjs/typeorm": "^12.0.1", "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", @@ -26,6 +28,7 @@ "better-auth": "^1.7.2", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "file-type": "^22.1.1", "graphql": "^16.14.2", "nodemailer": "^10.0.0", "pg": "^8.23.0", @@ -40,6 +43,7 @@ "@nestjs/schematics": "^12.0.0", "@nestjs/testing": "^12.0.1", "@types/express": "^5.0.0", + "@types/multer": "^2.3.0", "@types/node": "^24.0.0", "@types/pg": "^8.23.1", "@types/supertest": "^7.0.0", @@ -371,6 +375,314 @@ "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": { "version": "7.29.7", "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" } }, + "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": { "version": "12.0.5", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.5.tgz", @@ -2806,6 +3134,87 @@ "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": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -3072,6 +3481,12 @@ "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", "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": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -3079,6 +3494,16 @@ "dev": true, "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": { "version": "24.13.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", @@ -3900,6 +4325,12 @@ "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": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -4451,6 +4882,23 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", @@ -6467,6 +6915,15 @@ "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": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", diff --git a/package.json b/package.json index 10789b2..82d92b8 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@apollo/server": "^5.5.1", "@as-integrations/express5": "^1.1.2", + "@aws-sdk/client-s3": "^3.1145.0", "@better-auth/prisma-adapter": "^1.7.2", "@nestjs/apollo": "^14.0.0", "@nestjs/common": "^12.0.1", @@ -32,6 +33,7 @@ "@nestjs/event-emitter": "^12.0.1", "@nestjs/graphql": "^14.0.0", "@nestjs/platform-express": "^12.0.1", + "@nestjs/schedule": "^12.0.2", "@nestjs/typeorm": "^12.0.1", "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", @@ -39,6 +41,7 @@ "better-auth": "^1.7.2", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "file-type": "^22.1.1", "graphql": "^16.14.2", "nodemailer": "^10.0.0", "pg": "^8.23.0", @@ -53,6 +56,7 @@ "@nestjs/schematics": "^12.0.0", "@nestjs/testing": "^12.0.1", "@types/express": "^5.0.0", + "@types/multer": "^2.3.0", "@types/node": "^24.0.0", "@types/pg": "^8.23.1", "@types/supertest": "^7.0.0", diff --git a/prisma/migrations/20261002045355_file/migration.sql b/prisma/migrations/20261002045355_file/migration.sql new file mode 100644 index 0000000..8615b9e --- /dev/null +++ b/prisma/migrations/20261002045355_file/migration.sql @@ -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; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d4e90f7..acbf82f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { notificationPreferences NotificationPreference[] notificationMessages NotificationMessage[] comments Comment[] + uploadedFiles File[] @@unique([email]) @@map("user") @@ -209,3 +210,23 @@ model Comment { @@index([authorId]) @@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") +} diff --git a/src/app.module.ts b/src/app.module.ts index ad2c133..7acf720 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,10 +7,12 @@ import { UserModule } from './modules/user/user.module.js'; import { StatusModule } from './modules/status/status.module.js'; import { NotificationModule } from './modules/notification/notification.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 { auth } from './lib/auth.js'; import { ConfigModule } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ScheduleModule } from '@nestjs/schedule'; import { GraphQLModule } from '@nestjs/graphql'; import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo'; import { join } from 'node:path'; @@ -24,6 +26,8 @@ import { join } from 'node:path'; // Внутрипроцессная шина доменных событий. Notification-модуль слушает // события через неё, не завязываясь на модули, которые их порождают. EventEmitterModule.forRoot(), + // Периодические задачи (@Cron), например сборка файлов-сирот в FileModule + ScheduleModule.forRoot(), GraphQLModule.forRoot({ driver: ApolloDriver, autoSchemaFile: join(process.cwd(), 'src/schema.gql'), @@ -48,6 +52,7 @@ import { join } from 'node:path'; StatusModule, NotificationModule, CommentModule, + FileModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/common/interceptors/transform-response.interceptor.ts b/src/common/interceptors/transform-response.interceptor.ts index c1a8364..67bc4fc 100644 --- a/src/common/interceptors/transform-response.interceptor.ts +++ b/src/common/interceptors/transform-response.interceptor.ts @@ -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 { map } from 'rxjs/operators'; import { SSE_METADATA } from '@nestjs/common/constants.js'; @@ -46,6 +46,11 @@ export class TransformResponseInterceptor implements NestInterceptor< return next.handle().pipe( map((result) => { + // Файл отдаётся потоком как есть — обёртка {data, code} сломала бы скачивание + if (result instanceof StreamableFile) { + return result; + } + if (isPaginatedResult(result)) { return { data: result.data ?? null, diff --git a/src/modules/file/application/dto/file.dto.ts b/src/modules/file/application/dto/file.dto.ts new file mode 100644 index 0000000..2120c52 --- /dev/null +++ b/src/modules/file/application/dto/file.dto.ts @@ -0,0 +1,9 @@ +export class FileDto { + id: string; + fileName: string; + mimeType: string; + size: number; + /** null — загрузивший удалил аккаунт */ + uploaderId: string | null; + createdAt: Date; +} diff --git a/src/modules/file/application/mappers/file-application.mapper.ts b/src/modules/file/application/mappers/file-application.mapper.ts new file mode 100644 index 0000000..6518489 --- /dev/null +++ b/src/modules/file/application/mappers/file-application.mapper.ts @@ -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, + }; + } +} diff --git a/src/modules/file/application/services/file-access.service.spec.ts b/src/modules/file/application/services/file-access.service.spec.ts new file mode 100644 index 0000000..bd714fd --- /dev/null +++ b/src/modules/file/application/services/file-access.service.spec.ts @@ -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); + }); +}); diff --git a/src/modules/file/application/services/file-access.service.ts b/src/modules/file/application/services/file-access.service.ts new file mode 100644 index 0000000..266838c --- /dev/null +++ b/src/modules/file/application/services/file-access.service.ts @@ -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 { + 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 { + const file = await this.getVisibleFile(id, userId); + file.assertOwnedBy(userId); + return FileApplicationMapper.toDto(file); + } +} diff --git a/src/modules/file/application/services/file-gc.service.spec.ts b/src/modules/file/application/services/file-gc.service.spec.ts new file mode 100644 index 0000000..2bb049f --- /dev/null +++ b/src/modules/file/application/services/file-gc.service.spec.ts @@ -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' }); + }); +}); diff --git a/src/modules/file/application/services/file-gc.service.ts b/src/modules/file/application/services/file-gc.service.ts new file mode 100644 index 0000000..a933b9c --- /dev/null +++ b/src/modules/file/application/services/file-gc.service.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/modules/file/application/services/file-owner.registry.ts b/src/modules/file/application/services/file-owner.registry.ts new file mode 100644 index 0000000..168252a --- /dev/null +++ b/src/modules/file/application/services/file-owner.registry.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; + +/** + * Модуль, к сущностям которого привязываются файлы (задачи, комментарии...). + * Связи хранятся в его таблицах (issue_attachment и т.п.), file-модуль + * о них не знает и спрашивает через этот интерфейс. + */ +export interface FileOwner { + /** Для логов */ + readonly name: string; + /** Подмножество fileIds, на которые ссылаются сущности этого владельца (для GC) */ + findReferenced(fileIds: string[]): Promise; + /** Виден ли файл пользователю через сущность этого владельца */ + isVisibleThrough(fileId: string, userId: string): Promise; +} + +/** + * Реестр владельцев файлов. Модуль-владелец регистрируется в 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> { + 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 { + for (const owner of this.owners) { + if (await owner.isVisibleThrough(fileId, userId)) { + return true; + } + } + return false; + } +} diff --git a/src/modules/file/application/use-cases/delete-file/delete-file.command.ts b/src/modules/file/application/use-cases/delete-file/delete-file.command.ts new file mode 100644 index 0000000..90990ac --- /dev/null +++ b/src/modules/file/application/use-cases/delete-file/delete-file.command.ts @@ -0,0 +1,6 @@ +export class DeleteFileCommand { + constructor( + public readonly id: string, + public readonly userId: string, + ) {} +} diff --git a/src/modules/file/application/use-cases/delete-file/delete-file.use-case.spec.ts b/src/modules/file/application/use-cases/delete-file/delete-file.use-case.spec.ts new file mode 100644 index 0000000..4fe0885 --- /dev/null +++ b/src/modules/file/application/use-cases/delete-file/delete-file.use-case.spec.ts @@ -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); + }); +}); diff --git a/src/modules/file/application/use-cases/delete-file/delete-file.use-case.ts b/src/modules/file/application/use-cases/delete-file/delete-file.use-case.ts new file mode 100644 index 0000000..2a4e2a0 --- /dev/null +++ b/src/modules/file/application/use-cases/delete-file/delete-file.use-case.ts @@ -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 { + 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); + } +} diff --git a/src/modules/file/application/use-cases/download-file/download-file.query.ts b/src/modules/file/application/use-cases/download-file/download-file.query.ts new file mode 100644 index 0000000..15dc83a --- /dev/null +++ b/src/modules/file/application/use-cases/download-file/download-file.query.ts @@ -0,0 +1,6 @@ +export class DownloadFileQuery { + constructor( + public readonly id: string, + public readonly userId: string, + ) {} +} diff --git a/src/modules/file/application/use-cases/download-file/download-file.use-case.spec.ts b/src/modules/file/application/use-cases/download-file/download-file.use-case.spec.ts new file mode 100644 index 0000000..aceab3d --- /dev/null +++ b/src/modules/file/application/use-cases/download-file/download-file.use-case.spec.ts @@ -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); + }); +}); diff --git a/src/modules/file/application/use-cases/download-file/download-file.use-case.ts b/src/modules/file/application/use-cases/download-file/download-file.use-case.ts new file mode 100644 index 0000000..97087b6 --- /dev/null +++ b/src/modules/file/application/use-cases/download-file/download-file.use-case.ts @@ -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 { + 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, + }; + } +} diff --git a/src/modules/file/application/use-cases/get-files/get-files.query.ts b/src/modules/file/application/use-cases/get-files/get-files.query.ts new file mode 100644 index 0000000..93791eb --- /dev/null +++ b/src/modules/file/application/use-cases/get-files/get-files.query.ts @@ -0,0 +1,3 @@ +export class GetFilesQuery { + constructor(public readonly ids: string[]) {} +} diff --git a/src/modules/file/application/use-cases/get-files/get-files.use-case.ts b/src/modules/file/application/use-cases/get-files/get-files.use-case.ts new file mode 100644 index 0000000..75a6482 --- /dev/null +++ b/src/modules/file/application/use-cases/get-files/get-files.use-case.ts @@ -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 { + const ids = [...new Set(query.ids)]; + const files = await this.fileRepository.findByIds(ids); + return files.map(FileApplicationMapper.toDto); + } +} diff --git a/src/modules/file/application/use-cases/test-helpers.ts b/src/modules/file/application/use-cases/test-helpers.ts new file mode 100644 index 0000000..65fb340 --- /dev/null +++ b/src/modules/file/application/use-cases/test-helpers.ts @@ -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 } = {}) => { + 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; +}; diff --git a/src/modules/file/application/use-cases/upload-file/upload-file.command.ts b/src/modules/file/application/use-cases/upload-file/upload-file.command.ts new file mode 100644 index 0000000..759ac0e --- /dev/null +++ b/src/modules/file/application/use-cases/upload-file/upload-file.command.ts @@ -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, + ) {} +} diff --git a/src/modules/file/application/use-cases/upload-file/upload-file.use-case.spec.ts b/src/modules/file/application/use-cases/upload-file/upload-file.use-case.spec.ts new file mode 100644 index 0000000..dfbd39b --- /dev/null +++ b/src/modules/file/application/use-cases/upload-file/upload-file.use-case.spec.ts @@ -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); + }); +}); diff --git a/src/modules/file/application/use-cases/upload-file/upload-file.use-case.ts b/src/modules/file/application/use-cases/upload-file/upload-file.use-case.ts new file mode 100644 index 0000000..203c85f --- /dev/null +++ b/src/modules/file/application/use-cases/upload-file/upload-file.use-case.ts @@ -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 { + // Заголовку 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; + } + } +} diff --git a/src/modules/file/domain/entities/stored-file.entity.spec.ts b/src/modules/file/domain/entities/stored-file.entity.spec.ts new file mode 100644 index 0000000..d862a37 --- /dev/null +++ b/src/modules/file/domain/entities/stored-file.entity.spec.ts @@ -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); + }); +}); diff --git a/src/modules/file/domain/entities/stored-file.entity.ts b/src/modules/file/domain/entities/stored-file.entity.ts new file mode 100644 index 0000000..b76ccba --- /dev/null +++ b/src/modules/file/domain/entities/stored-file.entity.ts @@ -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'); + } + } +} diff --git a/src/modules/file/domain/exceptions/file-forbidden.exception.ts b/src/modules/file/domain/exceptions/file-forbidden.exception.ts new file mode 100644 index 0000000..b5e6d29 --- /dev/null +++ b/src/modules/file/domain/exceptions/file-forbidden.exception.ts @@ -0,0 +1,6 @@ +export class FileForbiddenException extends Error { + constructor(id: string) { + super(`Нет прав на это действие с файлом id=${id}`); + this.name = 'FileForbiddenException'; + } +} diff --git a/src/modules/file/domain/exceptions/file-not-found.exception.ts b/src/modules/file/domain/exceptions/file-not-found.exception.ts new file mode 100644 index 0000000..17a6efb --- /dev/null +++ b/src/modules/file/domain/exceptions/file-not-found.exception.ts @@ -0,0 +1,6 @@ +export class FileNotFoundException extends Error { + constructor(id: string) { + super(`Файл с id=${id} не найден`); + this.name = 'FileNotFoundException'; + } +} diff --git a/src/modules/file/domain/ports/file-storage.port.ts b/src/modules/file/domain/ports/file-storage.port.ts new file mode 100644 index 0000000..9d1367a --- /dev/null +++ b/src/modules/file/domain/ports/file-storage.port.ts @@ -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; + /** Бросает ошибку сразу, если объекта нет, — до того как ответ начнёт отправляться */ + get(key: string): Promise; + /** Идемпотентно: отсутствие объекта не ошибка */ + delete(key: string): Promise; +} diff --git a/src/modules/file/domain/ports/file-type-detector.port.ts b/src/modules/file/domain/ports/file-type-detector.port.ts new file mode 100644 index 0000000..b4c36e8 --- /dev/null +++ b/src/modules/file/domain/ports/file-type-detector.port.ts @@ -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; +} diff --git a/src/modules/file/domain/repositories/file.repository.interface.ts b/src/modules/file/domain/repositories/file.repository.interface.ts new file mode 100644 index 0000000..1596321 --- /dev/null +++ b/src/modules/file/domain/repositories/file.repository.interface.ts @@ -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; + findByIds(ids: string[]): Promise; + /** + * Файлы, созданные раньше `before`, по возрастанию id (uuid v7 ≈ время создания). + * Курсорная пагинация для GC: cursor — id последнего полученного файла. + */ + findCreatedBefore(params: { before: Date; limit: number; cursor?: string }): Promise; + create(file: StoredFile): Promise; + delete(id: string): Promise; +} diff --git a/src/modules/file/domain/value-objects/file-name.vo.spec.ts b/src/modules/file/domain/value-objects/file-name.vo.spec.ts new file mode 100644 index 0000000..965e595 --- /dev/null +++ b/src/modules/file/domain/value-objects/file-name.vo.spec.ts @@ -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); + }); +}); diff --git a/src/modules/file/domain/value-objects/file-name.vo.ts b/src/modules/file/domain/value-objects/file-name.vo.ts new file mode 100644 index 0000000..4599fe0 --- /dev/null +++ b/src/modules/file/domain/value-objects/file-name.vo.ts @@ -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; + } +} diff --git a/src/modules/file/file.module.ts b/src/modules/file/file.module.ts new file mode 100644 index 0000000..2f6bd19 --- /dev/null +++ b/src/modules/file/file.module.ts @@ -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 {} diff --git a/src/modules/file/infrastructure/persistence/prisma/mappers/file.mapper.ts b/src/modules/file/infrastructure/persistence/prisma/mappers/file.mapper.ts new file mode 100644 index 0000000..1f809c2 --- /dev/null +++ b/src/modules/file/infrastructure/persistence/prisma/mappers/file.mapper.ts @@ -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, + }; + } +} diff --git a/src/modules/file/infrastructure/persistence/prisma/repositories/file.prisma-repository.ts b/src/modules/file/infrastructure/persistence/prisma/repositories/file.prisma-repository.ts new file mode 100644 index 0000000..977a9d8 --- /dev/null +++ b/src/modules/file/infrastructure/persistence/prisma/repositories/file.prisma-repository.ts @@ -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 { + const record = await this.prisma.file.findUnique({ where: { id } }); + return record ? FilePrismaMapper.toDomain(record) : null; + } + + async findByIds(ids: string[]): Promise { + 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 { + 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 { + const record = await this.prisma.file.create({ + data: FilePrismaMapper.toCreateInput(file), + }); + + return FilePrismaMapper.toDomain(record); + } + + async delete(id: string): Promise { + // deleteMany не падает, если строку уже удалил параллельный запрос/GC + await this.prisma.file.deleteMany({ where: { id } }); + } +} diff --git a/src/modules/file/infrastructure/storage/file-storage.factory.ts b/src/modules/file/infrastructure/storage/file-storage.factory.ts new file mode 100644 index 0000000..1e3db64 --- /dev/null +++ b/src/modules/file/infrastructure/storage/file-storage.factory.ts @@ -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('FILE_STORAGE', 'local'); + + if (driver === 's3') { + return new S3FileStorage({ + endpoint: config.get('S3_ENDPOINT') || undefined, + region: config.get('S3_REGION', 'us-east-1'), + bucket: config.getOrThrow('S3_BUCKET'), + accessKeyId: config.getOrThrow('S3_ACCESS_KEY_ID'), + secretAccessKey: config.getOrThrow('S3_SECRET_ACCESS_KEY'), + forcePathStyle: config.get('S3_FORCE_PATH_STYLE', 'true') !== 'false', + }); + } + + if (driver !== 'local') { + throw new Error(`Неизвестный FILE_STORAGE=${driver}, ожидается local | s3`); + } + + return new LocalFileStorage(config.get('FILE_STORAGE_LOCAL_DIR', './storage')); +} diff --git a/src/modules/file/infrastructure/storage/file-type.detector.ts b/src/modules/file/infrastructure/storage/file-type.detector.ts new file mode 100644 index 0000000..a6f2a92 --- /dev/null +++ b/src/modules/file/infrastructure/storage/file-type.detector.ts @@ -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 { + return (await fileTypeFromFile(path))?.mime ?? null; + } +} diff --git a/src/modules/file/infrastructure/storage/local-file-storage.spec.ts b/src/modules/file/infrastructure/storage/local-file-storage.spec.ts new file mode 100644 index 0000000..ea534ec --- /dev/null +++ b/src/modules/file/infrastructure/storage/local-file-storage.spec.ts @@ -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('Недопустимый ключ'); + }); +}); diff --git a/src/modules/file/infrastructure/storage/local-file-storage.ts b/src/modules/file/infrastructure/storage/local-file-storage.ts new file mode 100644 index 0000000..0ca04b8 --- /dev/null +++ b/src/modules/file/infrastructure/storage/local-file-storage.ts @@ -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 { + const path = this.resolveKey(key); + await mkdir(dirname(path), { recursive: true }); + await pipeline(body, createWriteStream(path)); + } + + async get(key: string): Promise { + const path = this.resolveKey(key); + await stat(path); + return createReadStream(path); + } + + async delete(key: string): Promise { + 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; + } +} diff --git a/src/modules/file/infrastructure/storage/s3-file-storage.ts b/src/modules/file/infrastructure/storage/s3-file-storage.ts new file mode 100644 index 0000000..070ae56 --- /dev/null +++ b/src/modules/file/infrastructure/storage/s3-file-storage.ts @@ -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 { + 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 { + 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 { + // DeleteObject в S3 идемпотентен — отсутствие объекта не ошибка + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); + } +} diff --git a/src/modules/file/presentation/controllers/file.controller.ts b/src/modules/file/presentation/controllers/file.controller.ts new file mode 100644 index 0000000..dba7b8d --- /dev/null +++ b/src/modules/file/presentation/controllers/file.controller.ts @@ -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 { + 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(fn: () => Promise): Promise { + 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; + } + } +} diff --git a/src/modules/file/presentation/graphql/file.type.ts b/src/modules/file/presentation/graphql/file.type.ts new file mode 100644 index 0000000..2659104 --- /dev/null +++ b/src/modules/file/presentation/graphql/file.type.ts @@ -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; +} diff --git a/src/modules/file/presentation/helpers/content-disposition.spec.ts b/src/modules/file/presentation/helpers/content-disposition.spec.ts new file mode 100644 index 0000000..abf8ed3 --- /dev/null +++ b/src/modules/file/presentation/helpers/content-disposition.spec.ts @@ -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`, + ); + }); +}); diff --git a/src/modules/file/presentation/helpers/content-disposition.ts b/src/modules/file/presentation/helpers/content-disposition.ts new file mode 100644 index 0000000..427d0f3 --- /dev/null +++ b/src/modules/file/presentation/helpers/content-disposition.ts @@ -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}`; +} diff --git a/tsconfig.json b/tsconfig.json index 8109c41..5ab0f26 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,6 @@ "skipLibCheck": true, "strict": true, "strictPropertyInitialization": false, - "types": ["vitest/globals", "node"] + "types": ["vitest/globals", "node", "multer"] } }