Compare commits

...

33 Commits

Author SHA1 Message Date
Elias Schneider
53c7457697 release: 0.2.0 2022-11-13 23:27:51 +01:00
Elias Schneider
1abc0f7ef3 chore: migrate database for release 2022-11-13 23:24:59 +01:00
Elias Schneider
2c3760e064 chore: add smtp environment variables to docker compose 2022-11-13 23:08:51 +01:00
Elias Schneider
32eaee4236 fix: email sending when not signed in 2022-11-13 23:08:25 +01:00
Elias Schneider
99492c2ecc docs: add SMTP variables to readme 2022-11-13 22:39:04 +01:00
Elias Schneider
34db3ae2a9 fix: hide and disallow email recipients if disabled 2022-11-11 19:03:08 +01:00
Elias Schneider
32ad43ae27 feat: add email recepients functionality 2022-11-11 15:12:16 +01:00
Elias Schneider
0efd2d8bf9 fix: add public userDTO to prevent confusion 2022-11-10 13:50:52 +01:00
Elias Schneider
43299522ee chore: upgrade to Next.js 13 2022-10-31 11:20:54 +01:00
Elias Schneider
880ad85a1e release: 0.1.1 2022-10-31 10:34:55 +01:00
Elias Schneider
e40cc0f48b fix: only log jobs if they actually did something 2022-10-31 10:33:27 +01:00
Elias Schneider
46d667776f chore: add dev deployment script 2022-10-31 10:28:46 +01:00
Elias Schneider
99de4e57e1 fix: share finishes before all files are uploaded 2022-10-31 10:28:29 +01:00
Elias Schneider
00d0df731b docs: improve upgrade instruction 2022-10-30 13:24:50 +01:00
Elias Schneider
599d8caa31 fix: add ALLOW_UNAUTHENTICATED_SHARES to docker compose file 2022-10-30 00:25:49 +02:00
Elias Schneider
aff58da3a2 release: 0.1.0 2022-10-29 23:12:32 +02:00
Elias Schneider
63e0af3484 refactor: run formatter 2022-10-29 23:12:29 +02:00
Elias Schneider
c2ddce6203 fix: infinite loading when file size is small 2022-10-29 23:09:28 +02:00
Elias Schneider
c6e1f07f51 fix: only show not signed in warning if not signed in 2022-10-29 22:55:46 +02:00
Elias Schneider
c8021a42b7 fix: visitor count doesn't get incremented 2022-10-29 22:49:30 +02:00
Elias Schneider
d0901d497b ix: jwt guard when unauthenticated shares are allowed 2022-10-29 22:48:00 +02:00
Elias Schneider
ffdecbd32e Revert "fix: jwt guard when unauthenticated shares are allowed"
This reverts commit c2b87aba5c.
2022-10-29 22:47:23 +02:00
Elias Schneider
712cfe625a feat: add rate limiting 2022-10-24 12:11:10 +02:00
Elias Schneider
c2b87aba5c fix: jwt guard when unauthenticated shares are allowed 2022-10-24 09:25:45 +02:00
Elias Schneider
e4019612f8 Merge pull request #18 from stonith404/feat/allow-unauthenticated-shares
feat: allow unauthenticated shares
2022-10-24 09:15:28 +02:00
Elias Schneider
af6e2b61c0 docs: add ALLOW_UNAUTHENTICATED_SHARES to README 2022-10-24 09:12:12 +02:00
Elias Schneider
7237928844 chore: create database migration 2022-10-18 14:32:40 +02:00
Elias Schneider
38986c971a refactor: run formatter 2022-10-18 14:30:41 +02:00
Elias Schneider
84d29dff68 feat: allow unauthenticated uploads 2022-10-18 14:27:14 +02:00
Elias Schneider
41c3bafbd7 chore: upgrade packages to fix vulnerabilities 2022-10-18 09:43:13 +02:00
Elias Schneider
c52a4d5e3a feat(frontend): remove footer 2022-10-18 09:31:13 +02:00
Elias Schneider
239b18cdae fix: opt out of static site generation to enable publicRuntimeConfig 2022-10-18 09:17:57 +02:00
Elias Schneider
a8a56321dd chore: change changelog generator to conventionalcommits 2022-10-18 09:12:11 +02:00
51 changed files with 1629 additions and 3580 deletions

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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
@@ -28,17 +29,21 @@ 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 |
| `MAX_FILE_SIZE` | Maximum allowed size per file in bytes. | Number |
| `JWT_SECRET` | Long random string to sign the JWT's. | Random string |
| 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.

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -40,10 +40,19 @@ model Share {
views Int @default(0)
expiration DateTime
creatorId String
creator User @relation(fields: [creatorId], references: [id])
security ShareSecurity?
files File[]
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 {

View File

@@ -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 {}

View File

@@ -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) {

View File

@@ -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";
}
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { EmailService } from "./email.service";
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View 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 🐧`,
});
}
}

View File

@@ -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);
}
console.log(`job: deleted ${expiredShares.length} expired shares`);
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`
);
}
}

View File

@@ -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");

View File

@@ -12,7 +12,6 @@ export class PrismaService extends PrismaClient {
},
},
});
console.log(config.get("DB_URL"));
super.$connect().then(() => console.info("Connected to the database"));
}
}

View File

@@ -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;

View File

@@ -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 });
}

View File

@@ -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 });

View File

@@ -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;
}
}

View File

@@ -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"

View File

@@ -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) {

View File

@@ -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],

View File

@@ -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 } });

View File

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

View File

@@ -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"

View File

@@ -1,3 +1,5 @@
SHOW_HOME_PAGE=true
ALLOW_REGISTRATION=true
MAX_FILE_SIZE=1000000000
ALLOW_UNAUTHENTICATED_SHARES=false
EMAIL_RECIPIENTS_ENABLED=false

View File

@@ -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

View File

@@ -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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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;

View File

@@ -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"
>

View File

@@ -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} />}
>

View File

@@ -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>

View File

@@ -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;

View 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;

View File

@@ -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({

View 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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>
<Header />
<Container>
<Component {...pageProps} />
</Container>
</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;

View File

@@ -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>

View File

@@ -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",
});
};

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 {

View File

@@ -3,7 +3,7 @@ import { Global } from "@mantine/core";
const GlobalStyle = () => {
return (
<Global
styles={(theme) => ({
styles={() => ({
a: {
color: "inherit",
textDecoration: "none",

View File

@@ -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 ."
}
}