diff --git a/CHANGELOG.md b/CHANGELOG.md index aa933593f..cababd58d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [0.18.2](https://github.com/stonith404/pingvin-share/compare/v0.18.1...v0.18.2) (2023-10-09) + + +### Bug Fixes + +* disable image optimizations for logo to prevent caching issues with custom logos ([3891900](https://github.com/stonith404/pingvin-share/commit/38919003e9091203b507d0f0b061f4a1835ff4f4)) +* memory leak while downloading large files ([97e7d71](https://github.com/stonith404/pingvin-share/commit/97e7d7190dfe219caf441dffcd7830c304c3c939)) + ## [0.18.1](https://github.com/stonith404/pingvin-share/compare/v0.18.0...v0.18.1) (2023-09-22) diff --git a/Dockerfile b/Dockerfile index 60f3f1432..ba3893ad6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN npm run build # Stage 3: Backend dependencies FROM node:20-alpine AS backend-dependencies +RUN apk add --no-cache python3 WORKDIR /opt/app COPY backend/package.json backend/package-lock.json ./ RUN npm ci @@ -32,7 +33,9 @@ ENV NODE_ENV=docker # Alpine specific dependencies RUN apk update --no-cache RUN apk upgrade --no-cache -RUN apk add --no-cache curl +RUN apk add --no-cache curl nginx + +COPY ./nginx/nginx.conf /etc/nginx/nginx.conf WORKDIR /opt/app/frontend COPY --from=frontend-builder /opt/app/public ./public @@ -55,4 +58,4 @@ HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/he # Application startup # HOSTNAME=0.0.0.0 fixes https://github.com/vercel/next.js/issues/51684. It can be removed as soon as the issue is fixed -CMD cp -rn /tmp/img /opt/app/frontend/public && HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod \ No newline at end of file +CMD cp -rn /tmp/img /opt/app/frontend/public && nginx && PORT=3333 HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod \ No newline at end of file diff --git a/README.md b/README.md index 672a80af3..974d1a236 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ --- -_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md)_ +_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_ --- @@ -63,6 +63,8 @@ npm run build pm2 start --name="pingvin-share-frontend" npm -- run start ``` +**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Nginx can be found in `/nginx/nginx.conf`. + The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧! ### Integrations @@ -77,6 +79,10 @@ ClamAV is used to scan shares for malicious files and remove them if found. Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements). +#### OAuth 2 Login + +View the [OAuth 2 guide](/docs/oauth2-guide.md) for more information. + ### Additional resources - [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/) diff --git a/backend/package-lock.json b/backend/package-lock.json index f18b79d54..117759390 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,13 +1,14 @@ { "name": "pingvin-share-backend", - "version": "0.18.1", + "version": "0.18.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "pingvin-share-backend", - "version": "0.18.1", + "version": "0.18.2", "dependencies": { + "@nestjs/cache-manager": "^2.1.0", "@nestjs/common": "^10.1.2", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.1.2", @@ -21,6 +22,7 @@ "archiver": "^5.3.1", "argon2": "^0.30.3", "body-parser": "^1.20.2", + "cache-manager": "^5.2.4", "clamscan": "^2.1.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -28,6 +30,8 @@ "cookie-parser": "^1.4.6", "mime-types": "^2.1.35", "moment": "^2.29.4", + "nanoid": "^3.3.6", + "node-fetch": "^2.7.0", "nodemailer": "^6.9.4", "otplib": "^12.0.1", "passport": "^0.6.0", @@ -52,6 +56,7 @@ "@types/mime-types": "^2.1.1", "@types/multer": "^1.4.7", "@types/node": "^20.4.5", + "@types/node-fetch": "^2.6.6", "@types/nodemailer": "^6.4.9", "@types/passport-jwt": "^3.0.9", "@types/qrcode-svg": "^1.1.1", @@ -622,6 +627,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@nestjs/cache-manager": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@nestjs/cache-manager/-/cache-manager-2.1.0.tgz", + "integrity": "sha512-9kep3a8Mq5cMuXN/anGhSYc0P48CRBXk5wyJJRBFxhNkCH8AIzZF4CASGVDIEMmm3OjVcEUHojjyJwCODS17Qw==", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "cache-manager": "<=5", + "reflect-metadata": "^0.1.12", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.1.10", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.1.10.tgz", @@ -1438,6 +1455,16 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz", "integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==" }, + "node_modules/@types/node-fetch": { + "version": "2.6.6", + "resolved": "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.6.tgz", + "integrity": "sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/nodemailer": { "version": "6.4.9", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz", @@ -2525,6 +2552,23 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/cache-manager/-/cache-manager-5.2.4.tgz", + "integrity": "sha512-gkuCjug16NdGvKm/sydxGVx17uffrSWcEe2xraBtwRCgdYcFxwJAla4OYpASAZT2yhSoxgDiWL9XH6IAChcZJA==", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.0.1" + } + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.0.1.tgz", + "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==", + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -5248,6 +5292,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -5572,6 +5621,17 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -5733,9 +5793,9 @@ } }, "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -7833,7 +7893,7 @@ }, "node_modules/tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/tree-kill": { @@ -8235,7 +8295,7 @@ }, "node_modules/webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { @@ -8305,7 +8365,7 @@ }, "node_modules/whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dependencies": { "tr46": "~0.0.3", @@ -8951,6 +9011,12 @@ } } }, + "@nestjs/cache-manager": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@nestjs/cache-manager/-/cache-manager-2.1.0.tgz", + "integrity": "sha512-9kep3a8Mq5cMuXN/anGhSYc0P48CRBXk5wyJJRBFxhNkCH8AIzZF4CASGVDIEMmm3OjVcEUHojjyJwCODS17Qw==", + "requires": {} + }, "@nestjs/cli": { "version": "10.1.10", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.1.10.tgz", @@ -9542,6 +9608,16 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz", "integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==" }, + "@types/node-fetch": { + "version": "2.6.6", + "resolved": "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.6.tgz", + "integrity": "sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==", + "dev": true, + "requires": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "@types/nodemailer": { "version": "6.4.9", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz", @@ -10386,6 +10462,22 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" }, + "cache-manager": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/cache-manager/-/cache-manager-5.2.4.tgz", + "integrity": "sha512-gkuCjug16NdGvKm/sydxGVx17uffrSWcEe2xraBtwRCgdYcFxwJAla4OYpASAZT2yhSoxgDiWL9XH6IAChcZJA==", + "requires": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.0.1" + }, + "dependencies": { + "lru-cache": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.0.1.tgz", + "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==" + } + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -12394,6 +12486,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -12636,6 +12733,11 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==" + }, "napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -12767,9 +12869,9 @@ } }, "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "requires": { "whatwg-url": "^5.0.0" } @@ -14306,7 +14408,7 @@ }, "tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "tree-kill": { @@ -14586,7 +14688,7 @@ }, "webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { @@ -14635,7 +14737,7 @@ }, "whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "requires": { "tr46": "~0.0.3", diff --git a/backend/package.json b/backend/package.json index 843d30d77..bb50e9672 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "pingvin-share-backend", - "version": "0.18.1", + "version": "0.18.2", "scripts": { "build": "nest build", "dev": "cross-env NODE_ENV=development nest start --watch", @@ -13,6 +13,7 @@ "seed": "ts-node prisma/seed/config.seed.ts" }, "dependencies": { + "@nestjs/cache-manager": "^2.1.0", "@nestjs/common": "^10.1.2", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.1.2", @@ -26,6 +27,7 @@ "archiver": "^5.3.1", "argon2": "^0.30.3", "body-parser": "^1.20.2", + "cache-manager": "^5.2.4", "clamscan": "^2.1.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -33,6 +35,8 @@ "cookie-parser": "^1.4.6", "mime-types": "^2.1.35", "moment": "^2.29.4", + "nanoid": "^3.3.6", + "node-fetch": "^2.7.0", "nodemailer": "^6.9.4", "otplib": "^12.0.1", "passport": "^0.6.0", @@ -57,6 +61,7 @@ "@types/mime-types": "^2.1.1", "@types/multer": "^1.4.7", "@types/node": "^20.4.5", + "@types/node-fetch": "^2.6.6", "@types/nodemailer": "^6.4.9", "@types/passport-jwt": "^3.0.9", "@types/qrcode-svg": "^1.1.1", diff --git a/backend/prisma/migrations/20231021165436_oauth/migration.sql b/backend/prisma/migrations/20231021165436_oauth/migration.sql new file mode 100644 index 000000000..4887b726c --- /dev/null +++ b/backend/prisma/migrations/20231021165436_oauth/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "OAuthUser" ( + "id" TEXT NOT NULL PRIMARY KEY, + "provider" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerUsername" TEXT NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "OAuthUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT, + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "totpEnabled" BOOLEAN NOT NULL DEFAULT false, + "totpVerified" BOOLEAN NOT NULL DEFAULT false, + "totpSecret" TEXT +); +INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username") SELECT "createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index de898d354..c83a9f04b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -14,7 +14,7 @@ model User { username String @unique email String @unique - password String + password String? isAdmin Boolean @default(false) shares Share[] @@ -26,6 +26,8 @@ model User { totpVerified Boolean @default(false) totpSecret String? resetPasswordToken ResetPasswordToken? + + oAuthUsers OAuthUser[] } model RefreshToken { @@ -60,6 +62,15 @@ model ResetPasswordToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model OAuthUser { + id String @id @default(uuid()) + provider String + providerUserId String + providerUsername String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + model Share { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -134,7 +145,7 @@ model Config { name String category String type String - defaultValue String @default("") + defaultValue String @default("") value String? obscured Boolean @default(false) secret Boolean @default(true) diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index b7a78f4bb..e48b591d9 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -119,6 +119,89 @@ const configVariables: ConfigVariables = { obscured: true, }, }, + oauth: { + "allowRegistration": { + type: "boolean", + defaultValue: "true", + }, + "ignoreTotp": { + type: "boolean", + defaultValue: "true", + }, + "github-enabled": { + type: "boolean", + defaultValue: "false", + }, + "github-clientId": { + type: "string", + defaultValue: "", + }, + "github-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + "google-enabled": { + type: "boolean", + defaultValue: "false", + }, + "google-clientId": { + type: "string", + defaultValue: "", + }, + "google-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + "microsoft-enabled": { + type: "boolean", + defaultValue: "false", + }, + "microsoft-tenant": { + type: "string", + defaultValue: "common", + }, + "microsoft-clientId": { + type: "string", + defaultValue: "", + }, + "microsoft-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + "discord-enabled": { + type: "boolean", + defaultValue: "false", + }, + "discord-clientId": { + type: "string", + defaultValue: "", + }, + "discord-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + "oidc-enabled": { + type: "boolean", + defaultValue: "false", + }, + "oidc-discoveryUri": { + type: "string", + defaultValue: "", + }, + "oidc-clientId": { + type: "string", + defaultValue: "", + }, + "oidc-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + } }; type ConfigVariables = { @@ -175,7 +258,7 @@ async function migrateConfigVariables() { const configVariable = configVariables[existingConfigVariable.category]?.[ existingConfigVariable.name - ]; + ]; if (!configVariable) { await prisma.config.delete({ where: { diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts new file mode 100644 index 000000000..f7b3bc9c4 --- /dev/null +++ b/backend/src/app.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Res } from "@nestjs/common"; +import { Response } from "express"; +import { PrismaService } from "./prisma/prisma.service"; + +@Controller("/") +export class AppController { + constructor(private prismaService: PrismaService) {} + + @Get("health") + async health(@Res({ passthrough: true }) res: Response) { + try { + await this.prismaService.config.findMany(); + return "OK"; + } catch { + res.statusCode = 500; + return "ERROR"; + } + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7f47e08a5..949424571 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,9 @@ import { ShareModule } from "./share/share.module"; import { UserModule } from "./user/user.module"; import { ClamScanModule } from "./clamscan/clamscan.module"; import { ReverseShareModule } from "./reverseShare/reverseShare.module"; +import { AppController } from "./app.controller"; +import { OAuthModule } from "./oauth/oauth.module"; +import { CacheModule } from "@nestjs/cache-manager"; @Module({ imports: [ @@ -32,7 +35,12 @@ import { ReverseShareModule } from "./reverseShare/reverseShare.module"; ScheduleModule.forRoot(), ClamScanModule, ReverseShareModule, + OAuthModule, + CacheModule.register({ + isGlobal: true, + }), ], + controllers: [AppController], providers: [ { provide: APP_GUARD, diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 9f37f18bb..1816e7bff 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -47,7 +47,7 @@ export class AuthController { const result = await this.authService.signUp(dto); - response = this.addTokensToResponse( + this.authService.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -66,7 +66,7 @@ export class AuthController { const result = await this.authService.signIn(dto); if (result.accessToken && result.refreshToken) { - response = this.addTokensToResponse( + this.authService.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -85,7 +85,7 @@ export class AuthController { ) { const result = await this.authTotpService.signInTotp(dto); - response = this.addTokensToResponse( + this.authService.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -117,11 +117,11 @@ export class AuthController { ) { const result = await this.authService.updatePassword( user, - dto.oldPassword, dto.password, + dto.oldPassword, ); - response = this.addTokensToResponse(response, result.refreshToken); + this.authService.addTokensToResponse(response, result.refreshToken); return new TokenDTO().from(result); } @@ -136,7 +136,7 @@ export class AuthController { const accessToken = await this.authService.refreshAccessToken( request.cookies.refresh_token, ); - response = this.addTokensToResponse(response, undefined, accessToken); + this.authService.addTokensToResponse(response, undefined, accessToken); return new TokenDTO().from({ accessToken }); } @@ -172,22 +172,4 @@ export class AuthController { // Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code return this.authTotpService.disableTotp(user, body.password, body.code); } - - private addTokensToResponse( - response: Response, - refreshToken?: string, - accessToken?: string, - ) { - if (accessToken) - response.cookie("access_token", accessToken, { sameSite: "lax" }); - if (refreshToken) - response.cookie("refresh_token", refreshToken, { - path: "/api/auth/token", - httpOnly: true, - sameSite: "strict", - maxAge: 1000 * 60 * 60 * 24 * 30 * 3, - }); - - return response; - } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 56204d134..a96ab2fa8 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -7,7 +7,12 @@ import { AuthTotpService } from "./authTotp.service"; import { JwtStrategy } from "./strategy/jwt.strategy"; @Module({ - imports: [JwtModule.register({}), EmailModule], + imports: [ + JwtModule.register({ + global: true, + }), + EmailModule, + ], controllers: [AuthController], providers: [AuthService, AuthTotpService, JwtStrategy], exports: [AuthService], diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 27d2edc00..d46c14c82 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -8,6 +8,7 @@ import { JwtService } from "@nestjs/jwt"; import { User } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import * as argon from "argon2"; +import { Request, Response } from "express"; import * as moment from "moment"; import { ConfigService } from "src/config/config.service"; import { EmailService } from "src/email/email.service"; @@ -27,7 +28,7 @@ export class AuthService { async signUp(dto: AuthRegisterDTO) { const isFirstUser = (await this.prisma.user.count()) == 0; - const hash = await argon.hash(dto.password); + const hash = dto.password ? await argon.hash(dto.password) : null; try { const user = await this.prisma.user.create({ data: { @@ -43,7 +44,7 @@ export class AuthService { ); const accessToken = await this.createAccessToken(user, refreshTokenId); - return { accessToken, refreshToken }; + return { accessToken, refreshToken, user }; } catch (e) { if (e instanceof PrismaClientKnownRequestError) { if (e.code == "P2002") { @@ -69,9 +70,16 @@ export class AuthService { if (!user || !(await argon.verify(user.password, dto.password))) throw new UnauthorizedException("Wrong email or password"); + return this.generateToken(user); + } + + async generateToken(user: User, isOAuth = false) { // TODO: Make all old loginTokens invalid when a new one is created // Check if the user has TOTP enabled - if (user.totpVerified) { + if ( + user.totpVerified && + !(isOAuth && this.config.get("oauth.ignoreTotp")) + ) { const loginToken = await this.createLoginToken(user.id); return { loginToken }; @@ -129,9 +137,11 @@ export class AuthService { }); } - async updatePassword(user: User, oldPassword: string, newPassword: string) { - if (!(await argon.verify(user.password, oldPassword))) - throw new ForbiddenException("Invalid password"); + async updatePassword(user: User, newPassword: string, oldPassword?: string) { + const isPasswordValid = + !user.password || !(await argon.verify(user.password, oldPassword)); + + if (!isPasswordValid) throw new ForbiddenException("Invalid password"); const hash = await argon.hash(newPassword); @@ -210,4 +220,38 @@ export class AuthService { return loginToken; } + + addTokensToResponse( + response: Response, + refreshToken?: string, + accessToken?: string, + ) { + if (accessToken) + response.cookie("access_token", accessToken, { sameSite: "lax" }); + if (refreshToken) + response.cookie("refresh_token", refreshToken, { + path: "/api/auth/token", + httpOnly: true, + sameSite: "strict", + maxAge: 1000 * 60 * 60 * 24 * 30 * 3, + }); + } + + /** + * Returns the user id if the user is logged in, null otherwise + */ + async getIdOfCurrentUser(request: Request): Promise { + if (!request.cookies.access_token) return null; + try { + const payload = await this.jwtService.verifyAsync( + request.cookies.access_token, + { + secret: this.config.get("internal.jwtSecret"), + }, + ); + return payload.sub; + } catch { + return null; + } + } } diff --git a/backend/src/auth/authTotp.service.ts b/backend/src/auth/authTotp.service.ts index 3760ab8a1..1b4f0d427 100644 --- a/backend/src/auth/authTotp.service.ts +++ b/backend/src/auth/authTotp.service.ts @@ -22,43 +22,29 @@ export class AuthTotpService { ) {} async signInTotp(dto: AuthSignInTotpDTO) { - if (!dto.email && !dto.username) - throw new BadRequestException("Email or username is required"); - - const user = await this.prisma.user.findFirst({ - where: { - OR: [{ email: dto.email }, { username: dto.username }], - }, - }); - - if (!user || !(await argon.verify(user.password, dto.password))) - throw new UnauthorizedException("Wrong email or password"); - const token = await this.prisma.loginToken.findFirst({ where: { token: dto.loginToken, }, + include: { + user: true, + }, }); - if (!token || token.userId != user.id || token.used) + if (!token || token.used) throw new UnauthorizedException("Invalid login token"); if (token.expiresAt < new Date()) throw new UnauthorizedException("Login token expired", "token_expired"); // Check the TOTP code - const { totpSecret } = await this.prisma.user.findUnique({ - where: { id: user.id }, - select: { totpSecret: true }, - }); + const { totpSecret } = token.user; if (!totpSecret) { throw new BadRequestException("TOTP is not enabled"); } - const expected = authenticator.generate(totpSecret); - - if (dto.totp !== expected) { + if (!authenticator.check(dto.totp, totpSecret)) { throw new BadRequestException("Invalid code"); } @@ -69,9 +55,9 @@ export class AuthTotpService { }); const { refreshToken, refreshTokenId } = - await this.authService.createRefreshToken(user.id); + await this.authService.createRefreshToken(token.user.id); const accessToken = await this.authService.createAccessToken( - user, + token.user, refreshTokenId, ); diff --git a/backend/src/auth/dto/authSignInTotp.dto.ts b/backend/src/auth/dto/authSignInTotp.dto.ts index 835b5913a..5ba96db6a 100644 --- a/backend/src/auth/dto/authSignInTotp.dto.ts +++ b/backend/src/auth/dto/authSignInTotp.dto.ts @@ -1,7 +1,7 @@ import { IsString } from "class-validator"; import { AuthSignInDTO } from "./authSignIn.dto"; -export class AuthSignInTotpDTO extends AuthSignInDTO { +export class AuthSignInTotpDTO { @IsString() totp: string; diff --git a/backend/src/auth/dto/updatePassword.dto.ts b/backend/src/auth/dto/updatePassword.dto.ts index ee6b0e07f..c154785a3 100644 --- a/backend/src/auth/dto/updatePassword.dto.ts +++ b/backend/src/auth/dto/updatePassword.dto.ts @@ -1,8 +1,9 @@ import { PickType } from "@nestjs/swagger"; -import { IsString } from "class-validator"; +import { IsOptional, IsString } from "class-validator"; import { UserDTO } from "src/user/dto/user.dto"; export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) { @IsString() - oldPassword: string; + @IsOptional() + oldPassword?: string; } diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts index 431a127e1..a5e02a761 100644 --- a/backend/src/config/config.service.ts +++ b/backend/src/config/config.service.ts @@ -6,13 +6,20 @@ import { } from "@nestjs/common"; import { Config } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; +import { EventEmitter } from "events"; +/** + * ConfigService extends EventEmitter to allow listening for config updates, + * now only `update` event will be emitted. + */ @Injectable() -export class ConfigService { +export class ConfigService extends EventEmitter { constructor( @Inject("CONFIG_VARIABLES") private configVariables: Config[], private prisma: PrismaService, - ) {} + ) { + super(); + } get(key: `${string}.${string}`): any { const configVariable = this.configVariables.filter( @@ -105,6 +112,8 @@ export class ConfigService { this.configVariables = await this.prisma.config.findMany(); + this.emit("update", key, value); + return updatedVariable; } } diff --git a/backend/src/config/logo.service.ts b/backend/src/config/logo.service.ts index 1fdadcaf5..e975d68bc 100644 --- a/backend/src/config/logo.service.ts +++ b/backend/src/config/logo.service.ts @@ -7,7 +7,8 @@ const IMAGES_PATH = "../frontend/public/img"; @Injectable() export class LogoService { async create(file: Buffer) { - fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "binary"); + const resized = await sharp(file).resize(900).toBuffer(); + fs.writeFileSync(`${IMAGES_PATH}/logo.png`, resized, "binary"); this.createFavicon(file); this.createPWAIcons(file); } diff --git a/backend/src/oauth/dto/oauthCallback.dto.ts b/backend/src/oauth/dto/oauthCallback.dto.ts new file mode 100644 index 000000000..656b63a3c --- /dev/null +++ b/backend/src/oauth/dto/oauthCallback.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from "class-validator"; + +export class OAuthCallbackDto { + @IsString() + code: string; + + @IsString() + state: string; +} diff --git a/backend/src/oauth/dto/oauthSignIn.dto.ts b/backend/src/oauth/dto/oauthSignIn.dto.ts new file mode 100644 index 000000000..75a64ad38 --- /dev/null +++ b/backend/src/oauth/dto/oauthSignIn.dto.ts @@ -0,0 +1,6 @@ +export interface OAuthSignInDto { + provider: "github" | "google" | "microsoft" | "discord" | "oidc"; + providerId: string; + providerUsername: string; + email: string; +} diff --git a/backend/src/oauth/exceptions/errorPage.exception.ts b/backend/src/oauth/exceptions/errorPage.exception.ts new file mode 100644 index 000000000..6ebb10a6e --- /dev/null +++ b/backend/src/oauth/exceptions/errorPage.exception.ts @@ -0,0 +1,15 @@ +export class ErrorPageException extends Error { + /** + * Exception for redirecting to error page (all i18n key should omit `error.msg` and `error.param` prefix) + * @param key i18n key of message + * @param redirect redirect url + * @param params message params (key) + */ + constructor( + public readonly key: string = "default", + public readonly redirect: string = "/", + public readonly params?: string[], + ) { + super("error"); + } +} diff --git a/backend/src/oauth/filter/errorPageException.filter.ts b/backend/src/oauth/filter/errorPageException.filter.ts new file mode 100644 index 000000000..bdbbcf53d --- /dev/null +++ b/backend/src/oauth/filter/errorPageException.filter.ts @@ -0,0 +1,22 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from "@nestjs/common"; +import { ConfigService } from "../../config/config.service"; +import { ErrorPageException } from "../exceptions/errorPage.exception"; + +@Catch(ErrorPageException) +export class ErrorPageExceptionFilter implements ExceptionFilter { + constructor(private config: ConfigService) {} + + catch(exception: ErrorPageException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + const url = new URL(`${this.config.get("general.appUrl")}/error`); + url.searchParams.set("redirect", exception.redirect); + url.searchParams.set("error", exception.key); + if (exception.params) { + url.searchParams.set("params", exception.params.join(",")); + } + + response.redirect(url.toString()); + } +} diff --git a/backend/src/oauth/filter/oauthException.filter.ts b/backend/src/oauth/filter/oauthException.filter.ts new file mode 100644 index 000000000..42f78d77a --- /dev/null +++ b/backend/src/oauth/filter/oauthException.filter.ts @@ -0,0 +1,31 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, +} from "@nestjs/common"; +import { ConfigService } from "../../config/config.service"; + +@Catch(HttpException) +export class OAuthExceptionFilter implements ExceptionFilter { + private errorKeys: Record = { + access_denied: "access_denied", + expired_token: "expired_token", + }; + + constructor(private config: ConfigService) {} + + catch(_exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const key = this.errorKeys[request.query.error] || "default"; + + const url = new URL(`${this.config.get("general.appUrl")}/error`); + url.searchParams.set("redirect", "/account"); + url.searchParams.set("error", key); + + response.redirect(url.toString()); + } +} diff --git a/backend/src/oauth/guard/oauth.guard.ts b/backend/src/oauth/guard/oauth.guard.ts new file mode 100644 index 000000000..32d444f6c --- /dev/null +++ b/backend/src/oauth/guard/oauth.guard.ts @@ -0,0 +1,12 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; + +@Injectable() +export class OAuthGuard implements CanActivate { + constructor() {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const provider = request.params.provider; + return request.query.state === request.cookies[`oauth_${provider}_state`]; + } +} diff --git a/backend/src/oauth/guard/provider.guard.ts b/backend/src/oauth/guard/provider.guard.ts new file mode 100644 index 000000000..a115c735c --- /dev/null +++ b/backend/src/oauth/guard/provider.guard.ts @@ -0,0 +1,24 @@ +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, +} from "@nestjs/common"; +import { ConfigService } from "../../config/config.service"; + +@Injectable() +export class ProviderGuard implements CanActivate { + constructor( + private config: ConfigService, + @Inject("OAUTH_PLATFORMS") private platforms: string[], + ) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const provider = request.params.provider; + return ( + this.platforms.includes(provider) && + this.config.get(`oauth.${provider}-enabled`) + ); + } +} diff --git a/backend/src/oauth/oauth.controller.ts b/backend/src/oauth/oauth.controller.ts new file mode 100644 index 000000000..63220d94e --- /dev/null +++ b/backend/src/oauth/oauth.controller.ts @@ -0,0 +1,110 @@ +import { + Controller, + Get, + Inject, + Param, + Post, + Query, + Req, + Res, + UseFilters, + UseGuards, +} from "@nestjs/common"; +import { User } from "@prisma/client"; +import { Request, Response } from "express"; +import { nanoid } from "nanoid"; +import { AuthService } from "../auth/auth.service"; +import { GetUser } from "../auth/decorator/getUser.decorator"; +import { JwtGuard } from "../auth/guard/jwt.guard"; +import { ConfigService } from "../config/config.service"; +import { OAuthCallbackDto } from "./dto/oauthCallback.dto"; +import { ErrorPageExceptionFilter } from "./filter/errorPageException.filter"; +import { OAuthGuard } from "./guard/oauth.guard"; +import { ProviderGuard } from "./guard/provider.guard"; +import { OAuthService } from "./oauth.service"; +import { OAuthProvider } from "./provider/oauthProvider.interface"; +import { OAuthExceptionFilter } from "./filter/oauthException.filter"; + +@Controller("oauth") +export class OAuthController { + constructor( + private authService: AuthService, + private oauthService: OAuthService, + private config: ConfigService, + @Inject("OAUTH_PROVIDERS") + private providers: Record>, + ) {} + + @Get("available") + available() { + return this.oauthService.available(); + } + + @Get("status") + @UseGuards(JwtGuard) + async status(@GetUser() user: User) { + return this.oauthService.status(user); + } + + @Get("auth/:provider") + @UseGuards(ProviderGuard) + @UseFilters(ErrorPageExceptionFilter) + async auth( + @Param("provider") provider: string, + @Res({ passthrough: true }) response: Response, + ) { + const state = nanoid(16); + const url = await this.providers[provider].getAuthEndpoint(state); + response.cookie(`oauth_${provider}_state`, state, { sameSite: "lax" }); + response.redirect(url); + } + + @Get("callback/:provider") + @UseGuards(ProviderGuard, OAuthGuard) + @UseFilters(ErrorPageExceptionFilter, OAuthExceptionFilter) + async callback( + @Param("provider") provider: string, + @Query() query: OAuthCallbackDto, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ) { + const oauthToken = await this.providers[provider].getToken(query); + const user = await this.providers[provider].getUserInfo(oauthToken, query); + const id = await this.authService.getIdOfCurrentUser(request); + + if (id) { + await this.oauthService.link( + id, + provider, + user.providerId, + user.providerUsername, + ); + response.redirect(this.config.get("general.appUrl") + "/account"); + } else { + const token: { + accessToken?: string; + refreshToken?: string; + loginToken?: string; + } = await this.oauthService.signIn(user); + if (token.accessToken) { + this.authService.addTokensToResponse( + response, + token.refreshToken, + token.accessToken, + ); + response.redirect(this.config.get("general.appUrl")); + } else { + response.redirect( + this.config.get("general.appUrl") + `/auth/totp/${token.loginToken}`, + ); + } + } + } + + @Post("unlink/:provider") + @UseGuards(JwtGuard, ProviderGuard) + @UseFilters(ErrorPageExceptionFilter) + unlink(@GetUser() user: User, @Param("provider") provider: string) { + return this.oauthService.unlink(user, provider); + } +} diff --git a/backend/src/oauth/oauth.module.ts b/backend/src/oauth/oauth.module.ts new file mode 100644 index 000000000..bdb22b1e2 --- /dev/null +++ b/backend/src/oauth/oauth.module.ts @@ -0,0 +1,56 @@ +import { Module } from "@nestjs/common"; +import { OAuthController } from "./oauth.controller"; +import { OAuthService } from "./oauth.service"; +import { AuthModule } from "../auth/auth.module"; +import { GitHubProvider } from "./provider/github.provider"; +import { GoogleProvider } from "./provider/google.provider"; +import { OAuthProvider } from "./provider/oauthProvider.interface"; +import { OidcProvider } from "./provider/oidc.provider"; +import { DiscordProvider } from "./provider/discord.provider"; +import { MicrosoftProvider } from "./provider/microsoft.provider"; + +@Module({ + controllers: [OAuthController], + providers: [ + OAuthService, + GitHubProvider, + GoogleProvider, + MicrosoftProvider, + DiscordProvider, + OidcProvider, + { + provide: "OAUTH_PROVIDERS", + useFactory( + github: GitHubProvider, + google: GoogleProvider, + microsoft: MicrosoftProvider, + discord: DiscordProvider, + oidc: OidcProvider, + ): Record> { + return { + github, + google, + microsoft, + discord, + oidc, + }; + }, + inject: [ + GitHubProvider, + GoogleProvider, + MicrosoftProvider, + DiscordProvider, + OidcProvider, + ], + }, + { + provide: "OAUTH_PLATFORMS", + useFactory(providers: Record>): string[] { + return Object.keys(providers); + }, + inject: ["OAUTH_PROVIDERS"], + }, + ], + imports: [AuthModule], +}) +export class OAuthModule {} diff --git a/backend/src/oauth/oauth.service.ts b/backend/src/oauth/oauth.service.ts new file mode 100644 index 000000000..0a374ec24 --- /dev/null +++ b/backend/src/oauth/oauth.service.ts @@ -0,0 +1,171 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { User } from "@prisma/client"; +import { nanoid } from "nanoid"; +import { AuthService } from "../auth/auth.service"; +import { ConfigService } from "../config/config.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { OAuthSignInDto } from "./dto/oauthSignIn.dto"; +import { ErrorPageException } from "./exceptions/errorPage.exception"; + +@Injectable() +export class OAuthService { + constructor( + private prisma: PrismaService, + private config: ConfigService, + private auth: AuthService, + @Inject("OAUTH_PLATFORMS") private platforms: string[], + ) {} + + available(): string[] { + return this.platforms + .map((platform) => [ + platform, + this.config.get(`oauth.${platform}-enabled`), + ]) + .filter(([_, enabled]) => enabled) + .map(([platform, _]) => platform); + } + + async status(user: User) { + const oauthUsers = await this.prisma.oAuthUser.findMany({ + select: { + provider: true, + providerUsername: true, + }, + where: { + userId: user.id, + }, + }); + return Object.fromEntries(oauthUsers.map((u) => [u.provider, u])); + } + + async signIn(user: OAuthSignInDto) { + const oauthUser = await this.prisma.oAuthUser.findFirst({ + where: { + provider: user.provider, + providerUserId: user.providerId, + }, + include: { + user: true, + }, + }); + if (oauthUser) { + return this.auth.generateToken(oauthUser.user, true); + } + + return this.signUp(user); + } + + async link( + userId: string, + provider: string, + providerUserId: string, + providerUsername: string, + ) { + const oauthUser = await this.prisma.oAuthUser.findFirst({ + where: { + provider, + providerUserId, + }, + }); + if (oauthUser) { + throw new ErrorPageException("already_linked", "/account", [ + `provider_${provider}`, + ]); + } + + await this.prisma.oAuthUser.create({ + data: { + userId, + provider, + providerUsername, + providerUserId, + }, + }); + } + + async unlink(user: User, provider: string) { + const oauthUser = await this.prisma.oAuthUser.findFirst({ + where: { + userId: user.id, + provider, + }, + }); + if (oauthUser) { + await this.prisma.oAuthUser.delete({ + where: { + id: oauthUser.id, + }, + }); + } else { + throw new ErrorPageException("not_linked", "/account", [provider]); + } + } + + private async getAvailableUsername(email: string) { + // only remove + and - from email for now (maybe not enough) + let username = email.split("@")[0].replace(/[+-]/g, "").substring(0, 20); + while (true) { + const user = await this.prisma.user.findFirst({ + where: { + username: username, + }, + }); + if (user) { + username = username + "_" + nanoid(10).replaceAll("-", ""); + } else { + return username; + } + } + } + + private async signUp(user: OAuthSignInDto) { + // register + if (!this.config.get("oauth.allowRegistration")) { + throw new ErrorPageException("no_user", "/auth/signIn", [ + `provider_${user.provider}`, + ]); + } + + if (!user.email) { + throw new ErrorPageException("no_email", "/auth/signIn", [ + `provider_${user.provider}`, + ]); + } + + const existingUser: User = await this.prisma.user.findFirst({ + where: { + email: user.email, + }, + }); + + if (existingUser) { + await this.prisma.oAuthUser.create({ + data: { + provider: user.provider, + providerUserId: user.providerId.toString(), + providerUsername: user.providerUsername, + userId: existingUser.id, + }, + }); + return this.auth.generateToken(existingUser, true); + } + + const result = await this.auth.signUp({ + email: user.email, + username: await this.getAvailableUsername(user.email), + password: null, + }); + + await this.prisma.oAuthUser.create({ + data: { + provider: user.provider, + providerUserId: user.providerId.toString(), + providerUsername: user.providerUsername, + userId: result.user.id, + }, + }); + + return result; + } +} diff --git a/backend/src/oauth/provider/discord.provider.ts b/backend/src/oauth/provider/discord.provider.ts new file mode 100644 index 000000000..b14e5d941 --- /dev/null +++ b/backend/src/oauth/provider/discord.provider.ts @@ -0,0 +1,98 @@ +import { OAuthProvider, OAuthToken } from "./oauthProvider.interface"; +import { OAuthCallbackDto } from "../dto/oauthCallback.dto"; +import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; +import { ConfigService } from "../../config/config.service"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import fetch from "node-fetch"; + +@Injectable() +export class DiscordProvider implements OAuthProvider { + constructor(private config: ConfigService) {} + + getAuthEndpoint(state: string): Promise { + return Promise.resolve( + "https://discord.com/api/oauth2/authorize?" + + new URLSearchParams({ + client_id: this.config.get("oauth.discord-clientId"), + redirect_uri: + this.config.get("general.appUrl") + "/api/oauth/callback/discord", + response_type: "code", + state: state, + scope: "identify email", + }).toString(), + ); + } + + private getAuthorizationHeader() { + return ( + "Basic " + + Buffer.from( + this.config.get("oauth.discord-clientId") + + ":" + + this.config.get("oauth.discord-clientSecret"), + ).toString("base64") + ); + } + + async getToken(query: OAuthCallbackDto): Promise> { + const res = await fetch("https://discord.com/api/v10/oauth2/token", { + method: "post", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: this.getAuthorizationHeader(), + }, + body: new URLSearchParams({ + code: query.code, + grant_type: "authorization_code", + redirect_uri: + this.config.get("general.appUrl") + "/api/oauth/callback/discord", + }), + }); + const token: DiscordToken = await res.json(); + return { + accessToken: token.access_token, + refreshToken: token.refresh_token, + expiresIn: token.expires_in, + scope: token.scope, + tokenType: token.token_type, + rawToken: token, + }; + } + + async getUserInfo(token: OAuthToken): Promise { + const res = await fetch("https://discord.com/api/v10/user/@me", { + method: "post", + headers: { + Accept: "application/json", + Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`, + }, + }); + const user = (await res.json()) as DiscordUser; + if (user.verified === false) { + throw new BadRequestException("Unverified account."); + } + + return { + provider: "discord", + providerId: user.id, + providerUsername: user.global_name ?? user.username, + email: user.email, + }; + } +} + +export interface DiscordToken { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; +} + +export interface DiscordUser { + id: string; + username: string; + global_name: string; + email: string; + verified: boolean; +} diff --git a/backend/src/oauth/provider/genericOidc.provider.ts b/backend/src/oauth/provider/genericOidc.provider.ts new file mode 100644 index 000000000..72a1f4b1f --- /dev/null +++ b/backend/src/oauth/provider/genericOidc.provider.ts @@ -0,0 +1,206 @@ +import { BadRequestException } from "@nestjs/common"; +import fetch from "node-fetch"; +import { ConfigService } from "../../config/config.service"; +import { JwtService } from "@nestjs/jwt"; +import { Cache } from "cache-manager"; +import { nanoid } from "nanoid"; +import { OAuthCallbackDto } from "../dto/oauthCallback.dto"; +import { OAuthProvider, OAuthToken } from "./oauthProvider.interface"; +import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; + +export abstract class GenericOidcProvider implements OAuthProvider { + protected redirectUri: string; + protected discoveryUri: string; + private configuration: OidcConfigurationCache; + private jwk: OidcJwkCache; + + protected constructor( + protected name: string, + protected keyOfConfigUpdateEvents: string[], + protected config: ConfigService, + protected jwtService: JwtService, + protected cache: Cache, + ) { + this.discoveryUri = this.getDiscoveryUri(); + this.redirectUri = `${this.config.get( + "general.appUrl", + )}/api/oauth/callback/${this.name}`; + this.config.addListener("update", (key: string, _: unknown) => { + if (this.keyOfConfigUpdateEvents.includes(key)) { + this.deinit(); + this.discoveryUri = this.getDiscoveryUri(); + } + }); + } + + async getConfiguration(): Promise { + if (!this.configuration || this.configuration.expires < Date.now()) { + await this.fetchConfiguration(); + } + return this.configuration.data; + } + + async getJwk(): Promise { + if (!this.jwk || this.jwk.expires < Date.now()) { + await this.fetchJwk(); + } + return this.jwk.data; + } + + async getAuthEndpoint(state: string) { + const configuration = await this.getConfiguration(); + const endpoint = configuration.authorization_endpoint; + + const nonce = nanoid(); + await this.cache.set( + `oauth-${this.name}-nonce-${state}`, + nonce, + 1000 * 60 * 5, + ); + + return ( + endpoint + + "?" + + new URLSearchParams({ + client_id: this.config.get(`oauth.${this.name}-clientId`), + response_type: "code", + scope: "openid profile email", + redirect_uri: this.redirectUri, + state, + nonce, + }).toString() + ); + } + + async getToken(query: OAuthCallbackDto): Promise> { + const configuration = await this.getConfiguration(); + const endpoint = configuration.token_endpoint; + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: this.config.get(`oauth.${this.name}-clientId`), + client_secret: this.config.get(`oauth.${this.name}-clientSecret`), + grant_type: "authorization_code", + code: query.code, + redirect_uri: this.redirectUri, + }).toString(), + }); + const token: OidcToken = await res.json(); + return { + accessToken: token.access_token, + expiresIn: token.expires_in, + idToken: token.id_token, + refreshToken: token.refresh_token, + tokenType: token.token_type, + rawToken: token, + }; + } + + async getUserInfo( + token: OAuthToken, + query: OAuthCallbackDto, + ): Promise { + const idTokenData = this.decodeIdToken(token.idToken); + // maybe it's not necessary to verify the id token since it's directly obtained from the provider + + const key = `oauth-${this.name}-nonce-${query.state}`; + const nonce = await this.cache.get(key); + await this.cache.del(key); + if (nonce !== idTokenData.nonce) { + throw new BadRequestException("Invalid token"); + } + + return { + provider: this.name as any, + email: idTokenData.email, + providerId: idTokenData.sub, + providerUsername: idTokenData.name, + }; + } + + protected abstract getDiscoveryUri(): string; + + private async fetchConfiguration(): Promise { + const res = await fetch(this.discoveryUri); + const expires = res.headers.has("expires") + ? new Date(res.headers.get("expires")).getTime() + : Date.now() + 1000 * 60 * 60 * 24; + this.configuration = { + expires, + data: await res.json(), + }; + } + + private async fetchJwk(): Promise { + const configuration = await this.getConfiguration(); + const res = await fetch(configuration.jwks_uri); + const expires = res.headers.has("expires") + ? new Date(res.headers.get("expires")).getTime() + : Date.now() + 1000 * 60 * 60 * 24; + this.jwk = { + expires, + data: (await res.json())["keys"], + }; + } + + private deinit() { + this.discoveryUri = undefined; + this.configuration = undefined; + this.jwk = undefined; + } + + private decodeIdToken(idToken: string): OidcIdToken { + return this.jwtService.decode(idToken) as OidcIdToken; + } +} + +export interface OidcCache { + expires: number; + data: T; +} + +export interface OidcConfiguration { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; + jwks_uri: string; + response_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + scopes_supported?: string[]; + claims_supported?: string[]; +} + +export interface OidcJwk { + e: string; + alg: string; + kid: string; + use: string; + kty: string; + n: string; +} + +export type OidcConfigurationCache = OidcCache; + +export type OidcJwkCache = OidcCache; + +export interface OidcToken { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + id_token: string; +} + +export interface OidcIdToken { + iss: string; + sub: string; + exp: number; + iat: number; + email: string; + name: string; + nonce: string; +} diff --git a/backend/src/oauth/provider/github.provider.ts b/backend/src/oauth/provider/github.provider.ts new file mode 100644 index 000000000..bf1ae6228 --- /dev/null +++ b/backend/src/oauth/provider/github.provider.ts @@ -0,0 +1,110 @@ +import { OAuthProvider, OAuthToken } from "./oauthProvider.interface"; +import { OAuthCallbackDto } from "../dto/oauthCallback.dto"; +import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; +import { ConfigService } from "../../config/config.service"; +import fetch from "node-fetch"; +import { BadRequestException, Injectable } from "@nestjs/common"; + +@Injectable() +export class GitHubProvider implements OAuthProvider { + constructor(private config: ConfigService) {} + + getAuthEndpoint(state: string): Promise { + return Promise.resolve( + "https://github.com/login/oauth/authorize?" + + new URLSearchParams({ + client_id: this.config.get("oauth.github-clientId"), + redirect_uri: + this.config.get("general.appUrl") + "/api/oauth/callback/github", + state: state, + scope: "user:email", + }).toString(), + ); + } + + async getToken(query: OAuthCallbackDto): Promise> { + const res = await fetch( + "https://github.com/login/oauth/access_token?" + + new URLSearchParams({ + client_id: this.config.get("oauth.github-clientId"), + client_secret: this.config.get("oauth.github-clientSecret"), + code: query.code, + }).toString(), + { + method: "post", + headers: { + Accept: "application/json", + }, + }, + ); + const token: GitHubToken = await res.json(); + return { + accessToken: token.access_token, + tokenType: token.token_type, + rawToken: token, + }; + } + + async getUserInfo(token: OAuthToken): Promise { + const user = await this.getGitHubUser(token); + if (!token.scope.includes("user:email")) { + throw new BadRequestException("No email permission granted"); + } + const email = await this.getGitHubEmail(token); + if (!email) { + throw new BadRequestException("No email found"); + } + + return { + provider: "github", + providerId: user.id.toString(), + providerUsername: user.name ?? user.login, + email, + }; + } + + private async getGitHubUser( + token: OAuthToken, + ): Promise { + const res = await fetch("https://api.github.com/user", { + headers: { + Accept: "application/vnd.github+json", + Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`, + }, + }); + return (await res.json()) as GitHubUser; + } + + private async getGitHubEmail( + token: OAuthToken, + ): Promise { + const res = await fetch("https://api.github.com/user/public_emails", { + headers: { + Accept: "application/vnd.github+json", + Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`, + }, + }); + const emails = (await res.json()) as GitHubEmail[]; + return emails.find((e) => e.primary && e.verified)?.email; + } +} + +export interface GitHubToken { + access_token: string; + token_type: string; + scope: string; +} + +export interface GitHubUser { + login: string; + id: number; + name?: string; + email?: string; // this filed seems only return null +} + +export interface GitHubEmail { + email: string; + primary: boolean; + verified: boolean; + visibility: string | null; +} diff --git a/backend/src/oauth/provider/google.provider.ts b/backend/src/oauth/provider/google.provider.ts new file mode 100644 index 000000000..5c24dff3d --- /dev/null +++ b/backend/src/oauth/provider/google.provider.ts @@ -0,0 +1,21 @@ +import { GenericOidcProvider } from "./genericOidc.provider"; +import { ConfigService } from "../../config/config.service"; +import { JwtService } from "@nestjs/jwt"; +import { Inject, Injectable } from "@nestjs/common"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Cache } from "cache-manager"; + +@Injectable() +export class GoogleProvider extends GenericOidcProvider { + constructor( + config: ConfigService, + jwtService: JwtService, + @Inject(CACHE_MANAGER) cache: Cache, + ) { + super("google", ["oauth.google-enabled"], config, jwtService, cache); + } + + protected getDiscoveryUri(): string { + return "https://accounts.google.com/.well-known/openid-configuration"; + } +} diff --git a/backend/src/oauth/provider/microsoft.provider.ts b/backend/src/oauth/provider/microsoft.provider.ts new file mode 100644 index 000000000..42262b322 --- /dev/null +++ b/backend/src/oauth/provider/microsoft.provider.ts @@ -0,0 +1,29 @@ +import { GenericOidcProvider } from "./genericOidc.provider"; +import { ConfigService } from "../../config/config.service"; +import { JwtService } from "@nestjs/jwt"; +import { Inject, Injectable } from "@nestjs/common"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Cache } from "cache-manager"; + +@Injectable() +export class MicrosoftProvider extends GenericOidcProvider { + constructor( + config: ConfigService, + jwtService: JwtService, + @Inject(CACHE_MANAGER) cache: Cache, + ) { + super( + "microsoft", + ["oauth.microsoft-enabled", "oauth.microsoft-tenant"], + config, + jwtService, + cache, + ); + } + + protected getDiscoveryUri(): string { + return `https://login.microsoftonline.com/${this.config.get( + "oauth.microsoft-tenant", + )}/v2.0/.well-known/openid-configuration`; + } +} diff --git a/backend/src/oauth/provider/oauthProvider.interface.ts b/backend/src/oauth/provider/oauthProvider.interface.ts new file mode 100644 index 000000000..4ede09951 --- /dev/null +++ b/backend/src/oauth/provider/oauthProvider.interface.ts @@ -0,0 +1,24 @@ +import { OAuthCallbackDto } from "../dto/oauthCallback.dto"; +import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; + +/** + * @typeParam T - type of token + * @typeParam C - type of callback query + */ +export interface OAuthProvider { + getAuthEndpoint(state: string): Promise; + + getToken(query: C): Promise>; + + getUserInfo(token: OAuthToken, query: C): Promise; +} + +export interface OAuthToken { + accessToken: string; + expiresIn?: number; + refreshToken?: string; + tokenType?: string; + scope?: string; + idToken?: string; + rawToken: T; +} diff --git a/backend/src/oauth/provider/oidc.provider.ts b/backend/src/oauth/provider/oidc.provider.ts new file mode 100644 index 000000000..8a1233815 --- /dev/null +++ b/backend/src/oauth/provider/oidc.provider.ts @@ -0,0 +1,27 @@ +import { GenericOidcProvider } from "./genericOidc.provider"; +import { Inject, Injectable } from "@nestjs/common"; +import { ConfigService } from "../../config/config.service"; +import { JwtService } from "@nestjs/jwt"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Cache } from "cache-manager"; + +@Injectable() +export class OidcProvider extends GenericOidcProvider { + constructor( + config: ConfigService, + jwtService: JwtService, + @Inject(CACHE_MANAGER) protected cache: Cache, + ) { + super( + "oidc", + ["oauth.oidc-enabled", "oauth.oidc-discoveryUri"], + config, + jwtService, + cache, + ); + } + + protected getDiscoveryUri(): string { + return this.config.get("oauth.oidc-discoveryUri"); + } +} diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts index 5207f5524..b11d5d7f5 100644 --- a/backend/src/user/dto/user.dto.ts +++ b/backend/src/user/dto/user.dto.ts @@ -16,6 +16,9 @@ export class UserDTO { @IsEmail() email: string; + @Expose() + hasPassword: boolean; + @MinLength(8) password: string; diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 1256120fc..b55ea6650 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -28,7 +28,9 @@ export class UserController { @Get("me") @UseGuards(JwtGuard) async getCurrentUser(@GetUser() user: User) { - return new UserDTO().from(user); + const userDTO = new UserDTO().from(user); + userDTO.hasPassword = !!user.password; + return userDTO; } @Patch("me") diff --git a/backend/tsconfig.json b/backend/tsconfig.json index adb614cab..46ac521d7 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -6,7 +6,10 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "es2017", + "target": "es2021", + "lib": [ + "ES2021" + ], "sourceMap": true, "outDir": "./dist", "baseUrl": "./", diff --git a/docs/README.es.md b/docs/README.es.md index abb97ce11..8b32e55d6 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -2,7 +2,7 @@ --- -_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md)_ +_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_ --- diff --git a/docs/README.ja-jp.md b/docs/README.ja-jp.md new file mode 100644 index 000000000..e50671b19 --- /dev/null +++ b/docs/README.ja-jp.md @@ -0,0 +1,158 @@ +#

Pingvin Share
+ +--- + +_READMEを別の言語で読む: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_ + +--- + +Pingvin Share は、セルフホスト型のファイル共有プラットフォームであり、WeTransfer、ギガファイル便などの代替プラットフォームです。 + +## ✨ 特徴的な機能 + +- リンクを用いたファイル共有 +- ファイルサイズ無制限 (ストレージスペースの範囲内で) +- 共有への有効期限の設定 +- 訪問回数の制限とパスワードの設定により共有を安全に保つ +- メールでリンクを共有 +- ClamAVと連携して、ウイルスチェックが可能 + +## 🐧 Pingvin Shareについて知る + +- [デモ](https://pingvin-share.dev.eliasschneider.com) +- [DB Techによるレビュー](https://www.youtube.com/watch?v=rWwNeZCOPJA) + + + +## ⌨️ セットアップ + +> 注意: Pingvin Shareは、早期段階であり、バグが含まれている場合があります。 + +### Dockerでインストール (おすすめ) + +1. `docker-compose.yml`ファイルをダウンロード +2. `docker-compose up -d`を実行 + +Webサイトは、`http://localhost:3000`でリッスンされます。これでPingvin Shareをお使い頂けます🐧! + +### スタンドアローンインストール + +必要なツール: + +- [Node.js](https://nodejs.org/en/download/) >= 16 +- [Git](https://git-scm.com/downloads) +- [pm2](https://pm2.keymetrics.io/) Pingvin Shareをバックグラウンドで動作させるために必要 + +```bash +git clone https://github.com/stonith404/pingvin-share +cd pingvin-share + +# 最新バージョンをチェックアウト +git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`) + +# バックエンドを開始 +cd backend +npm install +npm run build +pm2 start --name="pingvin-share-backend" npm -- run prod + +#フロントエンドを開始 +cd ../frontend +npm install +npm run build +pm2 start --name="pingvin-share-frontend" npm -- run start +``` + +Webサイトは、`http://localhost:3000`でリッスンされます。これでPingvin Shareをお使い頂けます🐧! + +### 連携機能 + +#### ClamAV (Dockerのみ) + +ClamAVは、共有されたファイルをスキャンし、感染したファイルを見つけた場合に削除するために使用されます。 + +1. ClamAVコンテナをDocker Composeの定義ファイル(`docker-compose.yml`を確認)に追加し、コンテナを開始してください。 +2. Dockerは、Pingvin Shareを開始する前に、ClamAVの準備が整うまで待機します。これには、1分から2分ほどかかります。 +3. Pingvin Shareのログに"ClamAV is active"というログが記録されます。 + +ClamAVは、非常に多くのリソースを必要とします、詳しくは[リソース](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements)をご確認ください。 + +### 追加情報 + +- [Synology NASへのインストール方法](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/) + +### 新しいバージョンへのアップグレード + +Pingvin Shareは早期段階のため、アップグレード前に必ずリリースノートを確認して、アップグレードしても問題ないかどうかご確認ください。 + +#### Docker + +```bash +docker compose pull +docker compose up -d +``` + +#### スタンドアローン + +1. アプリを停止する + ```bash + pm2 stop pingvin-share-backend pingvin-share-frontend + ``` +2. `git clone`のステップを除いて、[インストールガイド](#stand-alone-installation)をくり返してください。 + + ```bash + cd pingvin-share + + # 最新バージョンをチェックアウト + git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`) + + # バックエンドを開始 + cd backend + npm run build + pm2 restart pingvin-share-backend + + #フロントエンドを開始 + cd ../frontend + npm run build + pm2 restart pingvin-share-frontend + ``` + +### 設定 + +管理者のダッシュボード内の「設定」ページから、Pingvin Shareをカスタマイズできます。 + +#### 環境変数 + +インストール時の特定の設定で、環境変数を使用できます。次の環境変数が使用可能です: + +##### バックエンド + +| 変数名 | デフォルト値 | 説明 | +| ---------------- | -------------------------------------------------- | -------------------------------------- | +| `PORT` | `8080` | バックエンドがリッスンするポート番号 | +| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | SQLiteのURL | +| `DATA_DIRECTORY` | `./data` | データを保管するディレクトリ | +| `CLAMAV_HOST` | `127.0.0.1` | ClamAVサーバーのIPアドレス | +| `CLAMAV_PORT` | `3310` | ClamAVサーバーのポート番号 | + +##### フロントエンド + +| 変数名 | デフォルト値 | 説明 | +| --------- | ----------------------- | ---------------------------------------- | +| `PORT` | `3000` | フロントエンドがリッスンするポート番号 | +| `API_URL` | `http://localhost:8080` | フロントエンドからアクセスするバックエンドへのURL | + +## 🖤 コントリビュート + +### 翻訳 + +Pingvin Shareをあなたが使用している言語に翻訳するお手伝いを募集しています。 +[Crowdin](https://crowdin.com/project/pingvin-share)上で、簡単にPingvin Shareの翻訳作業への参加が可能です。 + +あなたの言語がありませんか? 気軽に[リクエスト](https://github.com/stonith404/pingvin-share/issues/new?assignees=&labels=language-request&projects=&template=language-request.yml&title=%F0%9F%8C%90+Language+request%3A+%3Clanguage+name+in+english%3E)してください。 + +翻訳中に問題がありましたか? [ローカライズに関するディスカッション](https://github.com/stonith404/pingvin-share/discussions/198)に是非参加してください。 + +### プロジェクト + +Pingvin Shareへのコントリビュートをいつでもお待ちしています! [コントリビューションガイド](/CONTRIBUTING.md)を確認して、是非参加してください。 diff --git a/docs/README.zh-cn.md b/docs/README.zh-cn.md index a0401bb2d..986524529 100644 --- a/docs/README.zh-cn.md +++ b/docs/README.zh-cn.md @@ -2,7 +2,7 @@ --- -_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md)_ +_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md), [日本语](/docs/README.ja-jp.md)_ --- diff --git a/docs/oauth2-guide.md b/docs/oauth2-guide.md new file mode 100644 index 000000000..bcc99ea8b --- /dev/null +++ b/docs/oauth2-guide.md @@ -0,0 +1,168 @@ +# OAuth 2 Login Guide + +## Config Built-in OAuth 2 Providers + +- [GitHub](#github) +- [Google](#google) +- [Microsoft](#microsoft) +- [Discord](#discord) +- [OpenID Connect](#openid-connect) + +### GitHub + +Please follow the [official guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) +to create an OAuth app. + +Redirect URL: `https:///api/oauth/callback/github` + +### Google + +Please follow the [official guide](https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites) to +create an OAuth 2.0 App. + +Redirect URL: `https:///api/oauth/callback/google` + +### Microsoft + +Please follow +the [official guide](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) +to register an application. + +Redirect URL: `https:///api/oauth/callback/microsoft` + +### Discord + +Create an application on [Discord Developer Portal](https://discord.com/developers/applications). + +Redirect URL: `https:///api/oauth/callback/discord` + +### OpenID Connect + +Generic OpenID Connect provider is also supported, we have tested it on Keycloak and Authentik. + +Redirect URL: `https:///api/oauth/callback/oidc` + +## Custom your OAuth 2 Provider + +If our built-in providers don't meet your needs, you can create your own OAuth 2 provider. + +### 1. Create config + +Add your config (client id, client secret, etc.) in [`config.seed.ts`](../backend/prisma/seed/config.seed.ts): + +```ts +const configVariables: ConfigVariables = { + // ... + oauth: { + // ... + "YOUR_PROVIDER_NAME-enabled": { + type: "boolean", + defaultValue: "false", + }, + "YOUR_PROVIDER_NAME-clientId": { + type: "string", + defaultValue: "", + }, + "YOUR_PROVIDER_NAME-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + } +} +``` + +### 2. Create provider class + +#### OpenID Connect + +If your provider supports OpenID connect, it's extremely easy to +extend [`GenericOidcProvider`](../backend/src/oauth/provider/genericOidc.provider.ts) to add a new OpenID Connect +provider. + +The [Google provider](../backend/src/oauth/provider/google.provider.ts) +and [Microsoft provider](../backend/src/oauth/provider/microsoft.provider.ts) are good examples. + +Here are some discovery URIs for popular providers: + +- Microsoft: `https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration` +- Google: `https://accounts.google.com/.well-known/openid-configuration` +- Apple: `https://appleid.apple.com/.well-known/openid-configuration` +- Gitlab: `https://gitlab.com/.well-known/openid-configuration` +- Huawei: `https://oauth-login.cloud.huawei.com/.well-known/openid-configuration` +- Paypal: `https://www.paypal.com/.well-known/openid-configuration` +- Yahoo: `https://api.login.yahoo.com/.well-known/openid-configuration` + +#### OAuth 2 + +If your provider only supports OAuth 2, you can +implement [`OAuthProvider`](../backend/src/oauth/provider/oauthProvider.interface.ts) interface to add a new OAuth 2 +provider. + +The [GitHub provider](../backend/src/oauth/provider/github.provider.ts) +and [Discord provider](../backend/src/oauth/provider/discord.provider.ts) are good examples. + +### 3. Register provider + +Register your provider in [`OAuthModule`](../backend/src/oauth/oauth.module.ts) +and [`OAuthSignInDto`](../backend/src/oauth/dto/oauthSignIn.dto.ts): + +```ts +@Module({ + providers: [ + GitHubProvider, + // your provider + { + provide: "OAUTH_PROVIDERS", + useFactory(github: GitHubProvider, /* your provider */): Record> { + return { + github, + google, + oidc, + }; + }, + inject: [GitHubProvider, /* your provider */], + }, + ], +}) +export class OAuthModule { +} +``` + +```ts +export interface OAuthSignInDto { + provider: 'github' | 'google' | 'microsoft' | 'discord' | 'oidc' /* your provider*/ + ; + providerId: string; + providerUsername: string; + email: string; +} +``` + +### 4. Add frontend icon + +Add an icon in [`oauth.util.tsx`](../frontend/src/utils/oauth.util.tsx). + +```tsx +const getOAuthIcon = (provider: string) => { + return { + 'github': , + /* your provider */ + }[provider]; +} +``` + +### 5. Add i18n text + +Add keys below to your i18n text in [locale file](../frontend/src/i18n/translations/en-US.ts). + +- `signIn.oauth.YOUR_PROVIDER_NAME` +- `account.card.oauth.YOUR_PROVIDER_NAME` +- `admin.config.oauth.YOUR_PROVIDER_NAME-enabled` +- `admin.config.oauth.YOUR_PROVIDER_NAME-client-id` +- `admin.config.oauth.YOUR_PROVIDER_NAME-client-secret` +- Other config keys you defined in step 1 + +Congratulations! 🎉 You have successfully added a new OAuth 2 provider! Pull requests are welcome if you want to share +your provider with others. + diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index bfea7a17e..00b0a541a 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -11,6 +11,7 @@ "react-hooks/exhaustive-deps": ["off"], "import/no-anonymous-default-export": ["off"], "no-unused-vars": ["warn"], - "react/no-unescaped-entities": ["off"] + "react/no-unescaped-entities": ["off"], + "@next/next/no-img-element": ["off"] } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fecaf9e22..a9373d579 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pingvin-share-frontend", - "version": "0.18.1", + "version": "0.18.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "pingvin-share-frontend", - "version": "0.18.1", + "version": "0.18.2", "dependencies": { "@emotion/react": "^11.11.1", "@emotion/server": "^11.11.0", diff --git a/frontend/package.json b/frontend/package.json index 491ee3763..c1428eb76 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "pingvin-share-frontend", - "version": "0.18.1", + "version": "0.18.2", "scripts": { "dev": "next dev", "build": "next build", diff --git a/frontend/public/img/logo.png b/frontend/public/img/logo.png index 340efd290..bb7ea122e 100644 Binary files a/frontend/public/img/logo.png and b/frontend/public/img/logo.png differ diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx index c69c50c04..44fe2e44e 100644 --- a/frontend/src/components/Logo.tsx +++ b/frontend/src/components/Logo.tsx @@ -1,6 +1,4 @@ -import Image from "next/image"; - const Logo = ({ height, width }: { height: number; width: number }) => { - return logo; + return logo; }; export default Logo; diff --git a/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx b/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx index c749581c2..9df1a19c2 100644 --- a/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx +++ b/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import Link from "next/link"; import { Dispatch, SetStateAction } from "react"; -import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb"; +import { TbAt, TbMail, TbShare, TbSocial, TbSquare } from "react-icons/tb"; import { FormattedMessage } from "react-intl"; const categories = [ @@ -19,6 +19,7 @@ const categories = [ { name: "Email", icon: }, { name: "Share", icon: }, { name: "SMTP", icon: }, + { name: "OAuth", icon: }, ]; const useStyles = createStyles((theme) => ({ diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index 756a99dc3..e5bd96b6c 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -2,9 +2,11 @@ import { Anchor, Button, Container, + createStyles, Group, Paper, PasswordInput, + Stack, Text, TextInput, Title, @@ -18,19 +20,47 @@ import { TbInfoCircle } from "react-icons/tb"; import { FormattedMessage } from "react-intl"; import * as yup from "yup"; import useConfig from "../../hooks/config.hook"; -import useTranslate from "../../hooks/useTranslate.hook"; import useUser from "../../hooks/user.hook"; +import useTranslate from "../../hooks/useTranslate.hook"; import authService from "../../services/auth.service"; +import { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util"; import toast from "../../utils/toast.util"; +const useStyles = createStyles((theme) => ({ + or: { + "&:before": { + content: "''", + flex: 1, + display: "block", + borderTopWidth: 1, + borderTopStyle: "solid", + borderColor: + theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4], + }, + "&:after": { + content: "''", + flex: 1, + display: "block", + borderTopWidth: 1, + borderTopStyle: "solid", + borderColor: + theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4], + }, + }, +})); + const SignInForm = ({ redirectPath }: { redirectPath: string }) => { const config = useConfig(); const router = useRouter(); const t = useTranslate(); const { refreshUser } = useUser(); + const { classes } = useStyles(); - const [showTotp, setShowTotp] = React.useState(false); - const [loginToken, setLoginToken] = React.useState(""); + const [oauth, setOAuth] = React.useState([]); const validationSchema = yup.object().shape({ emailOrUsername: yup.string().required(t("common.error.field-required")), @@ -44,7 +74,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { initialValues: { emailOrUsername: "", password: "", - totp: "", }, validate: yupResolver(validationSchema), }); @@ -55,7 +84,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { .then(async (response) => { if (response.data["loginToken"]) { // Prompt the user to enter their totp code - setShowTotp(true); showNotification({ icon: , color: "blue", @@ -63,7 +91,11 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { title: t("signIn.notify.totp-required.title"), message: t("signIn.notify.totp-required.description"), }); - setLoginToken(response.data["loginToken"]); + router.push( + `/auth/totp/${ + response.data["loginToken"] + }?redirect=${encodeURIComponent(redirectPath)}`, + ); } else { await refreshUser(); router.replace(redirectPath); @@ -72,25 +104,15 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { .catch(toast.axiosError); }; - const signInTotp = (email: string, password: string, totp: string) => { - authService - .signInTotp(email, password, totp, loginToken) - .then(async () => { - await refreshUser(); - router.replace(redirectPath); - }) - .catch((error) => { - if (error?.response?.data?.error == "share_password_required") { - toast.axiosError(error); - // Refresh the page to start over - window.location.reload(); - } - - toast.axiosError(error); - form.setValues({ totp: "" }); - }); + const getAvailableOAuth = async () => { + const oauth = await authService.getAvailableOAuth(); + setOAuth(oauth.data); }; + React.useEffect(() => { + getAvailableOAuth().catch(toast.axiosError); + }, []); + return ( @@ -107,9 +129,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { <Paper withBorder shadow="md" p={30} mt={30} radius="md"> <form onSubmit={form.onSubmit((values) => { - if (showTotp) - signInTotp(values.emailOrUsername, values.password, values.totp); - else signIn(values.emailOrUsername, values.password); + signIn(values.emailOrUsername, values.password); })} > <TextInput @@ -123,15 +143,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { mt="md" {...form.getInputProps("password")} /> - {showTotp && ( - <TextInput - variant="filled" - label={t("account.modal.totp.code")} - placeholder="******" - mt="md" - {...form.getInputProps("totp")} - /> - )} {config.get("smtp.enabled") && ( <Group position="right" mt="xs"> <Anchor component={Link} href="/auth/resetPassword" size="xs"> @@ -143,6 +154,27 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { <FormattedMessage id="signin.button.submit" /> </Button> </form> + {oauth.length > 0 && ( + <Stack mt="xl"> + <Group align="center" className={classes.or}> + <Text>{t("signIn.oauth.or")}</Text> + </Group> + <Group position="center"> + {oauth.map((provider) => ( + <Button + key={provider} + component="a" + target="_blank" + title={t(`signIn.oauth.${provider}`)} + href={getOAuthUrl(config.get("general.appUrl"), provider)} + variant="light" + > + {getOAuthIcon(provider)} + </Button> + ))} + </Group> + </Stack> + )} </Paper> </Container> ); diff --git a/frontend/src/components/auth/TotpForm.tsx b/frontend/src/components/auth/TotpForm.tsx new file mode 100644 index 000000000..7fdf2edf6 --- /dev/null +++ b/frontend/src/components/auth/TotpForm.tsx @@ -0,0 +1,84 @@ +import { + Button, + Container, + Group, + Paper, + PinInput, + Title, +} from "@mantine/core"; +import { FormattedMessage } from "react-intl"; +import * as yup from "yup"; +import useTranslate from "../../hooks/useTranslate.hook"; +import { useForm, yupResolver } from "@mantine/form"; +import { useState } from "react"; +import authService from "../../services/auth.service"; +import toast from "../../utils/toast.util"; +import { useRouter } from "next/router"; +import useUser from "../../hooks/user.hook"; + +function TotpForm({ redirectPath }: { redirectPath: string }) { + const t = useTranslate(); + const router = useRouter(); + const { refreshUser } = useUser(); + + const [loading, setLoading] = useState(false); + + const validationSchema = yup.object().shape({ + code: yup + .string() + .min(6, t("common.error.too-short", { length: 6 })) + .required(t("common.error.field-required")), + }); + + const form = useForm({ + initialValues: { + code: "", + }, + validate: yupResolver(validationSchema), + }); + + const onSubmit = async () => { + if (loading) return; + setLoading(true); + try { + await authService.signInTotp( + form.values.code, + router.query.loginToken as string, + ); + await refreshUser(); + await router.replace(redirectPath); + } catch (e) { + toast.axiosError(e); + form.setFieldError("code", "error"); + } finally { + setLoading(false); + } + }; + + return ( + <Container size={420} my={40}> + <Title order={2} align="center" weight={900}> + <FormattedMessage id="totp.title" /> + + +
+ + + + +
+
+
+ ); +} + +export default TotpForm; diff --git a/frontend/src/components/upload/Dropzone.tsx b/frontend/src/components/upload/Dropzone.tsx index f2e4bce1e..1c834fd20 100644 --- a/frontend/src/components/upload/Dropzone.tsx +++ b/frontend/src/components/upload/Dropzone.tsx @@ -60,7 +60,7 @@ const Dropzone = ({ toast.error( t("upload.dropzone.notify.file-too-big", { maxSize: byteToHumanSizeString(maxShareSize), - }) + }), ); } else { files = files.map((newFile) => { diff --git a/frontend/src/components/upload/modals/showCreateUploadModal.tsx b/frontend/src/components/upload/modals/showCreateUploadModal.tsx index 65cda5080..1ffa1cc1e 100644 --- a/frontend/src/components/upload/modals/showCreateUploadModal.tsx +++ b/frontend/src/components/upload/modals/showCreateUploadModal.tsx @@ -40,7 +40,7 @@ const showCreateUploadModal = ( enableEmailRecepients: boolean; }, files: FileUpload[], - uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void + uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void, ) => { const t = translateOutsideContext(); @@ -137,7 +137,7 @@ const CreateUploadModalBody = ({ maxViews: values.maxViews, }, }, - files + files, ); modals.closeAll(); } @@ -160,7 +160,7 @@ const CreateUploadModalBody = ({ "link", Buffer.from(Math.random().toString(), "utf8") .toString("base64") - .substr(10, 7) + .substr(10, 7), ) } > @@ -259,7 +259,7 @@ const CreateUploadModalBody = ({ neverExpires: t("upload.modal.completed.never-expires"), expiresOn: t("upload.modal.completed.expires-on"), }, - form + form, )} @@ -274,7 +274,7 @@ const CreateUploadModalBody = ({