Compare commits

...

9 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
34 changed files with 1031 additions and 824 deletions

View File

@@ -1,11 +1,18 @@
# Read what every environment variable does: https://github.com/stonith404/pingvin-share#environment-variables # Read what every environment variable does: https://github.com/stonith404/pingvin-share#environment-variables
# GENERAL # General
APP_URL=http://localhost:3000 APP_URL=http://localhost:3000
SHOW_HOME_PAGE=true SHOW_HOME_PAGE=true
ALLOW_REGISTRATION=true ALLOW_REGISTRATION=true
ALLOW_UNAUTHENTICATED_SHARES=false ALLOW_UNAUTHENTICATED_SHARES=false
MAX_FILE_SIZE=1000000000 MAX_FILE_SIZE=1000000000
# SECURITY # Security
JWT_SECRET=long-random-string 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,3 +1,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) ### [0.1.1](https://github.com/stonith404/pingvin-share/compare/v0.1.0...v0.1.1) (2022-10-31)

View File

@@ -14,6 +14,7 @@ Demo: https://pingvin-share.dev.eliasschneider.com
- No file size limit, only your disk will be your limit - No file size limit, only your disk will be your limit
- Set a share expiration - Set a share expiration
- Optionally secure your share with a visitor limit and a password - Optionally secure your share with a visitor limit and a password
- Email recepients
- Light & dark mode - Light & dark mode
## ⌨️ Setup ## ⌨️ Setup
@@ -28,14 +29,16 @@ The website is now listening available on `http://localhost:3000`, have fun with
### Environment variables ### Environment variables
| Variable | Description | Possible values | | Variable | Description | Possible values |
| ------------------------------ | ------------------------------------------------------------------------------------------- | --------------- | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `APP_URL` | On which URL Pingvin Share is available. E.g http://localhost or https://pingvin-share.com. | URL | | `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 | | `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_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 | | `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 | | `MAX_FILE_SIZE` | Maximum allowed size per file in bytes. | Number |
| `JWT_SECRET` | Long random string to sign the JWT's. | Random string | | `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 ### Upgrade to a new version

View File

@@ -1,8 +1,15 @@
# CONFIGURATION # General
APP_URL=http://localhost:3000 APP_URL=http://localhost:3000
ALLOW_REGISTRATION=true ALLOW_REGISTRATION=true
MAX_FILE_SIZE=5000000000 MAX_FILE_SIZE=5000000000
ALLOW_UNAUTHENTICATED_SHARES=false ALLOW_UNAUTHENTICATED_SHARES=false
# SECURITY # Security
JWT_SECRET=random-string 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

View File

@@ -25,6 +25,7 @@
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.8.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
@@ -43,6 +44,7 @@
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^18.7.23", "@types/node": "^18.7.23",
"@types/nodemailer": "^6.4.6",
"@types/passport-jwt": "^3.0.7", "@types/passport-jwt": "^3.0.7",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.40.0", "@typescript-eslint/eslint-plugin": "^5.40.0",
@@ -1214,6 +1216,15 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz",
"integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg==" "integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg=="
}, },
"node_modules/@types/nodemailer": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.6.tgz",
"integrity": "sha512-pD6fL5GQtUKvD2WnPmg5bC2e8kWCAPDwMPmHe/ohQbW+Dy0EcHgZ2oCSuPlWNqk74LS5BVMig1SymQbFMPPK3w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse-json": { "node_modules/@types/parse-json": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -5028,6 +5039,14 @@
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
"dev": true "dev": true
}, },
"node_modules/nodemailer": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.8.0.tgz",
"integrity": "sha512-EjYvSmHzekz6VNkNd12aUqAco+bOkRe3Of5jVhltqKhEsjw/y0PYPJfp83+s9Wzh1dspYAkUW/YNQ350NATbSQ==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nopt": { "node_modules/nopt": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@@ -8303,6 +8322,15 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz",
"integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg==" "integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg=="
}, },
"@types/nodemailer": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.6.tgz",
"integrity": "sha512-pD6fL5GQtUKvD2WnPmg5bC2e8kWCAPDwMPmHe/ohQbW+Dy0EcHgZ2oCSuPlWNqk74LS5BVMig1SymQbFMPPK3w==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/parse-json": { "@types/parse-json": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -11224,6 +11252,11 @@
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
"dev": true "dev": true
}, },
"nodemailer": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.8.0.tgz",
"integrity": "sha512-EjYvSmHzekz6VNkNd12aUqAco+bOkRe3Of5jVhltqKhEsjw/y0PYPJfp83+s9Wzh1dspYAkUW/YNQ350NATbSQ=="
},
"nopt": { "nopt": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",

View File

@@ -27,6 +27,7 @@
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.8.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
@@ -45,6 +46,7 @@
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^18.7.23", "@types/node": "^18.7.23",
"@types/nodemailer": "^6.4.6",
"@types/passport-jwt": "^3.0.7", "@types/passport-jwt": "^3.0.7",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.40.0", "@typescript-eslint/eslint-plugin": "^5.40.0",

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

View File

@@ -13,12 +13,14 @@ import { PrismaService } from "./prisma/prisma.service";
import { ShareController } from "./share/share.controller"; import { ShareController } from "./share/share.controller";
import { ShareModule } from "./share/share.module"; import { ShareModule } from "./share/share.module";
import { UserController } from "./user/user.controller"; import { UserController } from "./user/user.controller";
import { EmailModule } from "./email/email.module";
@Module({ @Module({
imports: [ imports: [
AuthModule, AuthModule,
ShareModule, ShareModule,
FileModule, FileModule,
EmailModule,
PrismaModule, PrismaModule,
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot({ ThrottlerModule.forRoot({

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,5 +1,11 @@
import { Type } from "class-transformer"; 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"; import { ShareSecurityDTO } from "./shareSecurity.dto";
export class CreateShareDTO { export class CreateShareDTO {
@@ -13,6 +19,9 @@ export class CreateShareDTO {
@IsString() @IsString()
expiration: string; expiration: string;
@IsEmail({}, { each: true })
recipients: string[];
@ValidateNested() @ValidateNested()
@Type(() => ShareSecurityDTO) @Type(() => ShareSecurityDTO)
security: ShareSecurityDTO; security: ShareSecurityDTO;

View File

@@ -8,6 +8,9 @@ export class MyShareDTO extends ShareDTO {
@Expose() @Expose()
createdAt: Date; createdAt: Date;
@Expose()
recipients: string[];
from(partial: Partial<MyShareDTO>) { from(partial: Partial<MyShareDTO>) {
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true }); return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
} }

View File

@@ -1,6 +1,6 @@
import { Expose, plainToClass, Type } from "class-transformer"; import { Expose, plainToClass, Type } from "class-transformer";
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
import { FileDTO } from "src/file/dto/file.dto"; import { FileDTO } from "src/file/dto/file.dto";
import { PublicUserDTO } from "src/user/dto/publicUser.dto";
export class ShareDTO { export class ShareDTO {
@Expose() @Expose()
@@ -14,8 +14,8 @@ export class ShareDTO {
files: FileDTO[]; files: FileDTO[];
@Expose() @Expose()
@Type(() => AuthSignInDTO) @Type(() => PublicUserDTO)
creator: AuthSignInDTO; creator: PublicUserDTO;
from(partial: Partial<ShareDTO>) { from(partial: Partial<ShareDTO>) {
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true }); return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });

View File

@@ -1,11 +1,12 @@
import { forwardRef, Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { EmailModule } from "src/email/email.module";
import { FileModule } from "src/file/file.module"; import { FileModule } from "src/file/file.module";
import { ShareController } from "./share.controller"; import { ShareController } from "./share.controller";
import { ShareService } from "./share.service"; import { ShareService } from "./share.service";
@Module({ @Module({
imports: [JwtModule.register({}), forwardRef(() => FileModule)], imports: [JwtModule.register({}), EmailModule, forwardRef(() => FileModule)],
controllers: [ShareController], controllers: [ShareController],
providers: [ShareService], providers: [ShareService],
exports: [ShareService], exports: [ShareService],

View File

@@ -11,6 +11,7 @@ import * as archiver from "archiver";
import * as argon from "argon2"; import * as argon from "argon2";
import * as fs from "fs"; import * as fs from "fs";
import * as moment from "moment"; import * as moment from "moment";
import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { CreateShareDTO } from "./dto/createShare.dto"; import { CreateShareDTO } from "./dto/createShare.dto";
@@ -20,6 +21,7 @@ export class ShareService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private fileService: FileService, private fileService: FileService,
private emailService: EmailService,
private config: ConfigService, private config: ConfigService,
private jwtService: JwtService private jwtService: JwtService
) {} ) {}
@@ -36,7 +38,7 @@ export class ShareService {
} }
// We have to add an exception for "never" (since moment won't like that) // We have to add an exception for "never" (since moment won't like that)
let expirationDate; let expirationDate: Date;
if (share.expiration !== "never") { if (share.expiration !== "never") {
expirationDate = moment() expirationDate = moment()
.add( .add(
@@ -60,6 +62,11 @@ export class ShareService {
expiration: expirationDate, expiration: expirationDate,
creator: { connect: user ? { id: user.id } : undefined }, creator: { connect: user ? { id: user.id } : undefined },
security: { create: share.security }, security: { create: share.security },
recipients: {
create: share.recipients
? share.recipients.map((email) => ({ email }))
: [],
},
}, },
}); });
} }
@@ -84,21 +91,33 @@ export class ShareService {
} }
async complete(id: string) { 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)) if (await this.isShareCompleted(id))
throw new BadRequestException("Share already completed"); throw new BadRequestException("Share already completed");
const moreThanOneFileInShare = if (share.files.length == 0)
(await this.prisma.file.findMany({ where: { shareId: id } })).length != 0;
if (!moreThanOneFileInShare)
throw new BadRequestException( throw new BadRequestException(
"You need at least on file in your share to complete it." "You need at least on file in your share to complete it."
); );
// Asynchronously create a zip of all files
this.createZip(id).then(() => this.createZip(id).then(() =>
this.prisma.share.update({ where: { id }, data: { isZipReady: true } }) 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({ return await this.prisma.share.update({
where: { id }, where: { id },
data: { uploadLocked: true }, data: { uploadLocked: true },
@@ -106,7 +125,7 @@ export class ShareService {
} }
async getSharesByUser(userId: string) { async getSharesByUser(userId: string) {
return await this.prisma.share.findMany({ const shares = await this.prisma.share.findMany({
where: { where: {
creator: { id: userId }, creator: { id: userId },
uploadLocked: true, uploadLocked: true,
@@ -119,7 +138,17 @@ export class ShareService {
orderBy: { orderBy: {
expiration: "desc", 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) { async get(id: string) {

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

@@ -12,5 +12,10 @@ services:
- ALLOW_UNAUTHENTICATED_SHARES=${ALLOW_UNAUTHENTICATED_SHARES} - ALLOW_UNAUTHENTICATED_SHARES=${ALLOW_UNAUTHENTICATED_SHARES}
- MAX_FILE_SIZE=${MAX_FILE_SIZE} - MAX_FILE_SIZE=${MAX_FILE_SIZE}
- JWT_SECRET=${JWT_SECRET} - 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: volumes:
- "${PWD}/data:/opt/app/backend/data" - "${PWD}/data:/opt/app/backend/data"

View File

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

View File

@@ -5,7 +5,8 @@ const nextConfig = {
ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION, ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION,
SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE, SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE,
MAX_FILE_SIZE: process.env.MAX_FILE_SIZE, MAX_FILE_SIZE: process.env.MAX_FILE_SIZE,
ALLOW_UNAUTHENTICATED_SHARES: process.env.ALLOW_UNAUTHENTICATED_SHARES 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

@@ -23,12 +23,12 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jose": "^4.8.1", "jose": "^4.8.1",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "^12.3.1", "next": "^13.0.0",
"next-cookies": "^2.0.3", "next-cookies": "^2.0.3",
"next-http-proxy-middleware": "^1.2.4", "next-http-proxy-middleware": "^1.2.4",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"react": "18.0.0", "react": "^18.2.0",
"react-dom": "18.0.0", "react-dom": "^18.2.0",
"react-icons": "^4.4.0", "react-icons": "^4.4.0",
"yup": "^0.32.11" "yup": "^0.32.11"
}, },
@@ -39,7 +39,7 @@
"axios": "^0.26.1", "axios": "^0.26.1",
"dotenv-cli": "^6.0.0", "dotenv-cli": "^6.0.0",
"eslint": "8.13.0", "eslint": "8.13.0",
"eslint-config-next": "12.1.5", "eslint-config-next": "^13.0.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"tar": "^6.1.11", "tar": "^6.1.11",

View File

@@ -9,8 +9,8 @@ import {
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { NextLink } from "@mantine/next";
import getConfig from "next/config"; import getConfig from "next/config";
import Link from "next/link";
import * as yup from "yup"; import * as yup from "yup";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
@@ -61,7 +61,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
? "You have an account already?" ? "You have an account already?"
: "You don't have an account yet?"}{" "} : "You don't have an account yet?"}{" "}
<Anchor <Anchor
component={NextLink} component={Link}
href={mode == "signUp" ? "signIn" : "signUp"} href={mode == "signUp" ? "signIn" : "signUp"}
size="sm" size="sm"
> >

View File

@@ -1,5 +1,5 @@
import { ActionIcon, Avatar, Menu } from "@mantine/core"; 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 { TbDoorExit, TbLink } from "react-icons/tb";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
@@ -13,7 +13,7 @@ const ActionAvatar = () => {
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
component={NextLink} component={Link}
href="/account/shares" href="/account/shares"
icon={<TbLink size={14} />} icon={<TbLink size={14} />}
> >

View File

@@ -11,8 +11,8 @@ import {
Transition, Transition,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { NextLink } from "@mantine/next";
import getConfig from "next/config"; import getConfig from "next/config";
import Link from "next/link";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
import Logo from "../Logo"; import Logo from "../Logo";
@@ -22,7 +22,7 @@ const { publicRuntimeConfig } = getConfig();
const HEADER_HEIGHT = 60; const HEADER_HEIGHT = 60;
type Link = { type NavLink = {
link?: string; link?: string;
label?: string; label?: string;
component?: ReactNode; component?: ReactNode;
@@ -122,7 +122,7 @@ const NavBar = () => {
}, },
]; ];
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<Link[]>([ const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<NavLink[]>([
{ {
link: "/auth/signIn", link: "/auth/signIn",
label: "Sign in", label: "Sign in",
@@ -161,7 +161,7 @@ const NavBar = () => {
); );
} }
return ( return (
<NextLink <Link
key={link.label} key={link.label}
href={link.link ?? ""} href={link.link ?? ""}
onClick={() => toggleOpened.toggle()} onClick={() => toggleOpened.toggle()}
@@ -170,7 +170,7 @@ const NavBar = () => {
})} })}
> >
{link.label} {link.label}
</NextLink> </Link>
); );
})} })}
</> </>
@@ -178,12 +178,12 @@ const NavBar = () => {
return ( return (
<Header height={HEADER_HEIGHT} mb={40} className={classes.root}> <Header height={HEADER_HEIGHT} mb={40} className={classes.root}>
<Container className={classes.header}> <Container className={classes.header}>
<NextLink href="/"> <Link href="/" passHref>
<Group> <Group>
<Logo height={35} width={35} /> <Logo height={35} width={35} />
<Text weight={600}>Pingvin Share</Text> <Text weight={600}>Pingvin Share</Text>
</Group> </Group>
</NextLink> </Link>
<Group spacing={5} className={classes.links}> <Group spacing={5} className={classes.links}>
<Group>{items} </Group> <Group>{items} </Group>
</Group> </Group>

View File

@@ -10,14 +10,11 @@ import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import moment from "moment"; import moment from "moment";
import getConfig from "next/config";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { TbCopy } from "react-icons/tb"; import { TbCopy } from "react-icons/tb";
import { Share } from "../../../types/share.type"; import { Share } from "../../../types/share.type";
import toast from "../../../utils/toast.util"; import toast from "../../../utils/toast.util";
const { publicRuntimeConfig } = getConfig();
const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => { const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => {
return modals.openModal({ return modals.openModal({
closeOnClickOutside: false, closeOnClickOutside: false,

View File

@@ -6,6 +6,7 @@ import {
Col, Col,
Grid, Grid,
Group, Group,
MultiSelect,
NumberInput, NumberInput,
PasswordInput, PasswordInput,
Select, Select,
@@ -33,6 +34,7 @@ const showCreateUploadModal = (
uploadCallback: ( uploadCallback: (
id: string, id: string,
expiration: string, expiration: string,
recipients: string[],
security: ShareSecurity security: ShareSecurity
) => void ) => void
) => { ) => {
@@ -54,6 +56,7 @@ const CreateUploadModalBody = ({
uploadCallback: ( uploadCallback: (
id: string, id: string,
expiration: string, expiration: string,
recipients: string[],
security: ShareSecurity security: ShareSecurity
) => void; ) => void;
isSignedIn: boolean; isSignedIn: boolean;
@@ -79,7 +82,7 @@ const CreateUploadModalBody = ({
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
link: "", link: "",
recipients: [] as string[],
password: undefined, password: undefined,
maxViews: undefined, maxViews: undefined,
expiration_num: 1, expiration_num: 1,
@@ -110,7 +113,7 @@ const CreateUploadModalBody = ({
const expiration = form.values.never_expires const expiration = form.values.never_expires
? "never" ? "never"
: form.values.expiration_num + form.values.expiration_unit; : form.values.expiration_num + form.values.expiration_unit;
uploadCallback(values.link, expiration, { uploadCallback(values.link, expiration, values.recipients, {
password: values.password, password: values.password,
maxViews: values.maxViews, maxViews: values.maxViews,
}); });
@@ -211,7 +214,6 @@ const CreateUploadModalBody = ({
label="Never Expires" label="Never Expires"
{...form.getInputProps("never_expires")} {...form.getInputProps("never_expires")}
/> />
{/* Preview expiration date text */} {/* Preview expiration date text */}
<Text <Text
italic italic
@@ -222,8 +224,37 @@ const CreateUploadModalBody = ({
> >
{ExpirationPreview({ form })} {ExpirationPreview({ form })}
</Text> </Text>
<Accordion> <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.Item value="security" sx={{ borderBottom: "none" }}>
<Accordion.Control>Security options</Accordion.Control> <Accordion.Control>Security options</Accordion.Control>
<Accordion.Panel> <Accordion.Panel>

View File

@@ -8,7 +8,7 @@ import {
Group, Group,
} from "@mantine/core"; } from "@mantine/core";
import Meta from "../components/Meta"; import Meta from "../components/Meta";
import { NextLink } from "@mantine/next"; import Link from "next/link";
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
root: { root: {
@@ -53,7 +53,7 @@ const ErrorNotFound = () => {
className={classes.description} className={classes.description}
></Text> ></Text>
<Group position="center"> <Group position="center">
<Button component={NextLink} href="/" variant="light"> <Button component={Link} href="/" variant="light">
Bring me back Bring me back
</Button> </Button>
</Group> </Group>

View File

@@ -12,9 +12,8 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { NextLink } from "@mantine/next";
import moment from "moment"; import moment from "moment";
import getConfig from "next/config"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TbLink, TbTrash } from "react-icons/tb"; import { TbLink, TbTrash } from "react-icons/tb";
@@ -24,8 +23,6 @@ import shareService from "../../services/share.service";
import { MyShare } from "../../types/share.type"; import { MyShare } from "../../types/share.type";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
const { publicRuntimeConfig } = getConfig();
const MyShares = () => { const MyShares = () => {
const modals = useModals(); const modals = useModals();
const clipboard = useClipboard(); const clipboard = useClipboard();
@@ -54,7 +51,7 @@ const MyShares = () => {
<Title order={3}>It's empty here 👀</Title> <Title order={3}>It's empty here 👀</Title>
<Text>You don't have any shares.</Text> <Text>You don't have any shares.</Text>
<Space h={5} /> <Space h={5} />
<Button component={NextLink} href="/upload" variant="light"> <Button component={Link} href="/upload" variant="light">
Create one Create one
</Button> </Button>
</Stack> </Stack>

View File

@@ -8,9 +8,9 @@ import {
ThemeIcon, ThemeIcon,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { NextLink } from "@mantine/next";
import getConfig from "next/config"; import getConfig from "next/config";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { TbCheck } from "react-icons/tb"; import { TbCheck } from "react-icons/tb";
import Meta from "../components/Meta"; import Meta from "../components/Meta";
@@ -126,7 +126,7 @@ export default function Home() {
<Group mt={30}> <Group mt={30}>
<Button <Button
component={NextLink} component={Link}
href="/auth/signUp" href="/auth/signUp"
radius="xl" radius="xl"
size="md" size="md"
@@ -135,7 +135,7 @@ export default function Home() {
Get started Get started
</Button> </Button>
<Button <Button
component={NextLink} component={Link}
href="https://github.com/stonith404/pingvin-share" href="https://github.com/stonith404/pingvin-share"
target="_blank" target="_blank"
variant="default" variant="default"

View File

@@ -29,6 +29,7 @@ const Upload = () => {
const uploadFiles = async ( const uploadFiles = async (
id: string, id: string,
expiration: string, expiration: string,
recipients: string[],
security: ShareSecurity security: ShareSecurity
) => { ) => {
setisUploading(true); setisUploading(true);
@@ -39,7 +40,7 @@ const Upload = () => {
return file; return file;
}) })
); );
share = await shareService.create(id, expiration, security); share = await shareService.create(id, expiration, recipients, security);
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const progressCallBack = (progress: number) => { const progressCallBack = (progress: number) => {
setFiles((files) => { setFiles((files) => {

View File

@@ -9,9 +9,11 @@ import api from "./api.service";
const create = async ( const create = async (
id: string, id: string,
expiration: string, expiration: string,
recipients: string[],
security?: ShareSecurity 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) => { const completeShare = async (id: string) => {

View File

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

View File

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