Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53c7457697 | ||
|
|
1abc0f7ef3 | ||
|
|
2c3760e064 | ||
|
|
32eaee4236 | ||
|
|
99492c2ecc | ||
|
|
34db3ae2a9 | ||
|
|
32ad43ae27 | ||
|
|
0efd2d8bf9 | ||
|
|
43299522ee | ||
|
|
880ad85a1e | ||
|
|
e40cc0f48b | ||
|
|
46d667776f | ||
|
|
99de4e57e1 | ||
|
|
00d0df731b | ||
|
|
599d8caa31 | ||
|
|
aff58da3a2 | ||
|
|
63e0af3484 | ||
|
|
c2ddce6203 | ||
|
|
c6e1f07f51 | ||
|
|
c8021a42b7 | ||
|
|
d0901d497b | ||
|
|
ffdecbd32e | ||
|
|
712cfe625a | ||
|
|
c2b87aba5c | ||
|
|
e4019612f8 | ||
|
|
af6e2b61c0 | ||
|
|
7237928844 | ||
|
|
38986c971a | ||
|
|
84d29dff68 | ||
|
|
41c3bafbd7 | ||
|
|
c52a4d5e3a | ||
|
|
239b18cdae | ||
|
|
a8a56321dd |
12
.env.example
12
.env.example
@@ -1,10 +1,18 @@
|
||||
# Read what every environment variable does: https://github.com/stonith404/pingvin-share#environment-variables
|
||||
|
||||
# GENERAL
|
||||
# General
|
||||
APP_URL=http://localhost:3000
|
||||
SHOW_HOME_PAGE=true
|
||||
ALLOW_REGISTRATION=true
|
||||
ALLOW_UNAUTHENTICATED_SHARES=false
|
||||
MAX_FILE_SIZE=1000000000
|
||||
|
||||
# SECURITY
|
||||
# Security
|
||||
JWT_SECRET=long-random-string
|
||||
|
||||
# Email
|
||||
EMAIL_RECIPIENTS_ENABLED=false
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_EMAIL=pingvin-share@example.com
|
||||
SMTP_PASSWORD=example
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -1,4 +1,59 @@
|
||||
## 0.0.1 (2022-10-17)
|
||||
## [0.2.0](https://github.com/stonith404/pingvin-share/compare/v0.1.1...v0.2.0) (2022-11-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add email recepients functionality ([32ad43a](https://github.com/stonith404/pingvin-share/commit/32ad43ae27a29b946bfba0040cac7eb158c84553))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add public userDTO to prevent confusion ([0efd2d8](https://github.com/stonith404/pingvin-share/commit/0efd2d8bf96506cf7d7dc2dc3164a8d59009cec7))
|
||||
* email sending when not signed in ([32eaee4](https://github.com/stonith404/pingvin-share/commit/32eaee42363250defa92913c738a2702ba3e2693))
|
||||
* hide and disallow email recipients if disabled ([34db3ae](https://github.com/stonith404/pingvin-share/commit/34db3ae2a997498edaa70404807d0e770dad6edb))
|
||||
|
||||
### [0.1.1](https://github.com/stonith404/pingvin-share/compare/v0.1.0...v0.1.1) (2022-10-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add `ALLOW_UNAUTHENTICATED_SHARES` to docker compose file ([599d8ca](https://github.com/stonith404/pingvin-share/commit/599d8caa31dc018c14c959d6602ac652eaef5da2))
|
||||
* only log jobs if they actually did something ([e40cc0f](https://github.com/stonith404/pingvin-share/commit/e40cc0f48beec09e18738de1b445cabd9daab09b))
|
||||
* share finishes before all files are uploaded ([99de4e5](https://github.com/stonith404/pingvin-share/commit/99de4e57e18df54596e168a57b94c55d7a834472))
|
||||
|
||||
## [0.1.0](https://github.com/stonith404/pingvin-share/compare/v0.0.1...v0.1.0) (2022-10-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add rate limiting ([712cfe6](https://github.com/stonith404/pingvin-share/commit/712cfe625a19dc9790cda5fbc2843fed0836b860))
|
||||
* allow unauthenticated uploads ([84d29df](https://github.com/stonith404/pingvin-share/commit/84d29dff68d0ea9d76d9a35f9fb7dff95d3dda1b))
|
||||
* **frontend:** remove footer ([c52a4d5](https://github.com/stonith404/pingvin-share/commit/c52a4d5e3ad717a10d15b7fe1dbf359b041c0976))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* infinite loading when file size is small ([c2ddce6](https://github.com/stonith404/pingvin-share/commit/c2ddce62038e561d292f23fc6089562e64f1ffe9))
|
||||
* only show not signed in warning if not signed in ([c6e1f07](https://github.com/stonith404/pingvin-share/commit/c6e1f07f51e9cdd914bb70fb19dd81b90a470563))
|
||||
* opt out of static site generation to enable `publicRuntimeConfig` ([239b18c](https://github.com/stonith404/pingvin-share/commit/239b18cdae6367322bcdacb6b2bbaa1028295cc4))
|
||||
* visitor count doesn't get incremented ([c8021a4](https://github.com/stonith404/pingvin-share/commit/c8021a42b7fb094e587325bf855fc3133b6b96b0))
|
||||
|
||||
### [0.0.1](https://github.com/stonith404/pingvin-share/compare/4bab33ad8a79302fd94c6d92a3ddf87cdff8b214...v0.0.1) (2022-10-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `linux/arm/v7` arch for docker image ([d9e5c28](https://github.com/stonith404/pingvin-share/commit/d9e5c286e3b53834276511227f219d0858ca0829))
|
||||
* add progress indicator for uploading files ([8c84d50](https://github.com/stonith404/pingvin-share/commit/8c84d50159bdabc75a1199ffdf372b9586f67371))
|
||||
* Added "never" expiration date ([56349c6](https://github.com/stonith404/pingvin-share/commit/56349c6f4cc739d07bcf8ad862b0868e09342883))
|
||||
* automatically detect hour format ([4e3f6be](https://github.com/stonith404/pingvin-share/commit/4e3f6be8e322929b83a35c7789078260dca9eb58))
|
||||
* extract logo to component ([58efc48](https://github.com/stonith404/pingvin-share/commit/58efc48ffa559b4bfa03e381bccb552c8fb830b9))
|
||||
* improve share security ([6358ac3](https://github.com/stonith404/pingvin-share/commit/6358ac3918d1af1cc05aca634d9d32a8f35d251f))
|
||||
* put db and uploads in same folder ([80cdcda](https://github.com/stonith404/pingvin-share/commit/80cdcda93c385a8f5c1e22c7b84740f5d8119ef1))
|
||||
* remove appwrite and add nextjs backend ([4bab33a](https://github.com/stonith404/pingvin-share/commit/4bab33ad8a79302fd94c6d92a3ddf87cdff8b214))
|
||||
* remove postgres & use a single docker container ([388ac39](https://github.com/stonith404/pingvin-share/commit/388ac395ba85aae8a91ddfb5f5637a80a3e6f16b))
|
||||
* replace tooltip with toast ([a33b5b3](https://github.com/stonith404/pingvin-share/commit/a33b5b37d92071e643a0bf78a9d6ecf29bebc65a))
|
||||
* use system color theme ([d902aae](https://github.com/stonith404/pingvin-share/commit/d902aae03ff33d39c733cf1bce88ae58ff4cd888))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
@@ -28,19 +83,3 @@
|
||||
* upload volume path ([7522221](https://github.com/stonith404/pingvin-share/commit/7522221ee163cb0bd6144e7b924c77065f223fb9))
|
||||
* wrong environment configuration for `ALLOW_REGISTRATION` ([759db40](https://github.com/stonith404/pingvin-share/commit/759db40ac9f42ff71a795ceec521a7f9531d71c9))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `linux/arm/v7` arch for docker image ([d9e5c28](https://github.com/stonith404/pingvin-share/commit/d9e5c286e3b53834276511227f219d0858ca0829))
|
||||
* add progress indicator for uploading files ([8c84d50](https://github.com/stonith404/pingvin-share/commit/8c84d50159bdabc75a1199ffdf372b9586f67371))
|
||||
* automatically detect hour format ([4e3f6be](https://github.com/stonith404/pingvin-share/commit/4e3f6be8e322929b83a35c7789078260dca9eb58))
|
||||
* extract logo to component ([58efc48](https://github.com/stonith404/pingvin-share/commit/58efc48ffa559b4bfa03e381bccb552c8fb830b9))
|
||||
* improve share security ([6358ac3](https://github.com/stonith404/pingvin-share/commit/6358ac3918d1af1cc05aca634d9d32a8f35d251f))
|
||||
* put db and uploads in same folder ([80cdcda](https://github.com/stonith404/pingvin-share/commit/80cdcda93c385a8f5c1e22c7b84740f5d8119ef1))
|
||||
* remove appwrite and add nextjs backend ([4bab33a](https://github.com/stonith404/pingvin-share/commit/4bab33ad8a79302fd94c6d92a3ddf87cdff8b214))
|
||||
* remove postgres & use a single docker container ([388ac39](https://github.com/stonith404/pingvin-share/commit/388ac395ba85aae8a91ddfb5f5637a80a3e6f16b))
|
||||
* replace tooltip with toast ([a33b5b3](https://github.com/stonith404/pingvin-share/commit/a33b5b37d92071e643a0bf78a9d6ecf29bebc65a))
|
||||
* use system color theme ([d902aae](https://github.com/stonith404/pingvin-share/commit/d902aae03ff33d39c733cf1bce88ae58ff4cd888))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -31,4 +31,4 @@ WORKDIR /opt/app
|
||||
RUN npm i -g dotenv-cli
|
||||
|
||||
EXPOSE 3000
|
||||
CMD cd frontend && dotenv -e .env.development node_modules/.bin/next start & cd backend && npm run prod
|
||||
CMD cd frontend && dotenv node_modules/.bin/next start & cd backend && npm run prod
|
||||
@@ -14,6 +14,7 @@ Demo: https://pingvin-share.dev.eliasschneider.com
|
||||
- No file size limit, only your disk will be your limit
|
||||
- Set a share expiration
|
||||
- Optionally secure your share with a visitor limit and a password
|
||||
- Email recepients
|
||||
- Light & dark mode
|
||||
|
||||
## ⌨️ Setup
|
||||
@@ -29,16 +30,20 @@ The website is now listening available on `http://localhost:3000`, have fun with
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description | Possible values |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------- | --------------- |
|
||||
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------- |
|
||||
| `APP_URL` | On which URL Pingvin Share is available. E.g http://localhost or https://pingvin-share.com. | URL |
|
||||
| `SHOW_HOME_PAGE` | Whether the Pingvin Share home page should be shown. | true/false |
|
||||
| `ALLOW_REGISTRATION` | Whether a new user can create a new account. | true/false |
|
||||
| `ALLOW_UNAUTHENTICATED_SHARES` | Whether a user can create a share without being signed in. | true/false |
|
||||
| `MAX_FILE_SIZE` | Maximum allowed size per file in bytes. | Number |
|
||||
| `JWT_SECRET` | Long random string to sign the JWT's. | Random string |
|
||||
| `EMAIL_RECIPIENTS_ENABLED` | Whether email reciepients are enabled. Only set this to true if you entered the host, port, email and password of your SMTP server. | true/false |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_EMAIL`, `SMTP_PASSWORD` | Credentials for your SMTP server. | - |
|
||||
|
||||
### Upgrade to a new version
|
||||
|
||||
Just update the docker container by running `docker compose pull && docker compose up -d`
|
||||
1. Check if your local `docker-compose.yml` and `.env` files are up to date with the files in the repository
|
||||
2. Run `docker compose pull && docker compose up -d` to update your docker container
|
||||
|
||||
> Note: If you installed Pingvin Share before it used Sqlite, you unfortunately have to set up the project from scratch again, sorry for that.
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
# CONFIGURATION
|
||||
# General
|
||||
APP_URL=http://localhost:3000
|
||||
ALLOW_REGISTRATION=true
|
||||
MAX_FILE_SIZE=5000000000
|
||||
ALLOW_UNAUTHENTICATED_SHARES=false
|
||||
|
||||
# SECURITY
|
||||
# Security
|
||||
JWT_SECRET=random-string
|
||||
|
||||
# Email
|
||||
EMAIL_RECIPIENTS_ENABLED=false
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_EMAIL=pingvin-share@example.com
|
||||
SMTP_PASSWORD=example
|
||||
2364
backend/package-lock.json
generated
2364
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@
|
||||
"@nestjs/platform-express": "^9.1.2",
|
||||
"@nestjs/schedule": "^2.1.0",
|
||||
"@nestjs/swagger": "^6.1.2",
|
||||
"@nestjs/throttler": "^3.1.0",
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.29.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -26,6 +27,7 @@
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.8.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -44,6 +46,7 @@
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.7.23",
|
||||
"@types/nodemailer": "^6.4.6",
|
||||
"@types/passport-jwt": "^3.0.7",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.40.0",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
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,
|
||||
"creatorId" TEXT,
|
||||
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Share" ("createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views") SELECT "createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views" FROM "Share";
|
||||
DROP TABLE "Share";
|
||||
ALTER TABLE "new_Share" RENAME TO "Share";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ShareRecipient" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"email" TEXT NOT NULL,
|
||||
"shareId" TEXT NOT NULL,
|
||||
CONSTRAINT "ShareRecipient_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
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,
|
||||
"creatorId" TEXT,
|
||||
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Share" ("createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views") SELECT "createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views" FROM "Share";
|
||||
DROP TABLE "Share";
|
||||
ALTER TABLE "new_Share" RENAME TO "Share";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -40,12 +40,21 @@ model Share {
|
||||
views Int @default(0)
|
||||
expiration DateTime
|
||||
|
||||
creatorId String
|
||||
creator User @relation(fields: [creatorId], references: [id])
|
||||
creatorId String?
|
||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
security ShareSecurity?
|
||||
recipients ShareRecipient[]
|
||||
files File[]
|
||||
}
|
||||
|
||||
model ShareRecipient {
|
||||
id String @id @default(uuid())
|
||||
email String
|
||||
|
||||
shareId String
|
||||
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { JobsService } from "./jobs/jobs.service";
|
||||
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { FileController } from "./file/file.controller";
|
||||
import { FileModule } from "./file/file.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
@@ -11,17 +13,30 @@ import { PrismaService } from "./prisma/prisma.service";
|
||||
import { ShareController } from "./share/share.controller";
|
||||
import { ShareModule } from "./share/share.module";
|
||||
import { UserController } from "./user/user.controller";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
ShareModule,
|
||||
FileModule,
|
||||
EmailModule,
|
||||
PrismaModule,
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ThrottlerModule.forRoot({
|
||||
ttl: 60,
|
||||
limit: 100,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
providers: [PrismaService, JobsService],
|
||||
providers: [
|
||||
PrismaService,
|
||||
JobsService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
controllers: [UserController, ShareController, FileController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
@@ -19,6 +19,7 @@ export class AuthController {
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signUp")
|
||||
signUp(@Body() dto: AuthRegisterDTO) {
|
||||
if (this.config.get("ALLOW_REGISTRATION") == "false")
|
||||
@@ -26,6 +27,7 @@ export class AuthController {
|
||||
return this.authService.signUp(dto);
|
||||
}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signIn")
|
||||
@HttpCode(200)
|
||||
signIn(@Body() dto: AuthSignInDTO) {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
export class JwtGuard extends AuthGuard("jwt") {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
try {
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
} catch {
|
||||
return process.env.ALLOW_UNAUTHENTICATED_SHARES == "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
backend/src/email/email.module.ts
Normal file
8
backend/src/email/email.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EmailService } from "./email.service";
|
||||
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
38
backend/src/email/email.service.ts
Normal file
38
backend/src/email/email.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { User } from "@prisma/client";
|
||||
import * as nodemailer from "nodemailer";
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
// create reusable transporter object using the default SMTP transport
|
||||
transporter = nodemailer.createTransport({
|
||||
host: this.config.get("SMTP_HOST"),
|
||||
port: parseInt(this.config.get("SMTP_PORT")),
|
||||
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
|
||||
auth: {
|
||||
user: this.config.get("SMTP_EMAIL"),
|
||||
pass: this.config.get("SMTP_PASSWORD"),
|
||||
},
|
||||
});
|
||||
|
||||
async sendMail(recipientEmail: string, shareId: string, creator: User) {
|
||||
if (this.config.get("EMAIL_RECIPIENTS_ENABLED") == "false")
|
||||
throw new InternalServerErrorException("Email service disabled");
|
||||
|
||||
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
||||
const creatorIdentifier = creator ?
|
||||
creator.firstName && creator.lastName
|
||||
? `${creator.firstName} ${creator.lastName}`
|
||||
: creator.email : "A Pingvin Share user";
|
||||
|
||||
await this.transporter.sendMail({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: "Files shared with you",
|
||||
text: `Hey!\n${creatorIdentifier} shared some files with you. View or dowload the files with this link: ${shareUrl}.\n Shared securely with Pingvin Share 🐧`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import * as moment from "moment";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import * as moment from "moment";
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
@@ -31,14 +31,19 @@ export class JobsService {
|
||||
await this.fileService.deleteAllFiles(expiredShare.id);
|
||||
}
|
||||
|
||||
if (expiredShares.length > 0)
|
||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
async deleteExpiredRefreshTokens() {
|
||||
const expiredShares = await this.prisma.refreshToken.deleteMany({
|
||||
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
console.log(`job: deleted ${expiredShares.count} expired refresh tokens`);
|
||||
|
||||
if (expiredRefreshTokens.count > 0)
|
||||
console.log(
|
||||
`job: deleted ${expiredRefreshTokens.count} expired refresh tokens`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||
import { NestFactory, Reflector } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import * as fs from "fs";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||
|
||||
app.set("trust proxy", true);
|
||||
|
||||
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
|
||||
@@ -12,7 +12,6 @@ export class PrismaService extends PrismaClient {
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(config.get("DB_URL"));
|
||||
super.$connect().then(() => console.info("Connected to the database"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { IsString, Length, Matches, ValidateNested } from "class-validator";
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
Length,
|
||||
Matches,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ShareSecurityDTO } from "./shareSecurity.dto";
|
||||
|
||||
export class CreateShareDTO {
|
||||
@@ -13,6 +19,9 @@ export class CreateShareDTO {
|
||||
@IsString()
|
||||
expiration: string;
|
||||
|
||||
@IsEmail({}, { each: true })
|
||||
recipients: string[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ShareSecurityDTO)
|
||||
security: ShareSecurityDTO;
|
||||
|
||||
@@ -8,6 +8,9 @@ export class MyShareDTO extends ShareDTO {
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
recipients: string[];
|
||||
|
||||
from(partial: Partial<MyShareDTO>) {
|
||||
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Expose, plainToClass, Type } from "class-transformer";
|
||||
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
|
||||
import { FileDTO } from "src/file/dto/file.dto";
|
||||
import { PublicUserDTO } from "src/user/dto/publicUser.dto";
|
||||
|
||||
export class ShareDTO {
|
||||
@Expose()
|
||||
@@ -14,8 +14,8 @@ export class ShareDTO {
|
||||
files: FileDTO[];
|
||||
|
||||
@Expose()
|
||||
@Type(() => AuthSignInDTO)
|
||||
creator: AuthSignInDTO;
|
||||
@Type(() => PublicUserDTO)
|
||||
creator: PublicUserDTO;
|
||||
|
||||
from(partial: Partial<ShareDTO>) {
|
||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||
|
||||
@@ -28,6 +28,8 @@ export class ShareOwnerGuard implements CanActivate {
|
||||
|
||||
if (!share) throw new NotFoundException("Share not found");
|
||||
|
||||
if (!share.creatorId) return true;
|
||||
|
||||
return share.creatorId == (request.user as User).id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export class ShareSecurityGuard implements CanActivate {
|
||||
"share_password_required"
|
||||
);
|
||||
|
||||
if (!this.shareService.verifyShareToken(shareId, shareToken))
|
||||
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
|
||||
throw new ForbiddenException(
|
||||
"Share token required",
|
||||
"share_token_required"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { User } from "@prisma/client";
|
||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
@@ -20,7 +21,6 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard";
|
||||
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
||||
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
||||
import { ShareService } from "./share.service";
|
||||
|
||||
@Controller("shares")
|
||||
export class ShareController {
|
||||
constructor(private shareService: ShareService) {}
|
||||
@@ -70,6 +70,7 @@ export class ShareController {
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Throttle(10, 5 * 60)
|
||||
@UseGuards(ShareTokenSecurity)
|
||||
@Post(":id/token")
|
||||
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { FileModule } from "src/file/file.module";
|
||||
import { ShareController } from "./share.controller";
|
||||
import { ShareService } from "./share.service";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), forwardRef(() => FileModule)],
|
||||
imports: [JwtModule.register({}), EmailModule, forwardRef(() => FileModule)],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as archiver from "archiver";
|
||||
import * as argon from "argon2";
|
||||
import * as fs from "fs";
|
||||
import * as moment from "moment";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
@@ -20,11 +21,12 @@ export class ShareService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private fileService: FileService,
|
||||
private emailService: EmailService,
|
||||
private config: ConfigService,
|
||||
private jwtService: JwtService
|
||||
) {}
|
||||
|
||||
async create(share: CreateShareDTO, user: User) {
|
||||
async create(share: CreateShareDTO, user?: User) {
|
||||
if (!(await this.isShareIdAvailable(share.id)).isAvailable)
|
||||
throw new BadRequestException("Share id already in use");
|
||||
|
||||
@@ -36,7 +38,7 @@ export class ShareService {
|
||||
}
|
||||
|
||||
// We have to add an exception for "never" (since moment won't like that)
|
||||
let expirationDate;
|
||||
let expirationDate: Date;
|
||||
if (share.expiration !== "never") {
|
||||
expirationDate = moment()
|
||||
.add(
|
||||
@@ -58,8 +60,13 @@ export class ShareService {
|
||||
data: {
|
||||
...share,
|
||||
expiration: expirationDate,
|
||||
creator: { connect: { id: user.id } },
|
||||
creator: { connect: user ? { id: user.id } : undefined },
|
||||
security: { create: share.security },
|
||||
recipients: {
|
||||
create: share.recipients
|
||||
? share.recipients.map((email) => ({ email }))
|
||||
: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -84,21 +91,33 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async complete(id: string) {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id },
|
||||
include: { files: true, recipients: true, creator: true },
|
||||
});
|
||||
|
||||
if (await this.isShareCompleted(id))
|
||||
throw new BadRequestException("Share already completed");
|
||||
|
||||
const moreThanOneFileInShare =
|
||||
(await this.prisma.file.findMany({ where: { shareId: id } })).length != 0;
|
||||
|
||||
if (!moreThanOneFileInShare)
|
||||
if (share.files.length == 0)
|
||||
throw new BadRequestException(
|
||||
"You need at least on file in your share to complete it."
|
||||
);
|
||||
|
||||
// Asynchronously create a zip of all files
|
||||
this.createZip(id).then(() =>
|
||||
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
|
||||
);
|
||||
|
||||
// Send email for each recepient
|
||||
for (const recepient of share.recipients) {
|
||||
await this.emailService.sendMail(
|
||||
recepient.email,
|
||||
share.id,
|
||||
share.creator
|
||||
);
|
||||
}
|
||||
|
||||
return await this.prisma.share.update({
|
||||
where: { id },
|
||||
data: { uploadLocked: true },
|
||||
@@ -106,7 +125,7 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async getSharesByUser(userId: string) {
|
||||
return await this.prisma.share.findMany({
|
||||
const shares = await this.prisma.share.findMany({
|
||||
where: {
|
||||
creator: { id: userId },
|
||||
uploadLocked: true,
|
||||
@@ -119,7 +138,17 @@ export class ShareService {
|
||||
orderBy: {
|
||||
expiration: "desc",
|
||||
},
|
||||
include: { recipients: true },
|
||||
});
|
||||
|
||||
const sharesWithEmailRecipients = shares.map((share) => {
|
||||
return {
|
||||
...share,
|
||||
recipients: share.recipients.map((recipients) => recipients.email),
|
||||
};
|
||||
});
|
||||
|
||||
return sharesWithEmailRecipients;
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
@@ -154,6 +183,8 @@ export class ShareService {
|
||||
});
|
||||
|
||||
if (!share) throw new NotFoundException("Share not found");
|
||||
if (!share.creatorId)
|
||||
throw new ForbiddenException("Anonymous shares can't be deleted");
|
||||
|
||||
await this.fileService.deleteAllFiles(shareId);
|
||||
await this.prisma.share.delete({ where: { id: shareId } });
|
||||
|
||||
4
backend/src/user/dto/publicUser.dto.ts
Normal file
4
backend/src/user/dto/publicUser.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "./user.dto";
|
||||
|
||||
export class PublicUserDTO extends PickType(UserDTO, ["email"] as const) {}
|
||||
@@ -9,7 +9,13 @@ services:
|
||||
- APP_URL=${APP_URL}
|
||||
- SHOW_HOME_PAGE=${SHOW_HOME_PAGE}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION}
|
||||
- ALLOW_UNAUTHENTICATED_SHARES=${ALLOW_UNAUTHENTICATED_SHARES}
|
||||
- MAX_FILE_SIZE=${MAX_FILE_SIZE}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- EMAIL_RECIPIENTS_ENABLED=${EMAIL_RECIPIENTS_ENABLED}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_EMAIL=${SMTP_EMAIL}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
volumes:
|
||||
- "${PWD}/data:/opt/app/backend/data"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
SHOW_HOME_PAGE=true
|
||||
ALLOW_REGISTRATION=true
|
||||
MAX_FILE_SIZE=1000000000
|
||||
ALLOW_UNAUTHENTICATED_SHARES=false
|
||||
EMAIL_RECIPIENTS_ENABLED=false
|
||||
@@ -1,25 +0,0 @@
|
||||
FROM node:18-alpine AS deps
|
||||
WORKDIR /opt/app
|
||||
|
||||
|
||||
FROM node:18-alpine AS builder
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /opt/app
|
||||
COPY . .
|
||||
COPY --from=deps /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /opt/app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /opt/app/next.config.js ./
|
||||
COPY --from=builder /opt/app/public ./public
|
||||
COPY --from=builder /opt/app/.next ./.next
|
||||
COPY --from=builder /opt/app/node_modules ./node_modules
|
||||
|
||||
RUN npm i -g dotenv-cli
|
||||
|
||||
EXPOSE 3000
|
||||
CMD dotenv -e .env.development node_modules/.bin/next start
|
||||
@@ -5,6 +5,8 @@ const nextConfig = {
|
||||
ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION,
|
||||
SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE,
|
||||
MAX_FILE_SIZE: process.env.MAX_FILE_SIZE,
|
||||
ALLOW_UNAUTHENTICATED_SHARES: process.env.ALLOW_UNAUTHENTICATED_SHARES,
|
||||
EMAIL_RECIPIENTS_ENABLED: process.env.EMAIL_RECIPIENTS_ENABLED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1698
frontend/package-lock.json
generated
1698
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,13 +22,13 @@
|
||||
"cookies-next": "^2.0.4",
|
||||
"file-saver": "^2.0.5",
|
||||
"jose": "^4.8.1",
|
||||
"moment": "^2.29.3",
|
||||
"next": "^12.3.1",
|
||||
"moment": "^2.29.4",
|
||||
"next": "^13.0.0",
|
||||
"next-cookies": "^2.0.3",
|
||||
"next-http-proxy-middleware": "^1.2.4",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "18.0.0",
|
||||
"react-dom": "18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.4.0",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
@@ -39,7 +39,7 @@
|
||||
"axios": "^0.26.1",
|
||||
"dotenv-cli": "^6.0.0",
|
||||
"eslint": "8.13.0",
|
||||
"eslint-config-next": "12.1.5",
|
||||
"eslint-config-next": "^13.0.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"prettier": "^2.7.1",
|
||||
"tar": "^6.1.11",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Anchor, Center, Footer as MFooter, Text } from "@mantine/core";
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<MFooter height="auto" p={10}>
|
||||
<Center>
|
||||
<Text size="xs" color="dimmed">
|
||||
Made with 🖤 by{" "}
|
||||
<Anchor size="xs" href="https://eliasschneider.com" target="_blank">
|
||||
Elias Schneider
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Center>
|
||||
</MFooter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import getConfig from "next/config";
|
||||
import Link from "next/link";
|
||||
import * as yup from "yup";
|
||||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
@@ -61,7 +61,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
||||
? "You have an account already?"
|
||||
: "You don't have an account yet?"}{" "}
|
||||
<Anchor
|
||||
component={NextLink}
|
||||
component={Link}
|
||||
href={mode == "signUp" ? "signIn" : "signUp"}
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Avatar, Menu } from "@mantine/core";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import Link from "next/link";
|
||||
import { TbDoorExit, TbLink } from "react-icons/tb";
|
||||
import authService from "../../services/auth.service";
|
||||
|
||||
@@ -13,7 +13,7 @@ const ActionAvatar = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
component={NextLink}
|
||||
component={Link}
|
||||
href="/account/shares"
|
||||
icon={<TbLink size={14} />}
|
||||
>
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
Transition,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import getConfig from "next/config";
|
||||
import Link from "next/link";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import Logo from "../Logo";
|
||||
@@ -22,7 +22,7 @@ const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const HEADER_HEIGHT = 60;
|
||||
|
||||
type Link = {
|
||||
type NavLink = {
|
||||
link?: string;
|
||||
label?: string;
|
||||
component?: ReactNode;
|
||||
@@ -122,7 +122,7 @@ const NavBar = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<Link[]>([
|
||||
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<NavLink[]>([
|
||||
{
|
||||
link: "/auth/signIn",
|
||||
label: "Sign in",
|
||||
@@ -161,7 +161,7 @@ const NavBar = () => {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NextLink
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.link ?? ""}
|
||||
onClick={() => toggleOpened.toggle()}
|
||||
@@ -170,7 +170,7 @@ const NavBar = () => {
|
||||
})}
|
||||
>
|
||||
{link.label}
|
||||
</NextLink>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -178,12 +178,12 @@ const NavBar = () => {
|
||||
return (
|
||||
<Header height={HEADER_HEIGHT} mb={40} className={classes.root}>
|
||||
<Container className={classes.header}>
|
||||
<NextLink href="/">
|
||||
<Link href="/" passHref>
|
||||
<Group>
|
||||
<Logo height={35} width={35} />
|
||||
<Text weight={600}>Pingvin Share</Text>
|
||||
</Group>
|
||||
</NextLink>
|
||||
</Link>
|
||||
<Group spacing={5} className={classes.links}>
|
||||
<Group>{items} </Group>
|
||||
</Group>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Grid,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import moment from "moment";
|
||||
import * as yup from "yup";
|
||||
import shareService from "../../services/share.service";
|
||||
import { ShareSecurity } from "../../types/share.type";
|
||||
|
||||
const PreviewExpiration = ({ form }: { form: any }) => {
|
||||
const value = form.values.never_expires
|
||||
? "never"
|
||||
: form.values.expiration_num + form.values.expiration_unit;
|
||||
if (value === "never") return "This share will never expire.";
|
||||
|
||||
const expirationDate = moment()
|
||||
.add(
|
||||
value.split("-")[0],
|
||||
value.split("-")[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
|
||||
return `This share will expire on ${moment(expirationDate).format("LLL")}`;
|
||||
};
|
||||
|
||||
const CreateUploadModalBody = ({
|
||||
uploadCallback,
|
||||
}: {
|
||||
uploadCallback: (
|
||||
id: string,
|
||||
expiration: string,
|
||||
security: ShareSecurity
|
||||
) => void;
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const validationSchema = yup.object().shape({
|
||||
link: yup
|
||||
.string()
|
||||
.required()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
|
||||
message: "Can only contain letters, numbers, underscores and hyphens",
|
||||
}),
|
||||
password: yup.string().min(3).max(30),
|
||||
maxViews: yup.number().min(1),
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
link: "",
|
||||
|
||||
password: undefined,
|
||||
maxViews: undefined,
|
||||
expiration_num: 1,
|
||||
expiration_unit: "-days",
|
||||
never_expires: false,
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
if (!(await shareService.isShareIdAvailable(values.link))) {
|
||||
form.setFieldError("link", "This link is already in use");
|
||||
} else {
|
||||
const expiration = form.values.never_expires
|
||||
? "never"
|
||||
: form.values.expiration_num + form.values.expiration_unit;
|
||||
uploadCallback(values.link, expiration, {
|
||||
password: values.password,
|
||||
maxViews: values.maxViews,
|
||||
});
|
||||
modals.closeAll();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Stack align="stretch">
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={9}>
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label="Link"
|
||||
placeholder="myAwesomeShare"
|
||||
{...form.getInputProps("link")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={3}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
"link",
|
||||
Buffer.from(Math.random().toString(), "utf8")
|
||||
.toString("base64")
|
||||
.substr(10, 7)
|
||||
)
|
||||
}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</Col>
|
||||
</Grid>
|
||||
|
||||
<Text
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{window.location.origin}/share/
|
||||
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
||||
</Text>
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={6}>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={99999}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label="Expiration"
|
||||
placeholder="n"
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_num")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<Select
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_unit")}
|
||||
data={[
|
||||
// Set the label to singular if the number is 1, else plural
|
||||
{
|
||||
value: "-minutes",
|
||||
label:
|
||||
"Minute" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-hours",
|
||||
label: "Hour" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-days",
|
||||
label: "Day" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-weeks",
|
||||
label: "Week" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-months",
|
||||
label: "Month" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-years",
|
||||
label: "Year" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
<Checkbox
|
||||
label="Never Expires"
|
||||
{...form.getInputProps("never_expires")}
|
||||
/>
|
||||
|
||||
{/* Preview expiration date text */}
|
||||
<Text
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{PreviewExpiration({ form })}
|
||||
</Text>
|
||||
|
||||
<Accordion>
|
||||
<Accordion.Item value="security" sx={{ borderBottom: "none" }}>
|
||||
<Accordion.Control>Security options</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack align="stretch">
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="No password"
|
||||
label="Password protection"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<NumberInput
|
||||
min={1}
|
||||
type="number"
|
||||
variant="filled"
|
||||
placeholder="No limit"
|
||||
label="Maximal views"
|
||||
{...form.getInputProps("maxViews")}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Button type="submit">Share</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUploadModalBody;
|
||||
19
frontend/src/components/upload/ExpirationPreview.tsx
Normal file
19
frontend/src/components/upload/ExpirationPreview.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import moment from "moment";
|
||||
|
||||
const ExpirationPreview = ({ form }: { form: any }) => {
|
||||
const value = form.values.never_expires
|
||||
? "never"
|
||||
: form.values.expiration_num + form.values.expiration_unit;
|
||||
if (value === "never") return "This share will never expire.";
|
||||
|
||||
const expirationDate = moment()
|
||||
.add(
|
||||
value.split("-")[0],
|
||||
value.split("-")[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
|
||||
return `This share will expire on ${moment(expirationDate).format("LLL")}`;
|
||||
};
|
||||
|
||||
export default ExpirationPreview;
|
||||
@@ -10,13 +10,10 @@ import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import moment from "moment";
|
||||
import getConfig from "next/config";
|
||||
import { useRouter } from "next/router";
|
||||
import { TbCopy } from "react-icons/tb";
|
||||
import { Share } from "../../types/share.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
import { Share } from "../../../types/share.type";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => {
|
||||
return modals.openModal({
|
||||
287
frontend/src/components/upload/modals/showCreateUploadModal.tsx
Normal file
287
frontend/src/components/upload/modals/showCreateUploadModal.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Grid,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import getConfig from "next/config";
|
||||
import { useState } from "react";
|
||||
import { TbAlertCircle } from "react-icons/tb";
|
||||
import * as yup from "yup";
|
||||
import shareService from "../../../services/share.service";
|
||||
import { ShareSecurity } from "../../../types/share.type";
|
||||
import ExpirationPreview from "../ExpirationPreview";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const showCreateUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
isSignedIn: boolean,
|
||||
uploadCallback: (
|
||||
id: string,
|
||||
expiration: string,
|
||||
recipients: string[],
|
||||
security: ShareSecurity
|
||||
) => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Share</Title>,
|
||||
children: (
|
||||
<CreateUploadModalBody
|
||||
isSignedIn={isSignedIn}
|
||||
uploadCallback={uploadCallback}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const CreateUploadModalBody = ({
|
||||
uploadCallback,
|
||||
isSignedIn,
|
||||
}: {
|
||||
uploadCallback: (
|
||||
id: string,
|
||||
expiration: string,
|
||||
recipients: string[],
|
||||
security: ShareSecurity
|
||||
) => void;
|
||||
isSignedIn: boolean;
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
|
||||
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(
|
||||
publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true"
|
||||
);
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
link: yup
|
||||
.string()
|
||||
.required()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
|
||||
message: "Can only contain letters, numbers, underscores and hyphens",
|
||||
}),
|
||||
password: yup.string().min(3).max(30),
|
||||
maxViews: yup.number().min(1),
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
link: "",
|
||||
recipients: [] as string[],
|
||||
password: undefined,
|
||||
maxViews: undefined,
|
||||
expiration_num: 1,
|
||||
expiration_unit: "-days",
|
||||
never_expires: false,
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
return (
|
||||
<Group>
|
||||
{showNotSignedInAlert && !isSignedIn && (
|
||||
<Alert
|
||||
withCloseButton
|
||||
onClose={() => setShowNotSignedInAlert(false)}
|
||||
icon={<TbAlertCircle size={16} />}
|
||||
title="You're not signed in"
|
||||
color="yellow"
|
||||
>
|
||||
You will be unable to delete your share manually and view the visitor
|
||||
count.
|
||||
</Alert>
|
||||
)}
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
if (!(await shareService.isShareIdAvailable(values.link))) {
|
||||
form.setFieldError("link", "This link is already in use");
|
||||
} else {
|
||||
const expiration = form.values.never_expires
|
||||
? "never"
|
||||
: form.values.expiration_num + form.values.expiration_unit;
|
||||
uploadCallback(values.link, expiration, values.recipients, {
|
||||
password: values.password,
|
||||
maxViews: values.maxViews,
|
||||
});
|
||||
modals.closeAll();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Stack align="stretch">
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={9}>
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label="Link"
|
||||
placeholder="myAwesomeShare"
|
||||
{...form.getInputProps("link")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={3}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
"link",
|
||||
Buffer.from(Math.random().toString(), "utf8")
|
||||
.toString("base64")
|
||||
.substr(10, 7)
|
||||
)
|
||||
}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</Col>
|
||||
</Grid>
|
||||
|
||||
<Text
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{window.location.origin}/share/
|
||||
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
||||
</Text>
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={6}>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={99999}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label="Expiration"
|
||||
placeholder="n"
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_num")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<Select
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_unit")}
|
||||
data={[
|
||||
// Set the label to singular if the number is 1, else plural
|
||||
{
|
||||
value: "-minutes",
|
||||
label:
|
||||
"Minute" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-hours",
|
||||
label:
|
||||
"Hour" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-days",
|
||||
label: "Day" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-weeks",
|
||||
label:
|
||||
"Week" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-months",
|
||||
label:
|
||||
"Month" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-years",
|
||||
label:
|
||||
"Year" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
<Checkbox
|
||||
label="Never Expires"
|
||||
{...form.getInputProps("never_expires")}
|
||||
/>
|
||||
{/* Preview expiration date text */}
|
||||
<Text
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{ExpirationPreview({ form })}
|
||||
</Text>
|
||||
<Accordion>
|
||||
{publicRuntimeConfig.EMAIL_RECIPIENTS_ENABLED == "true" && (
|
||||
<Accordion.Item value="recipients" sx={{ borderBottom: "none" }}>
|
||||
<Accordion.Control>Email recipients</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<MultiSelect
|
||||
data={form.values.recipients}
|
||||
placeholder="Enter email recipients"
|
||||
searchable
|
||||
{...form.getInputProps("recipients")}
|
||||
creatable
|
||||
getCreateLabel={(query) => `+ ${query}`}
|
||||
onCreate={(query) => {
|
||||
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
||||
form.setFieldError(
|
||||
"recipients",
|
||||
"Invalid email address"
|
||||
);
|
||||
} else {
|
||||
form.setFieldError("recipients", null);
|
||||
form.setFieldValue("recipients", [
|
||||
...form.values.recipients,
|
||||
query,
|
||||
]);
|
||||
return query;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
<Accordion.Item value="security" sx={{ borderBottom: "none" }}>
|
||||
<Accordion.Control>Security options</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack align="stretch">
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="No password"
|
||||
label="Password protection"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<NumberInput
|
||||
min={1}
|
||||
type="number"
|
||||
variant="filled"
|
||||
placeholder="No limit"
|
||||
label="Maximal views"
|
||||
{...form.getInputProps("maxViews")}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Button type="submit">Share</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default showCreateUploadModal;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Title } from "@mantine/core";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { ShareSecurity } from "../../types/share.type";
|
||||
import CreateUploadModalBody from "../share/CreateUploadModalBody";
|
||||
|
||||
const showCreateUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
uploadCallback: (
|
||||
id: string,
|
||||
expiration: string,
|
||||
security: ShareSecurity
|
||||
) => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Share</Title>,
|
||||
children: <CreateUploadModalBody uploadCallback={uploadCallback} />,
|
||||
});
|
||||
};
|
||||
|
||||
export default showCreateUploadModal;
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import Meta from "../components/Meta";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import Link from "next/link";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
@@ -53,7 +53,7 @@ const ErrorNotFound = () => {
|
||||
className={classes.description}
|
||||
></Text>
|
||||
<Group position="center">
|
||||
<Button component={NextLink} href="/" variant="light">
|
||||
<Button component={Link} href="/" variant="light">
|
||||
Bring me back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -3,14 +3,12 @@ import {
|
||||
Container,
|
||||
LoadingOverlay,
|
||||
MantineProvider,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import { useColorScheme } from "@mantine/hooks";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { NotificationsProvider } from "@mantine/notifications";
|
||||
import type { AppProps } from "next/app";
|
||||
import { useEffect, useState } from "react";
|
||||
import Footer from "../components/Footer";
|
||||
import Header from "../components/navBar/NavBar";
|
||||
import { UserContext } from "../hooks/user.hook";
|
||||
import authService from "../services/auth.service";
|
||||
@@ -58,15 +56,10 @@ function App({ Component, pageProps }: AppProps) {
|
||||
) : (
|
||||
<UserContext.Provider value={user}>
|
||||
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
||||
<Stack justify="space-between" sx={{ minHeight: "100vh" }}>
|
||||
<div>
|
||||
<Header />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</div>
|
||||
<Footer />
|
||||
</Stack>
|
||||
</UserContext.Provider>
|
||||
)}
|
||||
</GlobalLoadingContext.Provider>
|
||||
@@ -76,4 +69,9 @@ function App({ Component, pageProps }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Opts out of static site generation to use publicRuntimeConfig
|
||||
App.getInitialProps = () => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -12,9 +12,8 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import moment from "moment";
|
||||
import getConfig from "next/config";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbLink, TbTrash } from "react-icons/tb";
|
||||
@@ -24,8 +23,6 @@ import shareService from "../../services/share.service";
|
||||
import { MyShare } from "../../types/share.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const MyShares = () => {
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
@@ -54,7 +51,7 @@ const MyShares = () => {
|
||||
<Title order={3}>It's empty here 👀</Title>
|
||||
<Text>You don't have any shares.</Text>
|
||||
<Space h={5} />
|
||||
<Button component={NextLink} href="/upload" variant="light">
|
||||
<Button component={Link} href="/upload" variant="light">
|
||||
Create one
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -8,9 +8,11 @@ export const config = {
|
||||
},
|
||||
};
|
||||
|
||||
// This function can be marked `async` if using `await` inside
|
||||
export default (req: NextApiRequest, res: NextApiResponse) =>
|
||||
httpProxyMiddleware(req, res, {
|
||||
// You can use the `http-proxy` option
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return httpProxyMiddleware(req, res, {
|
||||
headers: {
|
||||
"X-Forwarded-For": req.socket.remoteAddress ?? "",
|
||||
},
|
||||
target: "http://localhost:8080",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import getConfig from "next/config";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { TbCheck } from "react-icons/tb";
|
||||
import Meta from "../components/Meta";
|
||||
import useUser from "../hooks/user.hook";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
@@ -74,7 +75,7 @@ export default function Home() {
|
||||
|
||||
const { classes } = useStyles();
|
||||
const router = useRouter();
|
||||
if (user) {
|
||||
if (user || publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true") {
|
||||
router.replace("/upload");
|
||||
} else if (publicRuntimeConfig.SHOW_HOME_PAGE == "false") {
|
||||
router.replace("/auth/signIn");
|
||||
@@ -125,7 +126,7 @@ export default function Home() {
|
||||
|
||||
<Group mt={30}>
|
||||
<Button
|
||||
component={NextLink}
|
||||
component={Link}
|
||||
href="/auth/signUp"
|
||||
radius="xl"
|
||||
size="md"
|
||||
@@ -134,7 +135,7 @@ export default function Home() {
|
||||
Get started
|
||||
</Button>
|
||||
<Button
|
||||
component={NextLink}
|
||||
component={Link}
|
||||
href="https://github.com/stonith404/pingvin-share"
|
||||
target="_blank"
|
||||
variant="default"
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import axios from "axios";
|
||||
import getConfig from "next/config";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Meta from "../components/Meta";
|
||||
import Dropzone from "../components/upload/Dropzone";
|
||||
import FileList from "../components/upload/FileList";
|
||||
import showCompletedUploadModal from "../components/upload/showCompletedUploadModal";
|
||||
import showCreateUploadModal from "../components/upload/showCreateUploadModal";
|
||||
import showCompletedUploadModal from "../components/upload/modals/showCompletedUploadModal";
|
||||
import showCreateUploadModal from "../components/upload/modals/showCreateUploadModal";
|
||||
import useUser from "../hooks/user.hook";
|
||||
import shareService from "../services/share.service";
|
||||
import { FileUpload } from "../types/File.type";
|
||||
import { ShareSecurity } from "../types/share.type";
|
||||
import toast from "../utils/toast.util";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
let share: any;
|
||||
|
||||
const Upload = () => {
|
||||
const router = useRouter();
|
||||
const modals = useModals();
|
||||
@@ -25,6 +29,7 @@ const Upload = () => {
|
||||
const uploadFiles = async (
|
||||
id: string,
|
||||
expiration: string,
|
||||
recipients: string[],
|
||||
security: ShareSecurity
|
||||
) => {
|
||||
setisUploading(true);
|
||||
@@ -35,15 +40,13 @@ const Upload = () => {
|
||||
return file;
|
||||
})
|
||||
);
|
||||
const share = await shareService.create(id, expiration, security);
|
||||
share = await shareService.create(id, expiration, recipients, security);
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const progressCallBack = (bytesProgress: number) => {
|
||||
const progressCallBack = (progress: number) => {
|
||||
setFiles((files) => {
|
||||
return files.map((file, callbackIndex) => {
|
||||
if (i == callbackIndex) {
|
||||
file.uploadingProgress = Math.round(
|
||||
(100 * bytesProgress) / files[i].size
|
||||
);
|
||||
file.uploadingProgress = progress;
|
||||
}
|
||||
return file;
|
||||
});
|
||||
@@ -55,27 +58,6 @@ const Upload = () => {
|
||||
} catch {
|
||||
files[i].uploadingProgress = -1;
|
||||
}
|
||||
|
||||
if (
|
||||
files.every(
|
||||
(file) =>
|
||||
file.uploadingProgress >= 100 || file.uploadingProgress == -1
|
||||
)
|
||||
) {
|
||||
const fileErrorCount = files.filter(
|
||||
(file) => file.uploadingProgress == -1
|
||||
).length;
|
||||
setisUploading(false);
|
||||
if (fileErrorCount > 0) {
|
||||
toast.error(
|
||||
`${fileErrorCount} file(s) failed to upload. Try again.`
|
||||
);
|
||||
} else {
|
||||
await shareService.completeShare(share.id);
|
||||
showCompletedUploadModal(modals, share);
|
||||
setFiles([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
@@ -86,7 +68,34 @@ const Upload = () => {
|
||||
setisUploading(false);
|
||||
}
|
||||
};
|
||||
if (!user) {
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
files.length > 0 &&
|
||||
files.every(
|
||||
(file) => file.uploadingProgress >= 100 || file.uploadingProgress == -1
|
||||
)
|
||||
) {
|
||||
const fileErrorCount = files.filter(
|
||||
(file) => file.uploadingProgress == -1
|
||||
).length;
|
||||
setisUploading(false);
|
||||
if (fileErrorCount > 0) {
|
||||
toast.error(`${fileErrorCount} file(s) failed to upload. Try again.`);
|
||||
} else {
|
||||
shareService
|
||||
.completeShare(share.id)
|
||||
.then(() => {
|
||||
showCompletedUploadModal(modals, share);
|
||||
setFiles([]);
|
||||
})
|
||||
.catch(() =>
|
||||
toast.error("An error occured while finishing your share.")
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [files]);
|
||||
if (!user && publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "false") {
|
||||
router.replace("/");
|
||||
} else {
|
||||
return (
|
||||
@@ -96,7 +105,9 @@ const Upload = () => {
|
||||
<Button
|
||||
loading={isUploading}
|
||||
disabled={files.length <= 0}
|
||||
onClick={() => showCreateUploadModal(modals, uploadFiles)}
|
||||
onClick={() =>
|
||||
showCreateUploadModal(modals, user ? true : false, uploadFiles)
|
||||
}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
|
||||
@@ -9,9 +9,11 @@ import api from "./api.service";
|
||||
const create = async (
|
||||
id: string,
|
||||
expiration: string,
|
||||
recipients: string[],
|
||||
security?: ShareSecurity
|
||||
) => {
|
||||
return (await api.post("shares", { id, expiration, security })).data;
|
||||
return (await api.post("shares", { id, expiration, recipients, security }))
|
||||
.data;
|
||||
};
|
||||
|
||||
const completeShare = async (id: string) => {
|
||||
@@ -75,12 +77,16 @@ const uploadFile = async (
|
||||
let formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
return (
|
||||
await api.post(`shares/${shareId}/files`, formData, {
|
||||
onUploadProgress: (progressEvent) =>
|
||||
progressCallBack(progressEvent.loaded),
|
||||
})
|
||||
).data;
|
||||
const response = await api.post(`shares/${shareId}/files`, formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const uploadingProgress = Math.round(
|
||||
(100 * progressEvent.loaded) / progressEvent.total
|
||||
);
|
||||
if (uploadingProgress < 100) progressCallBack(uploadingProgress);
|
||||
},
|
||||
});
|
||||
progressCallBack(100);
|
||||
return response;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Global } from "@mantine/core";
|
||||
const GlobalStyle = () => {
|
||||
return (
|
||||
<Global
|
||||
styles={(theme) => ({
|
||||
styles={() => ({
|
||||
a: {
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "pingvin-share",
|
||||
"version": "0.0.1",
|
||||
"version": "0.2.0",
|
||||
"scripts": {
|
||||
"format": "cd frontend && npm run format && cd ../backend && npm run format",
|
||||
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
|
||||
"version": "conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md",
|
||||
"version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s && git add CHANGELOG.md",
|
||||
"release:patch": "npm version patch -m 'release: %s' && git push && git push --tags",
|
||||
"release:minor": "npm version minor -m 'release: %s' && git push && git push --tags"
|
||||
"release:minor": "npm version minor -m 'release: %s' && git push && git push --tags",
|
||||
"deploy:dev": "docker buildx build --push --tag stonith404/pingvin-share:development --platform linux/amd64,linux/arm64 ."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user