Compare commits

...

13 Commits

Author SHA1 Message Date
Elias Schneider
4c6ef52a17 release: 0.10.0 2023-02-10 11:47:29 +01:00
Elias Schneider
b9662701c4 fix: share creation without reverseShareToken 2023-02-10 11:47:17 +01:00
Elias Schneider
e3f88d0826 refactor(jobs): clear expired tokens and reverse shares 2023-02-10 11:29:51 +01:00
Elias Schneider
86a7379519 fix: delete all shares of reverse share 2023-02-10 11:15:23 +01:00
Elias Schneider
ccdf8ea3ae feat: allow multiple shares with one reverse share link 2023-02-10 11:10:07 +01:00
Elias Schneider
edc10b72b7 fix: share fails if a share was created with a reverse share link recently 2023-02-10 10:58:49 +01:00
Elias Schneider
5d1a7f0310 feat!: reset password with email 2023-02-09 18:17:53 +01:00
Elias Schneider
8ab359b71d docs(backend): add swagger documentation 2023-02-07 11:23:43 +01:00
Elias Schneider
38de022215 feat(frontend): server side rendering to improve performance 2023-02-07 10:21:25 +01:00
Elias Schneider
82f204e8a9 fix: invalid redirection after jwt expiry 2023-02-06 11:15:46 +01:00
Elias Schneider
4e840ecd29 refactor: handle authentication state in middleware 2023-02-04 18:12:49 +01:00
Elias Schneider
064ef38d78 fix: setup status doesn't change 2023-02-03 11:01:10 +01:00
Elias Schneider
b14e931d8d test: adapt tests to new features 2023-01-31 15:43:54 +01:00
64 changed files with 1402 additions and 1124 deletions

View File

@@ -1,3 +1,25 @@
## [0.10.0](https://github.com/stonith404/pingvin-share/compare/v0.9.0...v0.10.0) (2023-02-10)
### ⚠ BREAKING CHANGES
* reset password with email
### Features
* allow multiple shares with one reverse share link ([ccdf8ea](https://github.com/stonith404/pingvin-share/commit/ccdf8ea3ae1e7b8520c5b1dd9bea18b1b3305f35))
* **frontend:** server side rendering to improve performance ([38de022](https://github.com/stonith404/pingvin-share/commit/38de022215a9b99c2eb36654f8dbb1e17ca87aba))
* reset password with email ([5d1a7f0](https://github.com/stonith404/pingvin-share/commit/5d1a7f0310df2643213affd2a0d1785b7e0af398))
### Bug Fixes
* delete all shares of reverse share ([86a7379](https://github.com/stonith404/pingvin-share/commit/86a737951951c911abd7967d76cb253c4335cb0c))
* invalid redirection after jwt expiry ([82f204e](https://github.com/stonith404/pingvin-share/commit/82f204e8a93e3113dcf65b1881d4943a898602eb))
* setup status doesn't change ([064ef38](https://github.com/stonith404/pingvin-share/commit/064ef38d783b3f351535c2911eb451efd9526d71))
* share creation without reverseShareToken ([b966270](https://github.com/stonith404/pingvin-share/commit/b9662701c42fe6771c07acb869564031accb2932))
* share fails if a share was created with a reverse share link recently ([edc10b7](https://github.com/stonith404/pingvin-share/commit/edc10b72b7884c629a8417c3c82222b135ef7653))
## [0.9.0](https://github.com/stonith404/pingvin-share/compare/v0.8.0...v0.9.0) (2023-01-31) ## [0.9.0](https://github.com/stonith404/pingvin-share/compare/v0.8.0...v0.9.0) (2023-01-31)

View File

@@ -1,5 +1,8 @@
{ {
"$schema": "https://json.schemastore.org/nest-cli", "$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src" "sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"]
}
} }

View File

@@ -1,21 +1,21 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.9.0", "version": "0.10.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.9.0", "version": "0.10.0",
"dependencies": { "dependencies": {
"@nestjs/common": "^9.2.1", "@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1", "@nestjs/core": "^9.2.1",
"@nestjs/jwt": "^10.0.1", "@nestjs/jwt": "^10.0.1",
"@nestjs/mapped-types": "^1.2.0",
"@nestjs/passport": "^9.0.0", "@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.2.1", "@nestjs/platform-express": "^9.2.1",
"@nestjs/schedule": "^2.1.0", "@nestjs/schedule": "^2.1.0",
"@nestjs/swagger": "^6.2.1",
"@nestjs/throttler": "^3.1.0", "@nestjs/throttler": "^3.1.0",
"@prisma/client": "^4.8.1", "@prisma/client": "^4.8.1",
"archiver": "^5.3.1", "archiver": "^5.3.1",
@@ -704,13 +704,13 @@
} }
}, },
"node_modules/@nestjs/mapped-types": { "node_modules/@nestjs/mapped-types": {
"version": "1.2.0", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.2.tgz",
"integrity": "sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg==", "integrity": "sha512-3dHxLXs3M0GPiriAcCFFJQHoDFUuzTD5w6JDhE7TyfT89YKpe6tcCCIqOZWdXmt9AZjjK30RkHRSFF+QEnWFQg==",
"peerDependencies": { "peerDependencies": {
"@nestjs/common": "^7.0.8 || ^8.0.0 || ^9.0.0", "@nestjs/common": "^7.0.8 || ^8.0.0 || ^9.0.0",
"class-transformer": "^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", "class-transformer": "^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0",
"class-validator": "^0.11.1 || ^0.12.0 || ^0.13.0", "class-validator": "^0.11.1 || ^0.12.0 || ^0.13.0 || ^0.14.0",
"reflect-metadata": "^0.1.12" "reflect-metadata": "^0.1.12"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -806,6 +806,37 @@
"typescript": "^4.3.5" "typescript": "^4.3.5"
} }
}, },
"node_modules/@nestjs/swagger": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-6.2.1.tgz",
"integrity": "sha512-9M2vkfJHIzLqDZwvM5TEZO0MxRCvIb0xVy0LsmWwxH1lrb0z/4MhU+r2CWDhBtTccVJrKxVPiU2s3T3b9uUJbg==",
"dependencies": {
"@nestjs/mapped-types": "1.2.2",
"js-yaml": "4.1.0",
"lodash": "4.17.21",
"path-to-regexp": "3.2.0",
"swagger-ui-dist": "4.15.5"
},
"peerDependencies": {
"@fastify/static": "^6.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/core": "^9.0.0",
"class-transformer": "*",
"class-validator": "*",
"reflect-metadata": "^0.1.12"
},
"peerDependenciesMeta": {
"@fastify/static": {
"optional": true
},
"class-transformer": {
"optional": true
},
"class-validator": {
"optional": true
}
}
},
"node_modules/@nestjs/testing": { "node_modules/@nestjs/testing": {
"version": "9.2.1", "version": "9.2.1",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.2.1.tgz", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.2.1.tgz",
@@ -1955,8 +1986,7 @@
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
"dev": true
}, },
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
@@ -4431,7 +4461,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
}, },
@@ -6641,6 +6670,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/swagger-ui-dist": {
"version": "4.15.5",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.15.5.tgz",
"integrity": "sha512-V3eIa28lwB6gg7/wfNvAbjwJYmDXy1Jo1POjyTzlB6wPcHiGlRxq39TSjYGVjQrUSAzpv+a7nzp7mDxgNy57xA=="
},
"node_modules/symbol-observable": { "node_modules/symbol-observable": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
@@ -8047,9 +8081,9 @@
} }
}, },
"@nestjs/mapped-types": { "@nestjs/mapped-types": {
"version": "1.2.0", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.2.tgz",
"integrity": "sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg==", "integrity": "sha512-3dHxLXs3M0GPiriAcCFFJQHoDFUuzTD5w6JDhE7TyfT89YKpe6tcCCIqOZWdXmt9AZjjK30RkHRSFF+QEnWFQg==",
"requires": {} "requires": {}
}, },
"@nestjs/passport": { "@nestjs/passport": {
@@ -8115,6 +8149,18 @@
"pluralize": "8.0.0" "pluralize": "8.0.0"
} }
}, },
"@nestjs/swagger": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-6.2.1.tgz",
"integrity": "sha512-9M2vkfJHIzLqDZwvM5TEZO0MxRCvIb0xVy0LsmWwxH1lrb0z/4MhU+r2CWDhBtTccVJrKxVPiU2s3T3b9uUJbg==",
"requires": {
"@nestjs/mapped-types": "1.2.2",
"js-yaml": "4.1.0",
"lodash": "4.17.21",
"path-to-regexp": "3.2.0",
"swagger-ui-dist": "4.15.5"
}
},
"@nestjs/testing": { "@nestjs/testing": {
"version": "9.2.1", "version": "9.2.1",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.2.1.tgz", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.2.1.tgz",
@@ -9041,8 +9087,7 @@
"argparse": { "argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
"dev": true
}, },
"array-flatten": { "array-flatten": {
"version": "1.1.1", "version": "1.1.1",
@@ -10908,7 +10953,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"requires": { "requires": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
} }
@@ -12563,6 +12607,11 @@
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true "dev": true
}, },
"swagger-ui-dist": {
"version": "4.15.5",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.15.5.tgz",
"integrity": "sha512-V3eIa28lwB6gg7/wfNvAbjwJYmDXy1Jo1POjyTzlB6wPcHiGlRxq39TSjYGVjQrUSAzpv+a7nzp7mDxgNy57xA=="
},
"symbol-observable": { "symbol-observable": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",

View File

@@ -1,9 +1,9 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.9.0", "version": "0.10.0",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"dev": "nest start --watch", "dev": "cross-env NODE_ENV=development nest start --watch",
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main", "prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
"lint": "eslint 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"format": "prettier --write 'src/**/*.ts'", "format": "prettier --write 'src/**/*.ts'",
@@ -17,10 +17,10 @@
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1", "@nestjs/core": "^9.2.1",
"@nestjs/jwt": "^10.0.1", "@nestjs/jwt": "^10.0.1",
"@nestjs/mapped-types": "^1.2.0",
"@nestjs/passport": "^9.0.0", "@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.2.1", "@nestjs/platform-express": "^9.2.1",
"@nestjs/schedule": "^2.1.0", "@nestjs/schedule": "^2.1.0",
"@nestjs/swagger": "^6.2.1",
"@nestjs/throttler": "^3.1.0", "@nestjs/throttler": "^3.1.0",
"@prisma/client": "^4.8.1", "@prisma/client": "^4.8.1",
"archiver": "^5.3.1", "archiver": "^5.3.1",

View File

@@ -0,0 +1,64 @@
/*
Warnings:
- You are about to drop the column `shareId` on the `ReverseShare` table. All the data in the column will be lost.
- You are about to drop the column `used` on the `ReverseShare` table. All the data in the column will be lost.
- Added the required column `remainingUses` to the `ReverseShare` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
PRAGMA foreign_keys=OFF;
CREATE TABLE "ResetPasswordToken" (
"token" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Disable TOTP as secret isn't encrypted anymore
UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL;
-- RedefineTables
CREATE TABLE "new_Share" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
"views" INTEGER NOT NULL DEFAULT 0,
"expiration" DATETIME NOT NULL,
"description" TEXT,
"removedReason" TEXT,
"creatorId" TEXT,
"reverseShareId" TEXT,
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Share_reverseShareId_fkey" FOREIGN KEY ("reverseShareId") REFERENCES "ReverseShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Share" ("createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", "reverseShareId")
SELECT "createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", (SELECT id FROM ReverseShare WHERE shareId=Share.id)
FROM "Share";
DROP TABLE "Share";
ALTER TABLE "new_Share" RENAME TO "Share";
CREATE TABLE "new_ReverseShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"token" TEXT NOT NULL,
"shareExpiration" DATETIME NOT NULL,
"maxShareSize" TEXT NOT NULL,
"sendEmailNotification" BOOLEAN NOT NULL,
"remainingUses" INTEGER NOT NULL,
"creatorId" TEXT NOT NULL,
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", "remainingUses") SELECT "createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", iif("ReverseShare".used, 0, 1) FROM "ReverseShare";
DROP TABLE "ReverseShare";
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
-- CreateIndex
CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId");

View File

@@ -22,9 +22,10 @@ model User {
loginTokens LoginToken[] loginTokens LoginToken[]
reverseShares ReverseShare[] reverseShares ReverseShare[]
totpEnabled Boolean @default(false) totpEnabled Boolean @default(false)
totpVerified Boolean @default(false) totpVerified Boolean @default(false)
totpSecret String? totpSecret String?
resetPasswordToken ResetPasswordToken?
} }
model RefreshToken { model RefreshToken {
@@ -49,6 +50,16 @@ model LoginToken {
used Boolean @default(false) used Boolean @default(false)
} }
model ResetPasswordToken {
token String @id @default(uuid())
createdAt DateTime @default(now())
expiresAt DateTime
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Share { model Share {
id String @id @default(uuid()) id String @id @default(uuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -63,7 +74,8 @@ model Share {
creatorId String? creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade) creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
reverseShare ReverseShare? reverseShareId String?
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
security ShareSecurity? security ShareSecurity?
recipients ShareRecipient[] recipients ShareRecipient[]
@@ -78,13 +90,12 @@ model ReverseShare {
shareExpiration DateTime shareExpiration DateTime
maxShareSize String maxShareSize String
sendEmailNotification Boolean sendEmailNotification Boolean
used Boolean @default(false) remainingUses Int
creatorId String creatorId String
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
shareId String? @unique shares Share[]
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
} }
model ShareRecipient { model ShareRecipient {

View File

@@ -21,15 +21,6 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "internal", category: "internal",
locked: true, locked: true,
}, },
{
order: 0,
key: "TOTP_SECRET",
description: "A 16 byte random string used to generate TOTP secrets",
type: "string",
value: crypto.randomBytes(16).toString("base64"),
category: "internal",
locked: true,
},
{ {
order: 1, order: 1,
key: "APP_URL", key: "APP_URL",
@@ -89,6 +80,15 @@ const configVariables: Prisma.ConfigCreateInput[] = [
}, },
{ {
order: 7, order: 7,
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
description:
"Subject of the email which gets sent to the share recipients.",
type: "string",
value: "Files shared with you",
category: "email",
},
{
order: 8,
key: "SHARE_RECEPIENTS_EMAIL_MESSAGE", key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
description: description:
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.", "Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
@@ -98,16 +98,16 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "email", category: "email",
}, },
{ {
order: 8, order: 9,
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT", key: "REVERSE_SHARE_EMAIL_SUBJECT",
description: description:
"Subject of the email which gets sent to the share recipients.", "Subject of the email which gets sent when someone created a share with your reverse share link.",
type: "string", type: "string",
value: "Files shared with you", value: "Reverse share link used",
category: "email", category: "email",
}, },
{ {
order: 9, order: 10,
key: "REVERSE_SHARE_EMAIL_MESSAGE", key: "REVERSE_SHARE_EMAIL_MESSAGE",
description: description:
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.", "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
@@ -117,16 +117,27 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "email", category: "email",
}, },
{ {
order: 10, order: 11,
key: "REVERSE_SHARE_EMAIL_SUBJECT", key: "RESET_PASSWORD_EMAIL_SUBJECT",
description: description:
"Subject of the email which gets sent when someone created a share with your reverse share link.", "Subject of the email which gets sent when a user requests a password reset.",
type: "string", type: "string",
value: "Reverse share link used", value: "Pingvin Share password reset",
category: "email", category: "email",
}, },
{ {
order: 11, order: 12,
key: "RESET_PASSWORD_EMAIL_MESSAGE",
description:
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
type: "text",
value:
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
category: "email",
},
{
order: 13,
key: "SMTP_ENABLED", key: "SMTP_ENABLED",
description: description:
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
@@ -136,7 +147,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
secret: false, secret: false,
}, },
{ {
order: 12, order: 14,
key: "SMTP_HOST", key: "SMTP_HOST",
description: "Host of the SMTP server", description: "Host of the SMTP server",
type: "string", type: "string",
@@ -144,7 +155,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 13, order: 15,
key: "SMTP_PORT", key: "SMTP_PORT",
description: "Port of the SMTP server", description: "Port of the SMTP server",
type: "number", type: "number",
@@ -152,7 +163,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 14, order: 16,
key: "SMTP_EMAIL", key: "SMTP_EMAIL",
description: "Email address which the emails get sent from", description: "Email address which the emails get sent from",
type: "string", type: "string",
@@ -160,7 +171,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 15, order: 17,
key: "SMTP_USERNAME", key: "SMTP_USERNAME",
description: "Username of the SMTP server", description: "Username of the SMTP server",
type: "string", type: "string",
@@ -168,7 +179,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 16, order: 18,
key: "SMTP_PASSWORD", key: "SMTP_PASSWORD",
description: "Password of the SMTP server", description: "Password of the SMTP server",
type: "string", type: "string",

View File

@@ -3,6 +3,7 @@ import {
Controller, Controller,
ForbiddenException, ForbiddenException,
HttpCode, HttpCode,
Param,
Patch, Patch,
Post, Post,
Req, Req,
@@ -21,6 +22,7 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
import { EnableTotpDTO } from "./dto/enableTotp.dto"; import { EnableTotpDTO } from "./dto/enableTotp.dto";
import { ResetPasswordDTO } from "./dto/resetPassword.dto";
import { TokenDTO } from "./dto/token.dto"; import { TokenDTO } from "./dto/token.dto";
import { UpdatePasswordDTO } from "./dto/updatePassword.dto"; import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
import { VerifyTotpDTO } from "./dto/verifyTotp.dto"; import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
@@ -34,8 +36,8 @@ export class AuthController {
private config: ConfigService private config: ConfigService
) {} ) {}
@Throttle(10, 5 * 60)
@Post("signUp") @Post("signUp")
@Throttle(10, 5 * 60)
async signUp( async signUp(
@Body() dto: AuthRegisterDTO, @Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response
@@ -54,8 +56,8 @@ export class AuthController {
return result; return result;
} }
@Throttle(10, 5 * 60)
@Post("signIn") @Post("signIn")
@Throttle(10, 5 * 60)
@HttpCode(200) @HttpCode(200)
async signIn( async signIn(
@Body() dto: AuthSignInDTO, @Body() dto: AuthSignInDTO,
@@ -74,8 +76,8 @@ export class AuthController {
return result; return result;
} }
@Throttle(10, 5 * 60)
@Post("signIn/totp") @Post("signIn/totp")
@Throttle(10, 5 * 60)
@HttpCode(200) @HttpCode(200)
async signInTotp( async signInTotp(
@Body() dto: AuthSignInTotpDTO, @Body() dto: AuthSignInTotpDTO,
@@ -92,6 +94,20 @@ export class AuthController {
return new TokenDTO().from(result); return new TokenDTO().from(result);
} }
@Post("resetPassword/:email")
@Throttle(5, 5 * 60)
@HttpCode(204)
async requestResetPassword(@Param("email") email: string) {
return await this.authService.requestResetPassword(email);
}
@Post("resetPassword")
@Throttle(5, 5 * 60)
@HttpCode(204)
async resetPassword(@Body() dto: ResetPasswordDTO) {
return await this.authService.resetPassword(dto.token, dto.password);
}
@Patch("password") @Patch("password")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async updatePassword( async updatePassword(
@@ -120,7 +136,7 @@ export class AuthController {
const accessToken = await this.authService.refreshAccessToken( const accessToken = await this.authService.refreshAccessToken(
request.cookies.refresh_token request.cookies.refresh_token
); );
response.cookie("access_token", accessToken); response = this.addTokensToResponse(response, undefined, accessToken);
return new TokenDTO().from({ accessToken }); return new TokenDTO().from({ accessToken });
} }
@@ -162,11 +178,13 @@ export class AuthController {
refreshToken?: string, refreshToken?: string,
accessToken?: string accessToken?: string
) { ) {
if (accessToken) response.cookie("access_token", accessToken); if (accessToken)
response.cookie("access_token", accessToken, { sameSite: "lax" });
if (refreshToken) if (refreshToken)
response.cookie("refresh_token", refreshToken, { response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token", path: "/api/auth/token",
httpOnly: true, httpOnly: true,
sameSite: "strict",
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
}); });

View File

@@ -1,12 +1,13 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { EmailModule } from "src/email/email.module";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthTotpService } from "./authTotp.service"; import { AuthTotpService } from "./authTotp.service";
import { JwtStrategy } from "./strategy/jwt.strategy"; import { JwtStrategy } from "./strategy/jwt.strategy";
@Module({ @Module({
imports: [JwtModule.register({})], imports: [JwtModule.register({}), EmailModule],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, AuthTotpService, JwtStrategy], providers: [AuthService, AuthTotpService, JwtStrategy],
exports: [AuthService], exports: [AuthService],

View File

@@ -10,6 +10,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as argon from "argon2"; import * as argon from "argon2";
import * as moment from "moment"; import * as moment from "moment";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
@@ -19,7 +20,8 @@ export class AuthService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private jwtService: JwtService, private jwtService: JwtService,
private config: ConfigService private config: ConfigService,
private emailService: EmailService
) {} ) {}
async signUp(dto: AuthRegisterDTO) { async signUp(dto: AuthRegisterDTO) {
@@ -87,6 +89,50 @@ export class AuthService {
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
async requestResetPassword(email: string) {
const user = await this.prisma.user.findFirst({
where: { email },
include: { resetPasswordToken: true },
});
if (!user) throw new BadRequestException("User not found");
// Delete old reset password token
if (user.resetPasswordToken) {
await this.prisma.resetPasswordToken.delete({
where: { token: user.resetPasswordToken.token },
});
}
const { token } = await this.prisma.resetPasswordToken.create({
data: {
expiresAt: moment().add(1, "hour").toDate(),
user: { connect: { id: user.id } },
},
});
await this.emailService.sendResetPasswordEmail(user.email, token);
}
async resetPassword(token: string, newPassword: string) {
const user = await this.prisma.user.findFirst({
where: { resetPasswordToken: { token } },
});
if (!user) throw new BadRequestException("Token invalid or expired");
const newPasswordHash = await argon.hash(newPassword);
await this.prisma.resetPasswordToken.delete({
where: { token },
});
await this.prisma.user.update({
where: { id: user.id },
data: { password: newPasswordHash },
});
}
async updatePassword(user: User, oldPassword: string, newPassword: string) { async updatePassword(user: User, oldPassword: string, newPassword: string) {
if (!(await argon.verify(user.password, oldPassword))) if (!(await argon.verify(user.password, oldPassword)))
throw new ForbiddenException("Invalid password"); throw new ForbiddenException("Invalid password");
@@ -110,6 +156,7 @@ export class AuthService {
{ {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
isAdmin: user.isAdmin,
refreshTokenId, refreshTokenId,
}, },
{ {
@@ -120,16 +167,19 @@ export class AuthService {
} }
async signOut(accessToken: string) { async signOut(accessToken: string) {
const { refreshTokenId } = this.jwtService.decode(accessToken) as { const { refreshTokenId } =
refreshTokenId: string; (this.jwtService.decode(accessToken) as {
}; refreshTokenId: string;
}) || {};
await this.prisma.refreshToken if (refreshTokenId) {
.delete({ where: { id: refreshTokenId } }) await this.prisma.refreshToken
.catch((e) => { .delete({ where: { id: refreshTokenId } })
// Ignore error if refresh token doesn't exist .catch((e) => {
if (e.code != "P2025") throw e; // Ignore error if refresh token doesn't exist
}); if (e.code != "P2025") throw e;
});
}
} }
async refreshAccessToken(refreshToken: string) { async refreshAccessToken(refreshToken: string) {

View File

@@ -6,10 +6,8 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import * as argon from "argon2"; import * as argon from "argon2";
import * as crypto from "crypto";
import { authenticator, totp } from "otplib"; import { authenticator, totp } from "otplib";
import * as qrcode from "qrcode-svg"; import * as qrcode from "qrcode-svg";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@@ -17,7 +15,6 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@Injectable() @Injectable()
export class AuthTotpService { export class AuthTotpService {
constructor( constructor(
private config: ConfigService,
private prisma: PrismaService, private prisma: PrismaService,
private authService: AuthService private authService: AuthService
) {} ) {}
@@ -57,9 +54,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not enabled"); throw new BadRequestException("TOTP is not enabled");
} }
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password); const expected = authenticator.generate(totpSecret);
const expected = authenticator.generate(decryptedSecret);
if (dto.totp !== expected) { if (dto.totp !== expected) {
throw new BadRequestException("Invalid code"); throw new BadRequestException("Invalid code");
@@ -81,41 +76,6 @@ export class AuthTotpService {
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
encryptTotpSecret(totpSecret: string, password: string) {
let iv = this.config.get("TOTP_SECRET");
iv = Buffer.from(iv, "base64");
const key = crypto
.createHash("sha256")
.update(String(password))
.digest("base64")
.substr(0, 32);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
let encrypted = cipher.update(totpSecret);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return encrypted.toString("base64");
}
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
let iv = this.config.get("TOTP_SECRET");
iv = Buffer.from(iv, "base64");
const key = crypto
.createHash("sha256")
.update(String(password))
.digest("base64")
.substr(0, 32);
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
async enableTotp(user: User, password: string) { async enableTotp(user: User, password: string) {
if (!(await argon.verify(user.password, password))) if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password"); throw new ForbiddenException("Invalid password");
@@ -132,7 +92,6 @@ export class AuthTotpService {
// TODO: Maybe make the issuer configurable with env vars? // TODO: Maybe make the issuer configurable with env vars?
const secret = authenticator.generateSecret(); const secret = authenticator.generateSecret();
const encryptedSecret = this.encryptTotpSecret(secret, password);
const otpURL = totp.keyuri( const otpURL = totp.keyuri(
user.username || user.email, user.username || user.email,
@@ -144,7 +103,7 @@ export class AuthTotpService {
where: { id: user.id }, where: { id: user.id },
data: { data: {
totpEnabled: true, totpEnabled: true,
totpSecret: encryptedSecret, totpSecret: secret,
}, },
}); });
@@ -177,9 +136,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not in progress"); throw new BadRequestException("TOTP is not in progress");
} }
const decryptedSecret = this.decryptTotpSecret(totpSecret, password); const expected = authenticator.generate(totpSecret);
const expected = authenticator.generate(decryptedSecret);
if (code !== expected) { if (code !== expected) {
throw new BadRequestException("Invalid code"); throw new BadRequestException("Invalid code");
@@ -208,9 +165,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not enabled"); throw new BadRequestException("TOTP is not enabled");
} }
const decryptedSecret = this.decryptTotpSecret(totpSecret, password); const expected = authenticator.generate(totpSecret);
const expected = authenticator.generate(decryptedSecret);
if (code !== expected) { if (code !== expected) {
throw new BadRequestException("Invalid code"); throw new BadRequestException("Invalid code");

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { UserDTO } from "src/user/dto/user.dto"; import { UserDTO } from "src/user/dto/user.dto";
export class AuthRegisterDTO extends PickType(UserDTO, [ export class AuthRegisterDTO extends PickType(UserDTO, [

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString } from "class-validator"; import { IsEmail, IsOptional, IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto"; import { UserDTO } from "src/user/dto/user.dto";

View File

@@ -1,18 +1,7 @@
import { PickType } from "@nestjs/mapped-types"; import { IsString } from "class-validator";
import { IsEmail, IsOptional, IsString } from "class-validator"; import { AuthSignInDTO } from "./authSignIn.dto";
import { UserDTO } from "src/user/dto/user.dto";
export class AuthSignInTotpDTO extends PickType(UserDTO, [
"password",
] as const) {
@IsEmail()
@IsOptional()
email: string;
@IsString()
@IsOptional()
username: string;
export class AuthSignInTotpDTO extends AuthSignInDTO {
@IsString() @IsString()
totp: string; totp: string;

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { UserDTO } from "src/user/dto/user.dto"; import { UserDTO } from "src/user/dto/user.dto";
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {} export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}

View File

@@ -0,0 +1,8 @@
import { PickType } from "@nestjs/swagger";
import { IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto";
export class ResetPasswordDTO extends PickType(UserDTO, ["password"]) {
@IsString()
token: string;
}

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { IsString } from "class-validator"; import { IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto"; import { UserDTO } from "src/user/dto/user.dto";

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { IsString } from "class-validator"; import { IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto"; import { UserDTO } from "src/user/dto/user.dto";

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common"; import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
import { SkipThrottle } from "@nestjs/throttler";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard";
import { EmailService } from "src/email/email.service"; import { EmailService } from "src/email/email.service";
@@ -16,6 +17,7 @@ export class ConfigController {
) {} ) {}
@Get() @Get()
@SkipThrottle()
async list() { async list() {
return new ConfigDTO().fromList(await this.configService.list()); return new ConfigDTO().fromList(await this.configService.list());
} }

View File

@@ -77,9 +77,13 @@ export class ConfigService {
} }
async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") { async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") {
return await this.prisma.config.update({ const updatedVariable = await this.prisma.config.update({
where: { key: "SETUP_STATUS" }, where: { key: "SETUP_STATUS" },
data: { value: status }, data: { value: status },
}); });
this.configVariables = await this.prisma.config.findMany();
return updatedVariable;
} }
} }

View File

@@ -58,6 +58,21 @@ export class EmailService {
}); });
} }
async sendResetPasswordEmail(recipientEmail: string, token: string) {
const resetPasswordUrl = `${this.config.get(
"APP_URL"
)}/auth/resetPassword/${token}`;
await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: this.config.get("RESET_PASSWORD_EMAIL_SUBJECT"),
text: this.config
.get("RESET_PASSWORD_EMAIL_MESSAGE")
.replaceAll("{url}", resetPasswordUrl),
});
}
async sendTestMail(recipientEmail: string) { async sendTestMail(recipientEmail: string) {
try { try {
await this.getTransporter().sendMail({ await this.getTransporter().sendMail({

View File

@@ -1,9 +1,10 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { FileModule } from "src/file/file.module"; import { FileModule } from "src/file/file.module";
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
import { JobsService } from "./jobs.service"; import { JobsService } from "./jobs.service";
@Module({ @Module({
imports: [FileModule], imports: [FileModule, ReverseShareModule],
providers: [JobsService], providers: [JobsService],
}) })
export class JobsModule {} export class JobsModule {}

View File

@@ -4,11 +4,13 @@ import * as fs from "fs";
import * as moment from "moment"; import * as moment from "moment";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
@Injectable() @Injectable()
export class JobsService { export class JobsService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private reverseShareService: ReverseShareService,
private fileService: FileService private fileService: FileService
) {} ) {}
@@ -36,6 +38,24 @@ export class JobsService {
console.log(`job: deleted ${expiredShares.length} expired shares`); console.log(`job: deleted ${expiredShares.length} expired shares`);
} }
@Cron("0 * * * *")
async deleteExpiredReverseShares() {
const expiredReverseShares = await this.prisma.reverseShare.findMany({
where: {
shareExpiration: { lt: new Date() },
},
});
for (const expiredReverseShare of expiredReverseShares) {
await this.reverseShareService.remove(expiredReverseShare.id);
}
if (expiredReverseShares.length > 0)
console.log(
`job: deleted ${expiredReverseShares.length} expired reverse shares`
);
}
@Cron("0 0 * * *") @Cron("0 0 * * *")
deleteTemporaryFiles() { deleteTemporaryFiles() {
let filesDeleted = 0; let filesDeleted = 0;
@@ -69,14 +89,25 @@ export class JobsService {
} }
@Cron("0 * * * *") @Cron("0 * * * *")
async deleteExpiredRefreshTokens() { async deleteExpiredTokens() {
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({ const { count: refreshTokenCount } =
await this.prisma.refreshToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
const { count: loginTokenCount } = await this.prisma.loginToken.deleteMany({
where: { expiresAt: { lt: new Date() } }, where: { expiresAt: { lt: new Date() } },
}); });
if (expiredRefreshTokens.count > 0) const { count: resetPasswordTokenCount } =
console.log( await this.prisma.resetPasswordToken.deleteMany({
`job: deleted ${expiredRefreshTokens.count} expired refresh tokens` where: { expiresAt: { lt: new Date() } },
); });
const deletedTokensCount =
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
if (deletedTokensCount > 0)
console.log(`job: deleted ${deletedTokensCount} expired refresh tokens`);
} }
} }

View File

@@ -1,6 +1,7 @@
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common"; import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
import { NestFactory, Reflector } from "@nestjs/core"; import { NestFactory, Reflector } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express"; import { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import * as bodyParser from "body-parser"; import * as bodyParser from "body-parser";
import * as cookieParser from "cookie-parser"; import * as cookieParser from "cookie-parser";
import * as fs from "fs"; import * as fs from "fs";
@@ -18,6 +19,17 @@ async function bootstrap() {
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true }); await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
app.setGlobalPrefix("api"); app.setGlobalPrefix("api");
// Setup Swagger in development mode
if (process.env.NODE_ENV == "development") {
const config = new DocumentBuilder()
.setTitle("Pingvin Share API")
.setVersion("1.0")
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/swagger", app, document);
}
await app.listen(8080); await app.listen(8080);
} }
bootstrap(); bootstrap();

View File

@@ -1,4 +1,4 @@
import { IsBoolean, IsString } from "class-validator"; import { IsBoolean, IsString, Max, Min } from "class-validator";
export class CreateReverseShareDTO { export class CreateReverseShareDTO {
@IsBoolean() @IsBoolean()
@@ -9,4 +9,8 @@ export class CreateReverseShareDTO {
@IsString() @IsString()
shareExpiration: string; shareExpiration: string;
@Min(1)
@Max(1000)
maxUseCount: number;
} }

View File

@@ -1,9 +1,9 @@
import { OmitType } from "@nestjs/mapped-types"; import { OmitType } from "@nestjs/swagger";
import { Expose, plainToClass, Type } from "class-transformer"; import { Expose, plainToClass, Type } from "class-transformer";
import { MyShareDTO } from "src/share/dto/myShare.dto"; import { MyShareDTO } from "src/share/dto/myShare.dto";
import { ReverseShareDTO } from "./reverseShare.dto"; import { ReverseShareDTO } from "./reverseShare.dto";
export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
"shareExpiration", "shareExpiration",
] as const) { ] as const) {
@Expose() @Expose()
@@ -11,14 +11,17 @@ export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
@Expose() @Expose()
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const)) @Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
share: Omit< shares: Omit<
MyShareDTO, MyShareDTO,
"recipients" | "files" | "from" | "fromList" | "hasPassword" "recipients" | "files" | "from" | "fromList" | "hasPassword"
>; >[];
fromList(partial: Partial<ReverseShareTokenWithShare>[]) { @Expose()
remainingUses: number;
fromList(partial: Partial<ReverseShareTokenWithShares>[]) {
return partial.map((part) => return partial.map((part) =>
plainToClass(ReverseShareTokenWithShare, part, { plainToClass(ReverseShareTokenWithShares, part, {
excludeExtraneousValues: true, excludeExtraneousValues: true,
}) })
); );

View File

@@ -15,7 +15,7 @@ import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto"; import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
import { ReverseShareDTO } from "./dto/reverseShare.dto"; import { ReverseShareDTO } from "./dto/reverseShare.dto";
import { ReverseShareTokenWithShare } from "./dto/reverseShareTokenWithShare"; import { ReverseShareTokenWithShares } from "./dto/reverseShareTokenWithShares";
import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard"; import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard";
import { ReverseShareService } from "./reverseShare.service"; import { ReverseShareService } from "./reverseShare.service";
@@ -51,7 +51,7 @@ export class ReverseShareController {
@Get() @Get()
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async getAllByUser(@GetUser() user: User) { async getAllByUser(@GetUser() user: User) {
return new ReverseShareTokenWithShare().fromList( return new ReverseShareTokenWithShares().fromList(
await this.reverseShareService.getAllByUser(user.id) await this.reverseShareService.getAllByUser(user.id)
); );
} }

View File

@@ -34,6 +34,7 @@ export class ReverseShareService {
const reverseShare = await this.prisma.reverseShare.create({ const reverseShare = await this.prisma.reverseShare.create({
data: { data: {
shareExpiration: expirationDate, shareExpiration: expirationDate,
remainingUses: data.maxUseCount,
maxShareSize: data.maxShareSize, maxShareSize: data.maxShareSize,
sendEmailNotification: data.sendEmailNotification, sendEmailNotification: data.sendEmailNotification,
creatorId, creatorId,
@@ -43,7 +44,9 @@ export class ReverseShareService {
return reverseShare.token; return reverseShare.token;
} }
async getByToken(reverseShareToken: string) { async getByToken(reverseShareToken?: string) {
if (!reverseShareToken) return null;
const reverseShare = await this.prisma.reverseShare.findUnique({ const reverseShare = await this.prisma.reverseShare.findUnique({
where: { token: reverseShareToken }, where: { token: reverseShareToken },
}); });
@@ -60,7 +63,7 @@ export class ReverseShareService {
orderBy: { orderBy: {
shareExpiration: "desc", shareExpiration: "desc",
}, },
include: { share: { include: { creator: true } } }, include: { shares: { include: { creator: true } } },
}); });
return reverseShares; return reverseShares;
@@ -74,21 +77,21 @@ export class ReverseShareService {
if (!reverseShare) return false; if (!reverseShare) return false;
const isExpired = new Date() > reverseShare.shareExpiration; const isExpired = new Date() > reverseShare.shareExpiration;
const isUsed = reverseShare.used; const remainingUsesExceeded = reverseShare.remainingUses <= 0;
return !(isExpired || isUsed); return !(isExpired || remainingUsesExceeded);
} }
async remove(id: string) { async remove(id: string) {
const share = await this.prisma.share.findFirst({ const shares = await this.prisma.share.findMany({
where: { reverseShare: { id } }, where: { reverseShare: { id } },
}); });
if (share) { for (const share of shares) {
await this.prisma.share.delete({ where: { id: share.id } }); await this.prisma.share.delete({ where: { id: share.id } });
await this.fileService.deleteAllFiles(share.id); await this.fileService.deleteAllFiles(share.id);
} else {
await this.prisma.reverseShare.delete({ where: { id } });
} }
await this.prisma.reverseShare.delete({ where: { id } });
} }
} }

View File

@@ -44,12 +44,11 @@ export class ShareService {
let expirationDate: Date; let expirationDate: Date;
// If share is created by a reverse share token override the expiration date // If share is created by a reverse share token override the expiration date
if (reverseShareToken) { const reverseShare = await this.reverseShareService.getByToken(
const { shareExpiration } = await this.reverseShareService.getByToken( reverseShareToken
reverseShareToken );
); if (reverseShare) {
expirationDate = reverseShare.shareExpiration;
expirationDate = shareExpiration;
} else { } else {
// We have to add an exception for "never" (since moment won't like that) // We have to add an exception for "never" (since moment won't like that)
if (share.expiration !== "never") { if (share.expiration !== "never") {
@@ -84,12 +83,14 @@ export class ShareService {
}, },
}); });
if (reverseShareToken) { if (reverseShare) {
// Assign share to reverse share token // Assign share to reverse share token
await this.prisma.reverseShare.update({ await this.prisma.reverseShare.update({
where: { token: reverseShareToken }, where: { token: reverseShareToken },
data: { data: {
shareId: share.id, shares: {
connect: { id: shareTuple.id },
},
}, },
}); });
} }
@@ -164,10 +165,10 @@ export class ShareService {
// Check if any file is malicious with ClamAV // Check if any file is malicious with ClamAV
this.clamScanService.checkAndRemove(share.id); this.clamScanService.checkAndRemove(share.id);
if (reverseShareToken) { if (share.reverseShare) {
await this.prisma.reverseShare.update({ await this.prisma.reverseShare.update({
where: { token: reverseShareToken }, where: { token: reverseShareToken },
data: { used: true }, data: { remainingUses: { decrement: 1 } },
}); });
} }

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { UserDTO } from "./user.dto"; import { UserDTO } from "./user.dto";
export class PublicUserDTO extends PickType(UserDTO, ["username"] as const) {} export class PublicUserDTO extends PickType(UserDTO, ["username"] as const) {}

View File

@@ -1,4 +1,4 @@
import { OmitType, PartialType } from "@nestjs/mapped-types"; import { OmitType, PartialType } from "@nestjs/swagger";
import { UserDTO } from "./user.dto"; import { UserDTO } from "./user.dto";
export class UpdateOwnUserDTO extends PartialType( export class UpdateOwnUserDTO extends PartialType(

View File

@@ -1,4 +1,4 @@
import { PartialType } from "@nestjs/mapped-types"; import { PartialType } from "@nestjs/swagger";
import { CreateUserDTO } from "./createUser.dto"; import { CreateUserDTO } from "./createUser.dto";
export class UpdateUserDto extends PartialType(CreateUserDTO) {} export class UpdateUserDto extends PartialType(CreateUserDTO) {}

View File

@@ -4,7 +4,6 @@ import * as argon from "argon2";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserDTO } from "./dto/createUser.dto"; import { CreateUserDTO } from "./dto/createUser.dto";
import { UpdateUserDto } from "./dto/updateUser.dto"; import { UpdateUserDto } from "./dto/updateUser.dto";
import { UserDTO } from "./dto/user.dto";
@Injectable() @Injectable()
export class UserSevice { export class UserSevice {

View File

@@ -1,6 +1,6 @@
{ {
"info": { "info": {
"_postman_id": "38c7001d-4868-484b-935a-84fd3b5e7cf6", "_postman_id": "cd31bdf9-d558-42da-9231-154721476cd2",
"name": "Pingvin Share Testing", "name": "Pingvin Share Testing",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "17822132" "_exporter_id": "17822132"
@@ -804,16 +804,6 @@
"request": { "request": {
"method": "POST", "method": "POST",
"header": [], "header": [],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
}
]
},
"url": { "url": {
"raw": "{{API_URL}}/shares/:shareId/files", "raw": "{{API_URL}}/shares/:shareId/files",
"host": [ "host": [
@@ -853,16 +843,6 @@
"request": { "request": {
"method": "POST", "method": "POST",
"header": [], "header": [],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
}
]
},
"url": { "url": {
"raw": "{{API_URL}}/shares/:shareId/files", "raw": "{{API_URL}}/shares/:shareId/files",
"host": [ "host": [
@@ -987,7 +967,8 @@
" pm.expect(Object.keys(responseBody).length).be.equal(1)", " pm.expect(Object.keys(responseBody).length).be.equal(1)",
"});", "});",
"", "",
"pm.collectionVariables.set(\"shareToken\", pm.response.json().token)" "pm.collectionVariables.set(\"COOKIES\", `${pm.collectionVariables.get(\"COOKIES\")};${pm.response.headers.get(\"Set-Cookie\")}`)",
""
], ],
"type": "text/javascript" "type": "text/javascript"
} }
@@ -1041,8 +1022,6 @@
" pm.expect(responseBody.files.length).be.equal(2)", " pm.expect(responseBody.files.length).be.equal(2)",
"});", "});",
"", "",
"",
"",
"pm.collectionVariables.set(\"fileId\", pm.response.json().files[0].id)" "pm.collectionVariables.set(\"fileId\", pm.response.json().files[0].id)"
], ],
"type": "text/javascript" "type": "text/javascript"
@@ -1051,13 +1030,7 @@
], ],
"request": { "request": {
"method": "GET", "method": "GET",
"header": [ "header": [],
{
"key": "X-Share-Token",
"value": "{{shareToken}}",
"type": "text"
}
],
"url": { "url": {
"raw": "{{API_URL}}/shares/:shareId", "raw": "{{API_URL}}/shares/:shareId",
"host": [ "host": [
@@ -1077,88 +1050,6 @@
}, },
"response": [] "response": []
}, },
{
"name": "Get file download url",
"event": [
{
"listen": "test",
"script": {
"exec": [
"let URL = require('url');",
"",
"pm.test(\"Status code is 200\", () => {",
" pm.response.to.have.status(200);",
"});",
"",
"",
"pm.test(\"Response body correct\", () => {",
" const responseBody = pm.response.json();",
" pm.expect(responseBody).to.have.property(\"url\")",
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
"});",
"",
"",
"const path = URL.parse(pm.response.json().url).path.replace(\"/api/\", \"\")",
"",
"pm.collectionVariables.set(\"fileDownloadPath\",path )"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"method": "GET",
"header": [
{
"key": "X-Share-Token",
"value": "{{shareToken}}",
"type": "text"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
},
{
"key": "shareId",
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
"type": "text"
}
]
},
"url": {
"raw": "{{API_URL}}/shares/:shareId/files/:fileId/download",
"host": [
"{{API_URL}}"
],
"path": [
"shares",
":shareId",
"files",
":fileId",
"download"
],
"variable": [
{
"key": "shareId",
"value": "test-share"
},
{
"key": "fileId",
"value": "{{fileId}}"
}
]
}
},
"response": []
},
{ {
"name": "Get File", "name": "Get File",
"event": [ "event": [
@@ -1174,97 +1065,11 @@
} }
} }
], ],
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
},
{
"key": "shareId",
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
"type": "text"
}
]
},
"url": { "url": {
"raw": "{{API_URL}}/{{fileDownloadPath}}", "raw": "{{API_URL}}/shares/:shareId/files/{{fileId}}",
"host": [
"{{API_URL}}"
],
"path": [
"{{fileDownloadPath}}"
]
}
},
"response": []
},
{
"name": "Get zip download url",
"event": [
{
"listen": "test",
"script": {
"exec": [
"let URL = require('url');",
"",
"pm.test(\"Status code is 200\", () => {",
" pm.response.to.have.status(200);",
"});",
"",
"",
"pm.test(\"Response body correct\", () => {",
" const responseBody = pm.response.json();",
" pm.expect(responseBody).to.have.property(\"url\")",
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
"});",
"",
"",
"const path = URL.parse(pm.response.json().url).path.replace(\"/api/\", \"\")",
"",
"pm.collectionVariables.set(\"zipDownloadPath\",path )"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"method": "GET",
"header": [
{
"key": "X-Share-Token",
"value": "{{shareToken}}",
"type": "text"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
},
{
"key": "shareId",
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
"type": "text"
}
]
},
"url": {
"raw": "{{API_URL}}/shares/:shareId/files/zip/download",
"host": [ "host": [
"{{API_URL}}" "{{API_URL}}"
], ],
@@ -1272,8 +1077,7 @@
"shares", "shares",
":shareId", ":shareId",
"files", "files",
"zip", "{{fileId}}"
"download"
], ],
"variable": [ "variable": [
{ {
@@ -1306,64 +1110,16 @@
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
},
{
"key": "shareId",
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
"type": "text"
}
]
},
"url": { "url": {
"raw": "{{API_URL}}/{{zipDownloadPath}}", "raw": "{{API_URL}}/shares/:shareId/files/zip",
"host": [
"{{API_URL}}"
],
"path": [
"{{zipDownloadPath}}"
]
}
},
"response": []
}
]
},
{
"name": "Negative",
"item": [
{
"name": "Get share - No token",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 403\", () => {",
" pm.response.to.have.status(403);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{API_URL}}/shares/:shareId",
"host": [ "host": [
"{{API_URL}}" "{{API_URL}}"
], ],
"path": [ "path": [
"shares", "shares",
":shareId" ":shareId",
"files",
"zip"
], ],
"variable": [ "variable": [
{ {
@@ -1374,7 +1130,12 @@
} }
}, },
"response": [] "response": []
}, }
]
},
{
"name": "Negative",
"item": [
{ {
"name": "Get share token - Wrong password", "name": "Get share token - Wrong password",
"event": [ "event": [
@@ -1468,128 +1229,6 @@
} }
}, },
"response": [] "response": []
},
{
"name": "Get file download url - No token",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 403\", () => {",
" pm.response.to.have.status(403);",
"});",
""
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"method": "GET",
"header": [],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
},
{
"key": "shareId",
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
"type": "text"
}
]
},
"url": {
"raw": "{{API_URL}}/shares/:shareId/files/:fileId/download",
"host": [
"{{API_URL}}"
],
"path": [
"shares",
":shareId",
"files",
":fileId",
"download"
],
"variable": [
{
"key": "shareId",
"value": "test-share"
},
{
"key": "fileId",
"value": "{{fileId}}"
}
]
}
},
"response": []
},
{
"name": "Get zip download url - No token",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 403\", () => {",
" pm.response.to.have.status(403);",
"});",
""
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"method": "GET",
"header": [],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
},
{
"key": "shareId",
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
"type": "text"
}
]
},
"url": {
"raw": "{{API_URL}}/shares/:shareId/files/zip/download",
"host": [
"{{API_URL}}"
],
"path": [
"shares",
":shareId",
"files",
"zip",
"download"
],
"variable": [
{
"key": "shareId",
"value": "test-share"
}
]
}
},
"response": []
} }
] ]
} }

View File

@@ -1,12 +1,12 @@
{ {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.9.0", "version": "0.10.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.9.0", "version": "0.10.0",
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
"@emotion/server": "^11.10.0", "@emotion/server": "^11.10.0",
@@ -21,6 +21,7 @@
"cookies-next": "^2.1.1", "cookies-next": "^2.1.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jose": "^4.11.2", "jose": "^4.11.2",
"jwt-decode": "^3.1.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "^13.1.2", "next": "^13.1.2",
@@ -5610,6 +5611,11 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"node_modules/klona": { "node_modules/klona": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
@@ -12122,6 +12128,11 @@
"object.assign": "^4.1.2" "object.assign": "^4.1.2"
} }
}, },
"jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"klona": { "klona": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.9.0", "version": "0.10.0",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
@@ -22,6 +22,7 @@
"cookies-next": "^2.1.1", "cookies-next": "^2.1.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jose": "^4.11.2", "jose": "^4.11.2",
"jwt-decode": "^3.1.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "^13.1.2", "next": "^13.1.2",

View File

@@ -7,18 +7,20 @@ const Meta = ({
title: string; title: string;
description?: string; description?: string;
}) => { }) => {
const metaTitle = `${title} - Pingvin Share`;
return ( return (
<Head> <Head>
{/* TODO: Doesn't work because script get only executed on client side */} <title>{metaTitle}</title>
<title>{title} - Pingvin Share</title> <meta name="og:title" content={metaTitle} />
<meta name="og:title" content={`${title} - Pingvin Share`} />
<meta <meta
name="og:description" name="og:description"
content={ content={
description ?? "An open-source and self-hosted sharing platform." description ?? "An open-source and self-hosted sharing platform."
} }
/> />
<meta name="twitter:title" content={`${title} - Pingvin Share`} /> <meta property="og:image" content="/img/opengraph-default.png" />
<meta name="twitter:title" content={metaTitle} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
</Head> </Head>
); );

View File

@@ -18,7 +18,6 @@ const ThemeSwitcher = () => {
); );
const { toggleColorScheme } = useMantineColorScheme(); const { toggleColorScheme } = useMantineColorScheme();
const systemColorScheme = useColorScheme(); const systemColorScheme = useColorScheme();
return ( return (
<Stack> <Stack>
<SegmentedControl <SegmentedControl

View File

@@ -14,7 +14,6 @@ import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import * as yup from "yup"; import * as yup from "yup";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";

View File

@@ -9,6 +9,7 @@ import {
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks"; import { useMediaQuery } from "@mantine/hooks";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useConfig from "../../../hooks/config.hook"; import useConfig from "../../../hooks/config.hook";
import configService from "../../../services/config.service"; import configService from "../../../services/config.service";
@@ -27,12 +28,19 @@ import TestEmailButton from "./TestEmailButton";
const AdminConfigTable = () => { const AdminConfigTable = () => {
const config = useConfig(); const config = useConfig();
const router = useRouter();
const isMobile = useMediaQuery("(max-width: 560px)"); const isMobile = useMediaQuery("(max-width: 560px)");
const [updatedConfigVariables, setUpdatedConfigVariables] = useState< const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
UpdateConfig[] UpdateConfig[]
>([]); >([]);
useEffect(() => {
if (config.get("SETUP_STATUS") != "FINISHED") {
config.refresh();
}
}, []);
const updateConfigVariable = (configVariable: UpdateConfig) => { const updateConfigVariable = (configVariable: UpdateConfig) => {
const index = updatedConfigVariables.findIndex( const index = updatedConfigVariables.findIndex(
(item) => item.key === configVariable.key (item) => item.key === configVariable.key
@@ -68,7 +76,7 @@ const AdminConfigTable = () => {
.updateMany(updatedConfigVariables) .updateMany(updatedConfigVariables)
.then(async () => { .then(async () => {
await configService.finishSetup(); await configService.finishSetup();
window.location.reload(); router.replace("/upload");
}) })
.catch(toast.axiosError); .catch(toast.axiosError);
} else { } else {
@@ -80,6 +88,7 @@ const AdminConfigTable = () => {
}) })
.catch(toast.axiosError); .catch(toast.axiosError);
} }
config.refresh();
}; };
useEffect(() => { useEffect(() => {

View File

@@ -2,6 +2,7 @@ import {
Anchor, Anchor,
Button, Button,
Container, Container,
Group,
Paper, Paper,
PasswordInput, PasswordInput,
Text, Text,
@@ -11,15 +12,20 @@ import {
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { showNotification } from "@mantine/notifications"; import { showNotification } from "@mantine/notifications";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import React from "react"; import React from "react";
import { TbInfoCircle } from "react-icons/tb"; import { TbInfoCircle } from "react-icons/tb";
import * as yup from "yup"; import * as yup from "yup";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
const SignInForm = () => { const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
const config = useConfig(); const config = useConfig();
const router = useRouter();
const { refreshUser } = useUser();
const [showTotp, setShowTotp] = React.useState(false); const [showTotp, setShowTotp] = React.useState(false);
const [loginToken, setLoginToken] = React.useState(""); const [loginToken, setLoginToken] = React.useState("");
@@ -42,10 +48,10 @@ const SignInForm = () => {
validate: yupResolver(validationSchema), validate: yupResolver(validationSchema),
}); });
const signIn = (email: string, password: string) => { const signIn = async (email: string, password: string) => {
authService await authService
.signIn(email, password) .signIn(email, password)
.then((response) => { .then(async (response) => {
if (response.data["loginToken"]) { if (response.data["loginToken"]) {
// Prompt the user to enter their totp code // Prompt the user to enter their totp code
setShowTotp(true); setShowTotp(true);
@@ -58,7 +64,8 @@ const SignInForm = () => {
}); });
setLoginToken(response.data["loginToken"]); setLoginToken(response.data["loginToken"]);
} else { } else {
window.location.replace("/"); await refreshUser();
router.replace(redirectPath);
} }
}) })
.catch(toast.axiosError); .catch(toast.axiosError);
@@ -67,7 +74,10 @@ const SignInForm = () => {
const signInTotp = (email: string, password: string, totp: string) => { const signInTotp = (email: string, password: string, totp: string) => {
authService authService
.signInTotp(email, password, totp, loginToken) .signInTotp(email, password, totp, loginToken)
.then(() => window.location.replace("/")) .then(async () => {
await refreshUser();
router.replace(redirectPath);
})
.catch((error) => { .catch((error) => {
if (error?.response?.data?.message == "Login token expired") { if (error?.response?.data?.message == "Login token expired") {
toast.error("Login token expired"); toast.error("Login token expired");
@@ -82,13 +92,7 @@ const SignInForm = () => {
return ( return (
<Container size={420} my={40}> <Container size={420} my={40}>
<Title <Title order={2} align="center" weight={900}>
align="center"
sx={(theme) => ({
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
fontWeight: 900,
})}
>
Welcome back Welcome back
</Title> </Title>
{config.get("ALLOW_REGISTRATION") && ( {config.get("ALLOW_REGISTRATION") && (
@@ -109,7 +113,7 @@ const SignInForm = () => {
> >
<TextInput <TextInput
label="Email or username" label="Email or username"
placeholder="you@email.com" placeholder="Your email or username"
{...form.getInputProps("emailOrUsername")} {...form.getInputProps("emailOrUsername")}
/> />
<PasswordInput <PasswordInput
@@ -127,6 +131,13 @@ const SignInForm = () => {
{...form.getInputProps("totp")} {...form.getInputProps("totp")}
/> />
)} )}
{config.get("SMTP_ENABLED") && (
<Group position="right" mt="xs">
<Anchor component={Link} href="/auth/resetPassword" size="xs">
Forgot password?
</Anchor>
</Group>
)}
<Button fullWidth mt="xl" type="submit"> <Button fullWidth mt="xl" type="submit">
Sign in Sign in
</Button> </Button>

View File

@@ -10,13 +10,17 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import * as yup from "yup"; import * as yup from "yup";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
const SignUpForm = () => { const SignUpForm = () => {
const config = useConfig(); const config = useConfig();
const router = useRouter();
const { refreshUser } = useUser();
const validationSchema = yup.object().shape({ const validationSchema = yup.object().shape({
email: yup.string().email().required(), email: yup.string().email().required(),
@@ -33,22 +37,19 @@ const SignUpForm = () => {
validate: yupResolver(validationSchema), validate: yupResolver(validationSchema),
}); });
const signUp = (email: string, username: string, password: string) => { const signUp = async (email: string, username: string, password: string) => {
authService await authService
.signUp(email, username, password) .signUp(email, username, password)
.then(() => window.location.replace("/")) .then(async () => {
await refreshUser();
router.replace("/upload");
})
.catch(toast.axiosError); .catch(toast.axiosError);
}; };
return ( return (
<Container size={420} my={40}> <Container size={420} my={40}>
<Title <Title order={2} align="center" weight={900}>
align="center"
sx={(theme) => ({
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
fontWeight: 900,
})}
>
Sign up Sign up
</Title> </Title>
{config.get("ALLOW_REGISTRATION") && ( {config.get("ALLOW_REGISTRATION") && (
@@ -67,12 +68,12 @@ const SignUpForm = () => {
> >
<TextInput <TextInput
label="Username" label="Username"
placeholder="john.doe" placeholder="Your username"
{...form.getInputProps("username")} {...form.getInputProps("username")}
/> />
<TextInput <TextInput
label="Email" label="Email"
placeholder="you@email.com" placeholder="Your email"
mt="md" mt="md"
{...form.getInputProps("email")} {...form.getInputProps("email")}
/> />

View File

@@ -1,5 +1,4 @@
import { import {
ActionIcon,
Box, Box,
Burger, Burger,
Container, Container,
@@ -13,8 +12,8 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { TbPlus } from "react-icons/tb";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
import Logo from "../Logo"; import Logo from "../Logo";
@@ -111,11 +110,18 @@ const useStyles = createStyles((theme) => ({
const NavBar = () => { const NavBar = () => {
const { user } = useUser(); const { user } = useUser();
const router = useRouter();
const config = useConfig(); const config = useConfig();
const [opened, toggleOpened] = useDisclosure(false); const [opened, toggleOpened] = useDisclosure(false);
const authenticatedLinks = [ const [currentRoute, setCurrentRoute] = useState("");
useEffect(() => {
setCurrentRoute(router.pathname);
}, [router.pathname]);
const authenticatedLinks: NavLink[] = [
{ {
link: "/upload", link: "/upload",
label: "Upload", label: "Upload",
@@ -128,32 +134,31 @@ const NavBar = () => {
}, },
]; ];
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<NavLink[]>([ let unauthenticatedLinks: NavLink[] = [
{ {
link: "/auth/signIn", link: "/auth/signIn",
label: "Sign in", label: "Sign in",
}, },
]); ];
useEffect(() => { if (config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
if (config.get("SHOW_HOME_PAGE")) unauthenticatedLinks.unshift({
setUnauthenticatedLinks((array) => [ link: "/upload",
{ label: "Upload",
link: "/", });
label: "Home", }
},
...array,
]);
if (config.get("ALLOW_REGISTRATION")) if (config.get("SHOW_HOME_PAGE"))
setUnauthenticatedLinks((array) => [ unauthenticatedLinks.unshift({
...array, link: "/",
{ label: "Home",
link: "/auth/signUp", });
label: "Sign up",
}, if (config.get("ALLOW_REGISTRATION"))
]); unauthenticatedLinks.push({
}, []); link: "/auth/signUp",
label: "Sign up",
});
const { classes, cx } = useStyles(); const { classes, cx } = useStyles();
const items = ( const items = (
@@ -172,7 +177,7 @@ const NavBar = () => {
href={link.link ?? ""} href={link.link ?? ""}
onClick={() => toggleOpened.toggle()} onClick={() => toggleOpened.toggle()}
className={cx(classes.link, { className={cx(classes.link, {
[classes.linkActive]: window.location.pathname == link.link, [classes.linkActive]: currentRoute == link.link,
})} })}
> >
{link.label} {link.label}

View File

@@ -47,6 +47,7 @@ const Body = ({
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
maxShareSize: 104857600, maxShareSize: 104857600,
maxUseCount: 1,
sendEmailNotification: false, sendEmailNotification: false,
expiration_num: 1, expiration_num: 1,
expiration_unit: "-days", expiration_unit: "-days",
@@ -60,6 +61,7 @@ const Body = ({
.createReverseShare( .createReverseShare(
values.expiration_num + values.expiration_unit, values.expiration_num + values.expiration_unit,
values.maxShareSize, values.maxShareSize,
values.maxUseCount,
values.sendEmailNotification values.sendEmailNotification
) )
.then(({ link }) => { .then(({ link }) => {
@@ -132,6 +134,15 @@ const Body = ({
value={form.values.maxShareSize} value={form.values.maxShareSize}
onChange={(number) => form.setFieldValue("maxShareSize", number)} onChange={(number) => form.setFieldValue("maxShareSize", number)}
/> />
<NumberInput
min={1}
max={1000}
precision={0}
variant="filled"
label="Max use count"
description="The maximum number of times this reverse share link can be used"
{...form.getInputProps("maxUseCount")}
/>
{showSendEmailNotificationOption && ( {showSendEmailNotificationOption && (
<Switch <Switch
mt="xs" mt="xs"

View File

@@ -1,13 +1,17 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import configService from "../services/config.service"; import configService from "../services/config.service";
import Config from "../types/config.type"; import { ConfigHook } from "../types/config.type";
export const ConfigContext = createContext<Config[] | null>(null); export const ConfigContext = createContext<ConfigHook>({
configVariables: [],
refresh: async () => {},
});
const useConfig = () => { const useConfig = () => {
const configVariables = useContext(ConfigContext) as Config[]; const configContext = useContext(ConfigContext);
return { return {
get: (key: string) => configService.get(key, configVariables), get: (key: string) => configService.get(key, configContext.configVariables),
refresh: async () => configContext.refresh(),
}; };
}; };

View File

@@ -3,7 +3,7 @@ import { UserHook } from "../types/user.type";
export const UserContext = createContext<UserHook>({ export const UserContext = createContext<UserHook>({
user: null, user: null,
setUser: () => {}, refreshUser: async () => null,
}); });
const useUser = () => { const useUser = () => {

125
frontend/src/middleware.ts Normal file
View File

@@ -0,0 +1,125 @@
import jwtDecode from "jwt-decode";
import { NextRequest, NextResponse } from "next/server";
import configService from "./services/config.service";
// This middleware redirects based on different conditions:
// - Authentication state
// - Setup status
// - Admin privileges
export const config = {
matcher: "/((?!api|static|.*\\..*|_next).*)",
};
export async function middleware(request: NextRequest) {
const routes = {
unauthenticated: new Routes(["/auth/*", "/"]),
public: new Routes(["/share/*", "/upload/*"]),
setupStatusRegistered: new Routes(["/auth/*", "/admin/setup"]),
admin: new Routes(["/admin/*"]),
account: new Routes(["/account/*"]),
disabled: new Routes([]),
};
// Get config from backend
const config = await (
await fetch("http://localhost:8080/api/configs")
).json();
const getConfig = (key: string) => {
return configService.get(key, config);
};
const route = request.nextUrl.pathname;
let user: { isAdmin: boolean } | null = null;
const accessToken = request.cookies.get("access_token")?.value;
try {
const claims = jwtDecode<{ exp: number; isAdmin: boolean }>(
accessToken as string
);
if (claims.exp * 1000 > Date.now()) {
user = claims;
}
} catch {
user = null;
}
if (!getConfig("ALLOW_REGISTRATION")) {
routes.disabled.routes.push("/auth/signUp");
}
if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) {
routes.public.routes = ["*"];
}
if (!getConfig("SMTP_ENABLED")) {
routes.disabled.routes.push("/auth/resetPassword*");
}
// prettier-ignore
const rules = [
// Disabled routes
{
condition: routes.disabled.contains(route),
path: "/",
},
// Setup status
{
condition: getConfig("SETUP_STATUS") == "STARTED" && route != "/auth/signUp",
path: "/auth/signUp",
},
{
condition: getConfig("SETUP_STATUS") == "REGISTERED" && !routes.setupStatusRegistered.contains(route),
path: user ? "/admin/setup" : "/auth/signIn",
},
// Authenticated state
{
condition: user && routes.unauthenticated.contains(route) && !getConfig("ALLOW_UNAUTHENTICATED_SHARES"),
path: "/upload",
},
// Unauthenticated state
{
condition: !user && !routes.public.contains(route) && !routes.unauthenticated.contains(route),
path: "/auth/signIn",
},
{
condition: !user && routes.account.contains(route),
path: "/upload",
},
// Admin privileges
{
condition: routes.admin.contains(route) && !user?.isAdmin,
path: "/upload",
},
// Home page
{
condition: (!getConfig("SHOW_HOME_PAGE") || user) && route == "/",
path: "/upload",
},
];
for (const rule of rules) {
if (rule.condition) {
let { path } = rule;
if (path == "/auth/signIn") {
path = path + "?redirect=" + encodeURIComponent(route);
}
return NextResponse.redirect(new URL(path, request.url));
}
}
}
// Helper class to check if a route matches a list of routes
class Routes {
// eslint-disable-next-line no-unused-vars
constructor(public routes: string[]) {}
contains(_route: string) {
for (const route of this.routes) {
if (new RegExp("^" + route.replace(/\*/g, ".*") + "$").test(_route))
return true;
}
return false;
}
}

View File

@@ -2,14 +2,15 @@ import {
ColorScheme, ColorScheme,
ColorSchemeProvider, ColorSchemeProvider,
Container, Container,
LoadingOverlay,
MantineProvider, MantineProvider,
} from "@mantine/core"; } from "@mantine/core";
import { useColorScheme } from "@mantine/hooks"; import { useColorScheme } from "@mantine/hooks";
import { ModalsProvider } from "@mantine/modals"; import { ModalsProvider } from "@mantine/modals";
import { NotificationsProvider } from "@mantine/notifications"; import { NotificationsProvider } from "@mantine/notifications";
import axios from "axios";
import { getCookie, setCookie } from "cookies-next";
import { GetServerSidePropsContext } from "next";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Header from "../components/navBar/NavBar"; import Header from "../components/navBar/NavBar";
import { ConfigContext } from "../hooks/config.hook"; import { ConfigContext } from "../hooks/config.hook";
@@ -22,57 +23,38 @@ import GlobalStyle from "../styles/global.style";
import globalStyle from "../styles/mantine.style"; import globalStyle from "../styles/mantine.style";
import Config from "../types/config.type"; import Config from "../types/config.type";
import { CurrentUser } from "../types/user.type"; import { CurrentUser } from "../types/user.type";
import { GlobalLoadingContext } from "../utils/loading.util";
function App({ Component, pageProps }: AppProps) { function App({ Component, pageProps }: AppProps) {
const systemTheme = useColorScheme(); const systemTheme = useColorScheme(pageProps.colorScheme);
const router = useRouter(); const [colorScheme, setColorScheme] = useState<ColorScheme>(systemTheme);
const preferences = usePreferences(); const preferences = usePreferences();
const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<CurrentUser | null>(null);
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
const getInitalData = async () => { const [user, setUser] = useState<CurrentUser | null>(pageProps.user);
setIsLoading(true);
setConfigVariables(await configService.list()); const [configVariables, setConfigVariables] = useState<Config[]>(
await authService.refreshAccessToken(); pageProps.configVariables
setUser(await userService.getCurrentUser()); );
setIsLoading(false);
};
useEffect(() => { useEffect(() => {
setInterval(async () => await authService.refreshAccessToken(), 30 * 1000); setInterval(async () => await authService.refreshAccessToken(), 30 * 1000);
getInitalData();
}, []); }, []);
// Redirect to setup page if setup is not completed
useEffect(() => { useEffect(() => {
if ( const colorScheme =
configVariables &&
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
) {
const setupStatus = configVariables.filter(
(variable) => variable.key == "SETUP_STATUS"
)[0].value;
if (setupStatus == "STARTED") {
router.replace("/auth/signUp");
} else if (user && setupStatus == "REGISTERED") {
router.replace("/admin/setup");
} else if (setupStatus == "REGISTERED") {
router.replace("/auth/signIn");
}
}
}, [configVariables, router.asPath]);
useEffect(() => {
setColorScheme(
preferences.get("colorScheme") == "system" preferences.get("colorScheme") == "system"
? systemTheme ? systemTheme
: preferences.get("colorScheme") : preferences.get("colorScheme");
);
toggleColorScheme(colorScheme);
}, [systemTheme]); }, [systemTheme]);
const toggleColorScheme = (value: ColorScheme) => {
setColorScheme(value ?? "light");
setCookie("mantine-color-scheme", value ?? "light", {
sameSite: "lax",
});
};
return ( return (
<MantineProvider <MantineProvider
withGlobalStyles withGlobalStyles
@@ -81,26 +63,35 @@ function App({ Component, pageProps }: AppProps) {
> >
<ColorSchemeProvider <ColorSchemeProvider
colorScheme={colorScheme} colorScheme={colorScheme}
toggleColorScheme={(value) => setColorScheme(value ?? "light")} toggleColorScheme={toggleColorScheme}
> >
<GlobalStyle /> <GlobalStyle />
<NotificationsProvider> <NotificationsProvider>
<ModalsProvider> <ModalsProvider>
<GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}> <ConfigContext.Provider
{isLoading ? ( value={{
<LoadingOverlay visible overlayOpacity={1} /> configVariables,
) : ( refresh: async () => {
<ConfigContext.Provider value={configVariables}> setConfigVariables(await configService.list());
<UserContext.Provider value={{ user, setUser }}> },
<LoadingOverlay visible={isLoading} overlayOpacity={1} /> }}
<Header /> >
<Container> <UserContext.Provider
<Component {...pageProps} /> value={{
</Container> user,
</UserContext.Provider> refreshUser: async () => {
</ConfigContext.Provider> const user = await userService.getCurrentUser();
)} setUser(user);
</GlobalLoadingContext.Provider> return user;
},
}}
>
<Header />
<Container>
<Component {...pageProps} />
</Container>
</UserContext.Provider>
</ConfigContext.Provider>
</ModalsProvider> </ModalsProvider>
</NotificationsProvider> </NotificationsProvider>
</ColorSchemeProvider> </ColorSchemeProvider>
@@ -108,4 +99,33 @@ function App({ Component, pageProps }: AppProps) {
); );
} }
// Fetch user and config variables on server side when the first request is made
// These will get passed as a page prop to the App component and stored in the contexts
App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
let pageProps: {
user?: CurrentUser;
configVariables?: Config[];
colorScheme: ColorScheme;
} = {
colorScheme:
(getCookie("mantine-color-scheme", ctx) as ColorScheme) ?? "light",
};
if (ctx.req) {
const cookieHeader = ctx.req.headers.cookie;
pageProps.user = await axios(`http://localhost:8080/api/users/me`, {
headers: { cookie: cookieHeader },
})
.then((res) => res.data)
.catch(() => null);
pageProps.configVariables = (
await axios(`http://localhost:8080/api/configs`)
).data;
}
return { pageProps };
};
export default App; export default App;

View File

@@ -13,7 +13,6 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { useRouter } from "next/router";
import { Tb2Fa } from "react-icons/tb"; import { Tb2Fa } from "react-icons/tb";
import * as yup from "yup"; import * as yup from "yup";
import showEnableTotpModal from "../../components/account/showEnableTotpModal"; import showEnableTotpModal from "../../components/account/showEnableTotpModal";
@@ -25,9 +24,8 @@ import userService from "../../services/user.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
const Account = () => { const Account = () => {
const { user, setUser } = useUser(); const { user, refreshUser } = useUser();
const modals = useModals(); const modals = useModals();
const router = useRouter();
const accountForm = useForm({ const accountForm = useForm({
initialValues: { initialValues: {
@@ -83,13 +81,6 @@ const Account = () => {
), ),
}); });
const refreshUser = async () => setUser(await userService.getCurrentUser());
if (!user) {
router.push("/");
return;
}
return ( return (
<> <>
<Meta title="My account" /> <Meta title="My account" />
@@ -171,7 +162,7 @@ const Account = () => {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="totp" pt="xs"> <Tabs.Panel value="totp" pt="xs">
{user.totpVerified ? ( {user!.totpVerified ? (
<> <>
<form <form
onSubmit={disableTotpForm.onSubmit((values) => { onSubmit={disableTotpForm.onSubmit((values) => {

View File

@@ -1,4 +1,5 @@
import { import {
Accordion,
ActionIcon, ActionIcon,
Box, Box,
Button, Button,
@@ -13,7 +14,6 @@ import {
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import moment from "moment"; import moment from "moment";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TbInfoCircle, TbLink, TbPlus, TbTrash } from "react-icons/tb"; import { TbInfoCircle, TbLink, TbPlus, TbTrash } from "react-icons/tb";
import showShareLinkModal from "../../components/account/showShareLinkModal"; import showShareLinkModal from "../../components/account/showShareLinkModal";
@@ -21,7 +21,6 @@ import CenterLoader from "../../components/core/CenterLoader";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import showCreateReverseShareModal from "../../components/share/modals/showCreateReverseShareModal"; import showCreateReverseShareModal from "../../components/share/modals/showCreateReverseShareModal";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
import { MyReverseShare } from "../../types/share.type"; import { MyReverseShare } from "../../types/share.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util"; import { byteToHumanSizeString } from "../../utils/fileSize.util";
@@ -30,10 +29,8 @@ import toast from "../../utils/toast.util";
const MyShares = () => { const MyShares = () => {
const modals = useModals(); const modals = useModals();
const clipboard = useClipboard(); const clipboard = useClipboard();
const router = useRouter();
const config = useConfig();
const { user } = useUser(); const config = useConfig();
const [reverseShares, setReverseShares] = useState<MyReverseShare[]>(); const [reverseShares, setReverseShares] = useState<MyReverseShare[]>();
@@ -47,154 +44,168 @@ const MyShares = () => {
getReverseShares(); getReverseShares();
}, []); }, []);
if (!user) { if (!reverseShares) return <CenterLoader />;
router.replace("/"); return (
} else { <>
if (!reverseShares) return <CenterLoader />; <Meta title="My shares" />
return ( <Group position="apart" align="baseline" mb={20}>
<> <Group align="center" spacing={3} mb={30}>
<Meta title="My shares" /> <Title order={3}>My reverse shares</Title>
<Group position="apart" align="baseline" mb={20}> <Tooltip
<Group align="center" spacing={3} mb={30}> position="bottom"
<Title order={3}>My reverse shares</Title> multiline
<Tooltip width={220}
position="bottom" label="A reverse share allows you to generate a unique URL that allows external users to create a share."
multiline events={{ hover: true, focus: false, touch: true }}
width={220}
label="A reverse share allows you to generate a unique URL for a single-use share for an external user."
events={{ hover: true, focus: false, touch: true }}
>
<ActionIcon>
<TbInfoCircle />
</ActionIcon>
</Tooltip>
</Group>
<Button
onClick={() =>
showCreateReverseShareModal(
modals,
config.get("SMTP_ENABLED"),
getReverseShares
)
}
leftIcon={<TbPlus size={20} />}
> >
Create <ActionIcon>
</Button> <TbInfoCircle />
</ActionIcon>
</Tooltip>
</Group> </Group>
{reverseShares.length == 0 ? ( <Button
<Center style={{ height: "70vh" }}> onClick={() =>
<Stack align="center" spacing={10}> showCreateReverseShareModal(
<Title order={3}>It's empty here 👀</Title> modals,
<Text>You don't have any reverse shares.</Text> config.get("SMTP_ENABLED"),
</Stack> getReverseShares
</Center> )
) : ( }
<Box sx={{ display: "block", overflowX: "auto" }}> leftIcon={<TbPlus size={20} />}
<Table> >
<thead> Create
<tr> </Button>
<th>Name</th> </Group>
<th>Visitors</th> {reverseShares.length == 0 ? (
<th>Max share size</th> <Center style={{ height: "70vh" }}>
<th>Expires at</th> <Stack align="center" spacing={10}>
<th></th> <Title order={3}>It's empty here 👀</Title>
</tr> <Text>You don't have any reverse shares.</Text>
</thead> </Stack>
<tbody> </Center>
{reverseShares.map((reverseShare) => ( ) : (
<tr key={reverseShare.id}> <Box sx={{ display: "block", overflowX: "auto" }}>
<td> <Table>
{reverseShare.share ? ( <thead>
reverseShare.share?.id <tr>
) : ( <th>Shares</th>
<Text color="dimmed">No share created yet</Text> <th>Remaining uses</th>
)} <th>Max share size</th>
</td> <th>Expires at</th>
<td>{reverseShare.share?.views ?? "0"}</td> <th></th>
<td> </tr>
{byteToHumanSizeString( </thead>
parseInt(reverseShare.maxShareSize) <tbody>
)} {reverseShares.map((reverseShare) => (
</td> <tr key={reverseShare.id}>
<td> <td style={{ width: 220 }}>
{moment(reverseShare.shareExpiration).unix() === 0 {reverseShare.shares.length == 0 ? (
? "Never" <Text color="dimmed" size="sm">
: moment(reverseShare.shareExpiration).format("LLL")} No shares created yet
</td> </Text>
<td> ) : (
<Group position="right"> <Accordion>
{reverseShare.share && ( <Accordion.Item
<ActionIcon value="customization"
color="victoria" sx={{ borderBottom: "none" }}
variant="light"
size={25}
onClick={() => {
if (window.isSecureContext) {
clipboard.copy(
`${config.get("APP_URL")}/share/${
reverseShare.share!.id
}`
);
toast.success(
"The share link was copied to the keyboard."
);
} else {
showShareLinkModal(
modals,
reverseShare.share!.id,
config.get("APP_URL")
);
}
}}
>
<TbLink />
</ActionIcon>
)}
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => {
modals.openConfirmModal({
title: `Delete reverse share`,
children: (
<Text size="sm">
Do you really want to delete this reverse
share? If you do, the share will be deleted as
well.
</Text>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Confirm", cancel: "Cancel" },
onConfirm: () => {
shareService.removeReverseShare(
reverseShare.id
);
setReverseShares(
reverseShares.filter(
(item) => item.id !== reverseShare.id
)
);
},
});
}}
> >
<TbTrash /> <Accordion.Control p={0}>
</ActionIcon> <Text size="sm">
</Group> {`${reverseShare.shares.length} share${
</td> reverseShare.shares.length > 1 ? "s" : ""
</tr> }`}
))} </Text>
</tbody> </Accordion.Control>
</Table> <Accordion.Panel>
</Box> {reverseShare.shares.map((share) => (
)} <Group key={share.id} mb={4}>
</> <Text maw={120} truncate>
); {share.id}
} </Text>
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
if (window.isSecureContext) {
clipboard.copy(
`${config.get("APP_URL")}/share/${
share.id
}`
);
toast.success(
"The share link was copied to the keyboard."
);
} else {
showShareLinkModal(
modals,
share.id,
config.get("APP_URL")
);
}
}}
>
<TbLink />
</ActionIcon>
</Group>
))}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</td>
<td>{reverseShare.remainingUses}</td>
<td>
{byteToHumanSizeString(parseInt(reverseShare.maxShareSize))}
</td>
<td>
{moment(reverseShare.shareExpiration).unix() === 0
? "Never"
: moment(reverseShare.shareExpiration).format("LLL")}
</td>
<td>
<Group position="right">
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => {
modals.openConfirmModal({
title: `Delete reverse share`,
children: (
<Text size="sm">
Do you really want to delete this reverse share?
If you do, the associated shares will be deleted
as well.
</Text>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Delete", cancel: "Cancel" },
onConfirm: () => {
shareService.removeReverseShare(reverseShare.id);
setReverseShares(
reverseShares.filter(
(item) => item.id !== reverseShare.id
)
);
},
});
}}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
)}
</>
);
}; };
export default MyShares; export default MyShares;

View File

@@ -4,7 +4,6 @@ import {
Button, Button,
Center, Center,
Group, Group,
LoadingOverlay,
Space, Space,
Stack, Stack,
Table, Table,
@@ -15,13 +14,12 @@ import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import moment from "moment"; import moment from "moment";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TbLink, TbTrash } from "react-icons/tb"; import { TbLink, TbTrash } from "react-icons/tb";
import showShareLinkModal from "../../components/account/showShareLinkModal"; import showShareLinkModal from "../../components/account/showShareLinkModal";
import CenterLoader from "../../components/core/CenterLoader";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
import { MyShare } from "../../types/share.type"; import { MyShare } from "../../types/share.type";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
@@ -29,122 +27,116 @@ import toast from "../../utils/toast.util";
const MyShares = () => { const MyShares = () => {
const modals = useModals(); const modals = useModals();
const clipboard = useClipboard(); const clipboard = useClipboard();
const router = useRouter();
const config = useConfig(); const config = useConfig();
const { user } = useUser();
const [shares, setShares] = useState<MyShare[]>(); const [shares, setShares] = useState<MyShare[]>();
useEffect(() => { useEffect(() => {
shareService.getMyShares().then((shares) => setShares(shares)); shareService.getMyShares().then((shares) => setShares(shares));
}, []); }, []);
if (!user) { if (!shares) return <CenterLoader />;
router.replace("/");
} else { return (
if (!shares) return <LoadingOverlay visible />; <>
return ( <Meta title="My shares" />
<> <Title mb={30} order={3}>
<Meta title="My shares" /> My shares
<Title mb={30} order={3}> </Title>
My shares {shares.length == 0 ? (
</Title> <Center style={{ height: "70vh" }}>
{shares.length == 0 ? ( <Stack align="center" spacing={10}>
<Center style={{ height: "70vh" }}> <Title order={3}>It's empty here 👀</Title>
<Stack align="center" spacing={10}> <Text>You don't have any shares.</Text>
<Title order={3}>It's empty here 👀</Title> <Space h={5} />
<Text>You don't have any shares.</Text> <Button component={Link} href="/upload" variant="light">
<Space h={5} /> Create one
<Button component={Link} href="/upload" variant="light"> </Button>
Create one </Stack>
</Button> </Center>
</Stack> ) : (
</Center> <Box sx={{ display: "block", overflowX: "auto" }}>
) : ( <Table>
<Box sx={{ display: "block", overflowX: "auto" }}> <thead>
<Table> <tr>
<thead> <th>Name</th>
<tr> <th>Visitors</th>
<th>Name</th> <th>Expires at</th>
<th>Visitors</th> <th></th>
<th>Expires at</th> </tr>
<th></th> </thead>
<tbody>
{shares.map((share) => (
<tr key={share.id}>
<td>{share.id}</td>
<td>{share.views}</td>
<td>
{moment(share.expiration).unix() === 0
? "Never"
: moment(share.expiration).format("LLL")}
</td>
<td>
<Group position="right">
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
if (window.isSecureContext) {
clipboard.copy(
`${config.get("APP_URL")}/share/${share.id}`
);
toast.success(
"Your link was copied to the keyboard."
);
} else {
showShareLinkModal(
modals,
share.id,
config.get("APP_URL")
);
}
}}
>
<TbLink />
</ActionIcon>
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => {
modals.openConfirmModal({
title: `Delete share ${share.id}`,
children: (
<Text size="sm">
Do you really want to delete this share?
</Text>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Confirm", cancel: "Cancel" },
onConfirm: () => {
shareService.remove(share.id);
setShares(
shares.filter((item) => item.id !== share.id)
);
},
});
}}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{shares.map((share) => ( </Table>
<tr key={share.id}> </Box>
<td>{share.id}</td> )}
<td>{share.views}</td> </>
<td> );
{moment(share.expiration).unix() === 0
? "Never"
: moment(share.expiration).format("LLL")}
</td>
<td>
<Group position="right">
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
if (window.isSecureContext) {
clipboard.copy(
`${config.get("APP_URL")}/share/${share.id}`
);
toast.success(
"Your link was copied to the keyboard."
);
} else {
showShareLinkModal(
modals,
share.id,
config.get("APP_URL")
);
}
}}
>
<TbLink />
</ActionIcon>
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => {
modals.openConfirmModal({
title: `Delete share ${share.id}`,
children: (
<Text size="sm">
Do you really want to delete this share?
</Text>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Confirm", cancel: "Cancel" },
onConfirm: () => {
shareService.remove(share.id);
setShares(
shares.filter((item) => item.id !== share.id)
);
},
});
}}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
)}
</>
);
}
}; };
export default MyShares; export default MyShares;

View File

@@ -1,25 +1,10 @@
import { Box, Stack, Text, Title } from "@mantine/core"; import { Box, Stack, Text, Title } from "@mantine/core";
import { useRouter } from "next/router";
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable"; import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
import Logo from "../../components/Logo"; import Logo from "../../components/Logo";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
const Setup = () => { const Setup = () => {
const router = useRouter();
const config = useConfig();
const { user } = useUser();
if (!user) {
router.push("/auth/signUp");
return;
} else if (config.get("SETUP_STATUS") == "FINISHED") {
router.push("/");
return;
}
return ( return (
<> <>
<Meta title="Setup" /> <Meta title="Setup" />

View File

@@ -0,0 +1,81 @@
import {
Button,
Container,
createStyles,
Group,
Paper,
PasswordInput,
Text,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { useRouter } from "next/router";
import * as yup from "yup";
import authService from "../../../services/auth.service";
import toast from "../../../utils/toast.util";
const useStyles = createStyles((theme) => ({
control: {
[theme.fn.smallerThan("xs")]: {
width: "100%",
},
},
}));
const ResetPassword = () => {
const { classes } = useStyles();
const router = useRouter();
const form = useForm({
initialValues: {
password: "",
},
validate: yupResolver(
yup.object().shape({
password: yup.string().min(8).required(),
})
),
});
const resetPasswordToken = router.query.resetPasswordToken as string;
return (
<Container size={460} my={30}>
<Title order={2} weight={900} align="center">
Reset password
</Title>
<Text color="dimmed" size="sm" align="center">
Enter your new password
</Text>
<Paper withBorder shadow="md" p={30} radius="md" mt="xl">
<form
onSubmit={form.onSubmit((values) => {
console.log(resetPasswordToken);
authService
.resetPassword(resetPasswordToken, values.password)
.then(() => {
toast.success("Your password has been reset successfully.");
router.push("/auth/signIn");
})
.catch(toast.axiosError);
})}
>
<PasswordInput
label="New password"
placeholder="••••••••••"
{...form.getInputProps("password")}
/>
<Group position="right" mt="lg">
<Button type="submit" className={classes.control}>
Reset password
</Button>
</Group>
</form>
</Paper>
</Container>
);
};
export default ResetPassword;

View File

@@ -0,0 +1,107 @@
import {
Anchor,
Box,
Button,
Center,
Container,
createStyles,
Group,
Paper,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import Link from "next/link";
import { useRouter } from "next/router";
import { TbArrowLeft } from "react-icons/tb";
import * as yup from "yup";
import authService from "../../../services/auth.service";
import toast from "../../../utils/toast.util";
const useStyles = createStyles((theme) => ({
title: {
fontSize: 26,
fontWeight: 900,
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
},
controls: {
[theme.fn.smallerThan("xs")]: {
flexDirection: "column-reverse",
},
},
control: {
[theme.fn.smallerThan("xs")]: {
width: "100%",
textAlign: "center",
},
},
}));
const ResetPassword = () => {
const { classes } = useStyles();
const router = useRouter();
const form = useForm({
initialValues: {
email: "",
},
validate: yupResolver(
yup.object().shape({
email: yup.string().email().required(),
})
),
});
return (
<Container size={460} my={30}>
<Title order={2} weight={900} align="center">
Forgot your password?
</Title>
<Text color="dimmed" size="sm" align="center">
Enter your email to get a reset link
</Text>
<Paper withBorder shadow="md" p={30} radius="md" mt="xl">
<form
onSubmit={form.onSubmit((values) =>
authService
.requestResetPassword(values.email)
.then(() => {
toast.success("The email has been sent.");
router.push("/auth/signIn");
})
.catch(toast.axiosError)
)}
>
<TextInput
label="Your email"
placeholder="Your email"
{...form.getInputProps("email")}
/>
<Group position="apart" mt="lg" className={classes.controls}>
<Anchor
component={Link}
color="dimmed"
size="sm"
className={classes.control}
href={"/auth/signIn"}
>
<Center inline>
<TbArrowLeft size={12} />
<Box ml={5}>Back to login page</Box>
</Center>
</Anchor>
<Button type="submit" className={classes.control}>
Reset password
</Button>
</Group>
</form>
</Paper>
</Container>
);
};
export default ResetPassword;

View File

@@ -1,20 +1,42 @@
import { LoadingOverlay } from "@mantine/core";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import SignInForm from "../../components/auth/SignInForm"; import SignInForm from "../../components/auth/SignInForm";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
const SignIn = () => { export function getServerSideProps(context: GetServerSidePropsContext) {
const { user } = useUser(); return {
props: { redirectPath: context.query.redirect ?? null },
};
}
const SignIn = ({ redirectPath }: { redirectPath?: string }) => {
const { refreshUser } = useUser();
const router = useRouter(); const router = useRouter();
if (user) {
router.replace("/"); const [isLoading, setIsLoading] = useState(redirectPath ? true : false);
} else {
return ( // If the access token is expired, the middleware redirects to this page.
<> // If the refresh token is still valid, the user will be redirected to the last page.
<Meta title="Sign In" /> useEffect(() => {
<SignInForm /> refreshUser().then((user) => {
</> if (user) {
); router.replace(redirectPath ?? "/upload");
} } else {
setIsLoading(false);
}
});
}, []);
if (isLoading) return <LoadingOverlay overlayOpacity={1} visible />;
return (
<>
<Meta title="Sign In" />
<SignInForm redirectPath={redirectPath ?? "/upload"} />
</>
);
}; };
export default SignIn; export default SignIn;

View File

@@ -1,24 +1,12 @@
import { useRouter } from "next/router";
import SignUpForm from "../../components/auth/SignUpForm"; import SignUpForm from "../../components/auth/SignUpForm";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
const SignUp = () => { const SignUp = () => {
const config = useConfig(); return (
const { user } = useUser(); <>
const router = useRouter(); <Meta title="Sign Up" />
if (user) { <SignUpForm />
router.replace("/"); </>
} else if (!config.get("ALLOW_REGISTRATION")) { );
router.replace("/auth/signIn");
} else {
return (
<>
<Meta title="Sign Up" />
<SignUpForm />
</>
);
}
}; };
export default SignUp; export default SignUp;

View File

@@ -11,9 +11,9 @@ import {
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react";
import { TbCheck } from "react-icons/tb"; import { TbCheck } from "react-icons/tb";
import Meta from "../components/Meta"; import Meta from "../components/Meta";
import useConfig from "../hooks/config.hook";
import useUser from "../hooks/user.hook"; import useUser from "../hooks/user.hook";
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
@@ -69,94 +69,96 @@ const useStyles = createStyles((theme) => ({
})); }));
export default function Home() { export default function Home() {
const config = useConfig();
const { user } = useUser();
const { classes } = useStyles(); const { classes } = useStyles();
const { refreshUser } = useUser();
const router = useRouter(); const router = useRouter();
if (user || config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
router.replace("/upload");
} else if (!config.get("SHOW_HOME_PAGE")) {
router.replace("/auth/signIn");
} else {
return (
<>
<Meta title="Home" />
<Container>
<div className={classes.inner}>
<div className={classes.content}>
<Title className={classes.title}>
A <span className={classes.highlight}>self-hosted</span> <br />{" "}
file sharing platform.
</Title>
<Text color="dimmed" mt="md">
Do you really want to give your personal files in the hand of
third parties like WeTransfer?
</Text>
<List // If the user is already logged in, redirect to the upload page
mt={30} useEffect(() => {
spacing="sm" refreshUser().then((user) => {
size="sm" if (user) {
icon={ router.replace("/upload");
<ThemeIcon size={20} radius="xl"> }
<TbCheck size={12} /> });
</ThemeIcon> }, []);
}
return (
<>
<Meta title="Home" />
<Container>
<div className={classes.inner}>
<div className={classes.content}>
<Title className={classes.title}>
A <span className={classes.highlight}>self-hosted</span> <br />{" "}
file sharing platform.
</Title>
<Text color="dimmed" mt="md">
Do you really want to give your personal files in the hand of
third parties like WeTransfer?
</Text>
<List
mt={30}
spacing="sm"
size="sm"
icon={
<ThemeIcon size={20} radius="xl">
<TbCheck size={12} />
</ThemeIcon>
}
>
<List.Item>
<div>
<b>Self-Hosted</b> - Host Pingvin Share on your own machine.
</div>
</List.Item>
<List.Item>
<div>
<b>Privacy</b> - Your files are your files and should never
get into the hands of third parties.
</div>
</List.Item>
<List.Item>
<div>
<b>No annoying file size limit</b> - Upload as big files as
you want. Only your hard drive will be your limit.
</div>
</List.Item>
</List>
<Group mt={30}>
<Button
component={Link}
href="/auth/signUp"
radius="xl"
size="md"
className={classes.control}
> >
<List.Item> Get started
<div> </Button>
<b>Self-Hosted</b> - Host Pingvin Share on your own machine. <Button
</div> component={Link}
</List.Item> href="https://github.com/stonith404/pingvin-share"
<List.Item> target="_blank"
<div> variant="default"
<b>Privacy</b> - Your files are your files and should never radius="xl"
get into the hands of third parties. size="md"
</div> className={classes.control}
</List.Item> >
<List.Item> Source code
<div> </Button>
<b>No annoying file size limit</b> - Upload as big files as
you want. Only your hard drive will be your limit.
</div>
</List.Item>
</List>
<Group mt={30}>
<Button
component={Link}
href="/auth/signUp"
radius="xl"
size="md"
className={classes.control}
>
Get started
</Button>
<Button
component={Link}
href="https://github.com/stonith404/pingvin-share"
target="_blank"
variant="default"
radius="xl"
size="md"
className={classes.control}
>
Source code
</Button>
</Group>
</div>
<Group className={classes.image} align="center">
<Image
src="/img/logo.svg"
alt="Pingvin Share Logo"
width={200}
height={200}
/>
</Group> </Group>
</div> </div>
</Container> <Group className={classes.image} align="center">
</> <Image
); src="/img/logo.svg"
} alt="Pingvin Share Logo"
width={200}
height={200}
/>
</Group>
</div>
</Container>
</>
);
} }

View File

@@ -2,8 +2,6 @@ import { Button, Group } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { cleanNotifications } from "@mantine/notifications"; import { cleanNotifications } from "@mantine/notifications";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { getCookie } from "cookies-next";
import { useRouter } from "next/router";
import pLimit from "p-limit"; import pLimit from "p-limit";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
@@ -30,7 +28,6 @@ const Upload = ({
maxShareSize?: number; maxShareSize?: number;
isReverseShare: boolean; isReverseShare: boolean;
}) => { }) => {
const router = useRouter();
const modals = useModals(); const modals = useModals();
const { user } = useUser(); const { user } = useUser();
@@ -158,51 +155,42 @@ const Upload = ({
} }
}, [files]); }, [files]);
if ( return (
!user && <>
!config.get("ALLOW_UNAUTHENTICATED_SHARES") && <Meta title="Upload" />
!getCookie("reverse_share_token") <Group position="right" mb={20}>
) { <Button
router.replace("/"); loading={isUploading}
return null; disabled={files.length <= 0}
} else { onClick={() => {
return ( showCreateUploadModal(
<> modals,
<Meta title="Upload" /> {
<Group position="right" mb={20}> isUserSignedIn: user ? true : false,
<Button isReverseShare,
loading={isUploading} appUrl: config.get("APP_URL"),
disabled={files.length <= 0} allowUnauthenticatedShares: config.get(
onClick={() => { "ALLOW_UNAUTHENTICATED_SHARES"
showCreateUploadModal( ),
modals, enableEmailRecepients: config.get(
{ "ENABLE_SHARE_EMAIL_RECIPIENTS"
isUserSignedIn: user ? true : false, ),
isReverseShare, },
appUrl: config.get("APP_URL"), uploadFiles
allowUnauthenticatedShares: config.get( );
"ALLOW_UNAUTHENTICATED_SHARES" }}
), >
enableEmailRecepients: config.get( Share
"ENABLE_SHARE_EMAIL_RECIPIENTS" </Button>
), </Group>
}, <Dropzone
uploadFiles maxShareSize={maxShareSize}
); files={files}
}} setFiles={setFiles}
> isUploading={isUploading}
Share />
</Button> {files.length > 0 && <FileList files={files} setFiles={setFiles} />}
</Group> </>
<Dropzone );
maxShareSize={maxShareSize}
files={files}
setFiles={setFiles}
isUploading={isUploading}
/>
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
</>
);
}
}; };
export default Upload; export default Upload;

View File

@@ -60,6 +60,14 @@ const refreshAccessToken = async () => {
} }
}; };
const requestResetPassword = async (email: string) => {
await api.post(`/auth/resetPassword/${email}`);
};
const resetPassword = async (token: string, password: string) => {
await api.post("/auth/resetPassword", { token, password });
};
const updatePassword = async (oldPassword: string, password: string) => { const updatePassword = async (oldPassword: string, password: string) => {
await api.patch("/auth/password", { oldPassword, password }); await api.patch("/auth/password", { oldPassword, password });
}; };
@@ -95,6 +103,8 @@ export default {
signOut, signOut,
refreshAccessToken, refreshAccessToken,
updatePassword, updatePassword,
requestResetPassword,
resetPassword,
enableTOTP, enableTOTP,
verifyTOTP, verifyTOTP,
disableTOTP, disableTOTP,

View File

@@ -99,12 +99,14 @@ const uploadFile = async (
const createReverseShare = async ( const createReverseShare = async (
shareExpiration: string, shareExpiration: string,
maxShareSize: number, maxShareSize: number,
maxUseCount: number,
sendEmailNotification: boolean sendEmailNotification: boolean
) => { ) => {
return ( return (
await api.post("reverseShares", { await api.post("reverseShares", {
shareExpiration, shareExpiration,
maxShareSize: maxShareSize.toString(), maxShareSize: maxShareSize.toString(),
maxUseCount,
sendEmailNotification, sendEmailNotification,
}) })
).data; ).data;

View File

@@ -29,4 +29,9 @@ export type AdminConfigGroupedByCategory = {
]; ];
}; };
export type ConfigHook = {
configVariables: Config[];
refresh: () => void;
};
export default Config; export default Config;

View File

@@ -31,7 +31,8 @@ export type MyReverseShare = {
id: string; id: string;
maxShareSize: string; maxShareSize: string;
shareExpiration: Date; shareExpiration: Date;
share?: MyShare; remainingUses: number;
shares: MyShare[];
}; };
export type ShareSecurity = { export type ShareSecurity = {

View File

@@ -29,7 +29,7 @@ export type CurrentUser = User & {};
export type UserHook = { export type UserHook = {
user: CurrentUser | null; user: CurrentUser | null;
setUser: (user: CurrentUser | null) => void; refreshUser: () => Promise<CurrentUser | null>;
}; };
export default User; export default User;

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share", "name": "pingvin-share",
"version": "0.9.0", "version": "0.10.0",
"scripts": { "scripts": {
"format": "cd frontend && npm run format && cd ../backend && npm run format", "format": "cd frontend && npm run format && cd ../backend && npm run format",
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint", "lint": "cd frontend && npm run lint && cd ../backend && npm run lint",