mirror of
https://github.com/swissmakers/swiss-datashare.git
synced 2026-04-11 10:27:01 +02:00
feat: remove appwrite and add nextjs backend
This commit is contained in:
3
backend/.dockerignore
Normal file
3
backend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
12
backend/.env.example
Normal file
12
backend/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Environment variables declared in this file are automatically made available to Prisma.
|
||||
|
||||
DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/pingvin-share?schema=public"
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_HOST=localhost:5432
|
||||
|
||||
APP_URL=http://localhost:3000
|
||||
ALLOW_REGISTRATION=true
|
||||
MAX_FILE_SIZE=5000000000
|
||||
|
||||
JWT_SECRET=csdkdfmfdfdkslfjskl3987rfkhjgdfnkjdf
|
||||
18
backend/.eslintrc.json
Normal file
18
backend/.eslintrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"overrides": [
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
}
|
||||
}
|
||||
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:18 AS deps
|
||||
WORKDIR /opt/app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
RUN npx prisma generate
|
||||
|
||||
|
||||
FROM node:18 As build
|
||||
WORKDIR /opt/app
|
||||
COPY . .
|
||||
COPY --from=deps /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM node:18 As runner
|
||||
WORKDIR /opt/app
|
||||
COPY --from=build /opt/app/node_modules ./node_modules
|
||||
COPY --from=build /opt/app/dist ./dist
|
||||
COPY --from=build /opt/app/prisma ./prisma
|
||||
COPY --from=deps /opt/app/package.json ./
|
||||
CMD npm run prod
|
||||
15
backend/docker-compose.yml
Normal file
15
backend/docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
db:
|
||||
image: postgres:14.1-alpine
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=pingvin-share
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- pingvin-share-dev-db:/var/lib/postgresql/data
|
||||
volumes:
|
||||
pingvin-share-dev-db:
|
||||
5
backend/nest-cli.json
Normal file
5
backend/nest-cli.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
11069
backend/package-lock.json
generated
Normal file
11069
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
backend/package.json
Normal file
60
backend/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "dotenv -- nest start --watch",
|
||||
"prod": "npx prisma migrate deploy && dotenv node dist/main",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"format": "prettier --write 'src/**/*.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^9.1.2",
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.1.2",
|
||||
"@nestjs/jwt": "^9.0.0",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.1.2",
|
||||
"@nestjs/schedule": "^2.1.0",
|
||||
"@nestjs/swagger": "^6.1.2",
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.29.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"rimraf": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.1.4",
|
||||
"@nestjs/schematics": "^9.0.3",
|
||||
"@nestjs/testing": "^9.1.2",
|
||||
"@prisma/client": "^4.4.0",
|
||||
"@types/archiver": "^5.3.1",
|
||||
"@types/cron": "^2.0.0",
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.7.23",
|
||||
"@types/passport-jwt": "^3.0.7",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.39.0",
|
||||
"@typescript-eslint/parser": "^5.39.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv-cli": "^6.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"prisma": "^4.4.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.4.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "4.1.0",
|
||||
"typescript": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"firstName" TEXT,
|
||||
"lastName" TEXT,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL DEFAULT NOW() + interval '3 months',
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("token")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Share" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiration" TIMESTAMP(3) NOT NULL,
|
||||
"creatorId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Share_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "File" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"name" TEXT NOT NULL,
|
||||
"size" TEXT NOT NULL,
|
||||
"shareId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ShareSecurity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"password" TEXT,
|
||||
"maxViews" INTEGER,
|
||||
"shareId" TEXT,
|
||||
|
||||
CONSTRAINT "ShareSecurity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ShareSecurity_shareId_key" ON "ShareSecurity"("shareId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Share" ADD CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ShareSecurity" ADD CONSTRAINT "ShareSecurity_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
69
backend/prisma/schema.prisma
Normal file
69
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,69 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DB_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
email String @unique
|
||||
password String
|
||||
firstName String?
|
||||
lastName String?
|
||||
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
token String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
expiresAt DateTime @default(dbgenerated("NOW() + interval '3 months'"))
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model Share {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
uploadLocked Boolean @default(false)
|
||||
isZipReady Boolean @default(false)
|
||||
views Int @default(0)
|
||||
expiration DateTime
|
||||
|
||||
creatorId String
|
||||
creator User @relation(fields: [creatorId], references: [id])
|
||||
security ShareSecurity?
|
||||
files File[]
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
name String
|
||||
size String
|
||||
|
||||
shareId String
|
||||
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ShareSecurity {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
password String?
|
||||
maxViews Int?
|
||||
|
||||
shareId String? @unique
|
||||
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
27
backend/src/app.module.ts
Normal file
27
backend/src/app.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { JobsService } from "./auth/jobs/jobs.service";
|
||||
|
||||
import { FileController } from "./file/file.controller";
|
||||
import { FileModule } from "./file/file.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { PrismaService } from "./prisma/prisma.service";
|
||||
import { ShareController } from "./share/share.controller";
|
||||
import { ShareModule } from "./share/share.module";
|
||||
import { UserController } from "./user/user.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
ShareModule,
|
||||
FileModule,
|
||||
PrismaModule,
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
providers: [PrismaService, JobsService],
|
||||
controllers: [UserController, ShareController, FileController],
|
||||
})
|
||||
export class AppModule {}
|
||||
42
backend/src/auth/auth.controller.ts
Normal file
42
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthDTO } from "./dto/auth.dto";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
@Post("signUp")
|
||||
signUp(@Body() dto: AuthRegisterDTO) {
|
||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
||||
throw new ForbiddenException("Registration is not allowed");
|
||||
return this.authService.signUp(dto);
|
||||
}
|
||||
|
||||
@Post("signIn")
|
||||
signIn(@Body() dto: AuthDTO) {
|
||||
return this.authService.signIn(dto);
|
||||
}
|
||||
|
||||
@Post("token")
|
||||
@HttpCode(200)
|
||||
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {
|
||||
const accessToken = await this.authService.refreshAccessToken(
|
||||
body.refreshToken
|
||||
);
|
||||
return { accessToken };
|
||||
}
|
||||
}
|
||||
13
backend/src/auth/auth.module.ts
Normal file
13
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
95
backend/src/auth/auth.service.ts
Normal file
95
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import * as argon from "argon2";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthDTO } from "./dto/auth.dto";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
async signUp(dto: AuthRegisterDTO) {
|
||||
const hash = await argon.hash(dto.password);
|
||||
try {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
password: hash,
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = await this.createAccessToken(user);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
} catch (e) {
|
||||
if (e instanceof PrismaClientKnownRequestError) {
|
||||
if (e.code == "P2002") {
|
||||
throw new BadRequestException("Credentials taken");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(dto: AuthDTO) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
email: dto.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
|
||||
const accessToken = await this.createAccessToken(user);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async createAccessToken(user: User) {
|
||||
return this.jwtService.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
{
|
||||
expiresIn: "15min",
|
||||
secret: this.config.get("JWT_SECRET"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async refreshAccessToken(refreshToken: string) {
|
||||
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
|
||||
throw new UnauthorizedException();
|
||||
|
||||
return this.createAccessToken(refreshTokenMetaData.user);
|
||||
}
|
||||
|
||||
async createRefreshToken(userId: string) {
|
||||
const refreshToken = (
|
||||
await this.prisma.refreshToken.create({ data: { userId } })
|
||||
).token;
|
||||
|
||||
return refreshToken;
|
||||
}
|
||||
}
|
||||
9
backend/src/auth/decorator/getUser.decorator.ts
Normal file
9
backend/src/auth/decorator/getUser.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
|
||||
export const GetUser = createParamDecorator(
|
||||
(data: string, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
return data ? user?.[data] : user;
|
||||
}
|
||||
);
|
||||
26
backend/src/auth/dto/auth.dto.ts
Normal file
26
backend/src/auth/dto/auth.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class AuthDTO {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
firstName: string;
|
||||
|
||||
@Expose()
|
||||
lastName: string;
|
||||
|
||||
@Expose()
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
constructor(partial: Partial<AuthDTO>) {
|
||||
return plainToClass(AuthDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
}
|
||||
4
backend/src/auth/dto/authRegister.dto.ts
Normal file
4
backend/src/auth/dto/authRegister.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { AuthDTO } from "./auth.dto";
|
||||
|
||||
export class AuthRegisterDTO extends AuthDTO {}
|
||||
7
backend/src/auth/dto/authSignIn.dto.ts
Normal file
7
backend/src/auth/dto/authSignIn.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { AuthDTO } from "./auth.dto";
|
||||
|
||||
export class AuthSignInDTO extends PickType(AuthDTO, [
|
||||
"email",
|
||||
"password",
|
||||
] as const) {}
|
||||
6
backend/src/auth/dto/refreshAccessToken.dto.ts
Normal file
6
backend/src/auth/dto/refreshAccessToken.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class RefreshAccessTokenDTO {
|
||||
@IsNotEmpty()
|
||||
refreshToken: string;
|
||||
}
|
||||
7
backend/src/auth/guard/jwt.guard.ts
Normal file
7
backend/src/auth/guard/jwt.guard.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
export class JwtGuard extends AuthGuard("jwt") {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
37
backend/src/auth/jobs/jobs.service.ts
Normal file
37
backend/src/auth/jobs/jobs.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private fileService: FileService
|
||||
) {}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
async deleteExpiredShares() {
|
||||
const expiredShares = await this.prisma.share.findMany({
|
||||
where: { expiration: { lt: new Date() } },
|
||||
});
|
||||
|
||||
for (const expiredShare of expiredShares) {
|
||||
await this.prisma.share.delete({
|
||||
where: { id: expiredShare.id },
|
||||
});
|
||||
|
||||
await this.fileService.deleteAllFiles(expiredShare.id);
|
||||
}
|
||||
|
||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
async deleteExpiredRefreshTokens() {
|
||||
const expiredShares = await this.prisma.refreshToken.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
console.log(`job: deleted ${expiredShares.count} expired refresh tokens`);
|
||||
}
|
||||
}
|
||||
24
backend/src/auth/strategy/jwt.strategy.ts
Normal file
24
backend/src/auth/strategy/jwt.strategy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { User } from "@prisma/client";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: config.get("JWT_SECRET"),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
const user: User = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
22
backend/src/file/dto/file.dto.ts
Normal file
22
backend/src/file/dto/file.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { ShareDTO } from "src/share/dto/share.dto";
|
||||
|
||||
export class FileDTO {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
size: string;
|
||||
|
||||
@Expose()
|
||||
url: boolean;
|
||||
|
||||
share: ShareDTO;
|
||||
|
||||
constructor(partial: Partial<FileDTO>) {
|
||||
return plainToClass(FileDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
}
|
||||
107
backend/src/file/file.controller.ts
Normal file
107
backend/src/file/file.controller.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseFilePipeBuilder,
|
||||
Post,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { Response } from "express";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
|
||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||
import { FileService } from "./file.service";
|
||||
|
||||
@Controller("shares/:shareId/files")
|
||||
export class FileController {
|
||||
constructor(
|
||||
private fileService: FileService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
dest: "./uploads/_temp/",
|
||||
})
|
||||
)
|
||||
async create(
|
||||
@UploadedFile(
|
||||
new ParseFilePipeBuilder()
|
||||
.addMaxSizeValidator({
|
||||
maxSize: parseInt(process.env.MAX_FILE_SIZE),
|
||||
})
|
||||
.build()
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
@Param("shareId") shareId: string
|
||||
) {
|
||||
return await this.fileService.create(file, shareId);
|
||||
}
|
||||
|
||||
@Get(":fileId/download")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
async getFileDownloadUrl(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("fileId") fileId: string
|
||||
) {
|
||||
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
|
||||
|
||||
return { url };
|
||||
}
|
||||
|
||||
@Get("zip/download")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
async getZipArchiveDownloadURL(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("fileId") fileId: string
|
||||
) {
|
||||
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
|
||||
|
||||
res.set({
|
||||
"Content-Type": "application/zip",
|
||||
});
|
||||
|
||||
return { url };
|
||||
}
|
||||
|
||||
@Get("zip")
|
||||
@UseGuards(FileDownloadGuard)
|
||||
async getZip(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string
|
||||
) {
|
||||
const zip = this.fileService.getZip(shareId);
|
||||
res.set({
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}"`,
|
||||
});
|
||||
|
||||
return new StreamableFile(zip);
|
||||
}
|
||||
|
||||
@Get(":fileId")
|
||||
@UseGuards(FileDownloadGuard)
|
||||
async getFile(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("fileId") fileId: string
|
||||
) {
|
||||
const file = await this.fileService.get(shareId, fileId);
|
||||
res.set({
|
||||
"Content-Type": file.metaData.mimeType,
|
||||
"Content-Length": file.metaData.size,
|
||||
"Content-Disposition": `attachment ; filename="${file.metaData.name}"`,
|
||||
});
|
||||
|
||||
return new StreamableFile(file.file);
|
||||
}
|
||||
}
|
||||
14
backend/src/file/file.module.ts
Normal file
14
backend/src/file/file.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { ShareModule } from "src/share/share.module";
|
||||
import { ShareService } from "src/share/share.service";
|
||||
import { FileController } from "./file.controller";
|
||||
import { FileService } from "./file.service";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), ShareModule],
|
||||
controllers: [FileController],
|
||||
providers: [FileService],
|
||||
exports: [FileService],
|
||||
})
|
||||
export class FileModule {}
|
||||
112
backend/src/file/file.service.ts
Normal file
112
backend/src/file/file.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as mime from "mime-types";
|
||||
import { join } from "path";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
async create(file: Express.Multer.File, shareId: string) {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
if (share.uploadLocked)
|
||||
throw new BadRequestException("Share is already completed");
|
||||
|
||||
const fileId = randomUUID();
|
||||
|
||||
await fs.promises.mkdir(`./uploads/shares/${shareId}`, { recursive: true });
|
||||
fs.promises.rename(
|
||||
`./uploads/_temp/${file.filename}`,
|
||||
`./uploads/shares/${shareId}/${fileId}`
|
||||
);
|
||||
|
||||
return await this.prisma.file.create({
|
||||
data: {
|
||||
id: fileId,
|
||||
name: file.originalname,
|
||||
size: file.size.toString(),
|
||||
share: { connect: { id: shareId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async get(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
const file = fs.createReadStream(
|
||||
join(process.cwd(), `uploads/shares/${shareId}/${fileId}`)
|
||||
);
|
||||
|
||||
return {
|
||||
metaData: {
|
||||
mimeType: mime.contentType(fileMetaData.name.split(".").pop()),
|
||||
...fileMetaData,
|
||||
size: fileMetaData.size,
|
||||
},
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
await fs.promises.rm(`./uploads/shares/${shareId}`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
getZip(shareId: string) {
|
||||
return fs.createReadStream(`./uploads/shares/${shareId}/archive.zip`);
|
||||
}
|
||||
|
||||
getFileDownloadUrl(shareId: string, fileId: string) {
|
||||
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
|
||||
return `${this.config.get(
|
||||
"APP_URL"
|
||||
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
|
||||
}
|
||||
|
||||
generateFileDownloadToken(shareId: string, fileId: string) {
|
||||
if (fileId == "zip") fileId = undefined;
|
||||
|
||||
return this.jwtService.sign(
|
||||
{
|
||||
shareId,
|
||||
fileId,
|
||||
},
|
||||
{
|
||||
expiresIn: "10min",
|
||||
secret: this.config.get("JWT_SECRET"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
verifyFileDownloadToken(shareId: string, fileId: string, token: string) {
|
||||
try {
|
||||
const claims = this.jwtService.verify(token, {
|
||||
secret: this.config.get("JWT_SECRET"),
|
||||
});
|
||||
return claims.shareId == shareId && claims.fileId == fileId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
backend/src/file/guard/fileDownload.guard.ts
Normal file
23
backend/src/file/guard/fileDownload.guard.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class FileDownloadGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private fileService: FileService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
|
||||
const token = request.query.token as string;
|
||||
const { shareId, fileId } = request.params;
|
||||
|
||||
return this.fileService.verifyFileDownloadToken(shareId, fileId, token);
|
||||
}
|
||||
}
|
||||
15
backend/src/main.ts
Normal file
15
backend/src/main.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||
import { NestFactory, Reflector } from "@nestjs/core";
|
||||
import * as fs from "fs";
|
||||
import { AppModule } from "./app.module";
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||
|
||||
await fs.promises.mkdir("./uploads/_temp", { recursive: true });
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
await app.listen(8080);
|
||||
}
|
||||
bootstrap();
|
||||
6
backend/src/prisma/prisma.module.ts
Normal file
6
backend/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { PrismaService } from "./prisma.service";
|
||||
|
||||
@Global()
|
||||
@Module({ providers: [PrismaService], exports: [PrismaService] })
|
||||
export class PrismaModule {}
|
||||
18
backend/src/prisma/prisma.service.ts
Normal file
18
backend/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
datasources: {
|
||||
db: {
|
||||
url: config.get("DB_URL"),
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(config.get("DB_URL"));
|
||||
super.$connect().then(() => console.info("Connected to the database"));
|
||||
}
|
||||
}
|
||||
18
backend/src/share/dto/createShare.dto.ts
Normal file
18
backend/src/share/dto/createShare.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { IsString, Matches, ValidateNested } from "class-validator";
|
||||
import { ShareSecurityDTO } from "./shareSecurity.dto";
|
||||
|
||||
export class CreateShareDTO {
|
||||
@IsString()
|
||||
@Matches("^[a-zA-Z0-9_-]*$", undefined, {
|
||||
message: "ID only can contain letters, numbers, underscores and hyphens",
|
||||
})
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
expiration: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ShareSecurityDTO)
|
||||
security: ShareSecurityDTO;
|
||||
}
|
||||
20
backend/src/share/dto/myShare.dto.ts
Normal file
20
backend/src/share/dto/myShare.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { ShareDTO } from "./share.dto";
|
||||
|
||||
export class MyShareDTO extends ShareDTO {
|
||||
@Expose()
|
||||
views: number;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
from(partial: Partial<MyShareDTO>) {
|
||||
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
fromList(partial: Partial<MyShareDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true })
|
||||
);
|
||||
}
|
||||
}
|
||||
29
backend/src/share/dto/share.dto.ts
Normal file
29
backend/src/share/dto/share.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Expose, plainToClass, Type } from "class-transformer";
|
||||
import { AuthDTO } from "src/auth/dto/auth.dto";
|
||||
import { FileDTO } from "src/file/dto/file.dto";
|
||||
|
||||
export class ShareDTO {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
expiration: Date;
|
||||
|
||||
@Expose()
|
||||
@Type(() => FileDTO)
|
||||
files: FileDTO[];
|
||||
|
||||
@Expose()
|
||||
@Type(() => AuthDTO)
|
||||
creator: AuthDTO;
|
||||
|
||||
from(partial: Partial<ShareDTO>) {
|
||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
fromList(partial: Partial<ShareDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(ShareDTO, part, { excludeExtraneousValues: true })
|
||||
);
|
||||
}
|
||||
}
|
||||
15
backend/src/share/dto/shareMetaData.dto.ts
Normal file
15
backend/src/share/dto/shareMetaData.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
|
||||
export class ShareMetaDataDTO {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
isZipReady: boolean;
|
||||
|
||||
from(partial: Partial<ShareMetaDataDTO>) {
|
||||
return plainToClass(ShareMetaDataDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
6
backend/src/share/dto/sharePassword.dto.ts
Normal file
6
backend/src/share/dto/sharePassword.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
|
||||
export class SharePasswordDto {
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
12
backend/src/share/dto/shareSecurity.dto.ts
Normal file
12
backend/src/share/dto/shareSecurity.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsNumber, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
export class ShareSecurityDTO {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Length(3, 30)
|
||||
password: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
maxViews: number;
|
||||
}
|
||||
57
backend/src/share/guard/shareSecurity.guard.ts
Normal file
57
backend/src/share/guard/shareSecurity.guard.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
import * as moment from "moment";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ShareService } from "src/share/share.service";
|
||||
|
||||
@Injectable()
|
||||
export class ShareSecurityGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private shareService: ShareService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { security: true },
|
||||
});
|
||||
|
||||
if (!share || moment().isAfter(share.expiration))
|
||||
throw new NotFoundException("Share not found");
|
||||
|
||||
if (!share.security) return true;
|
||||
|
||||
if (share.security.maxViews && share.security.maxViews <= share.views)
|
||||
throw new ForbiddenException(
|
||||
"Maximum views exceeded",
|
||||
"share_max_views_exceeded"
|
||||
);
|
||||
|
||||
if (
|
||||
!this.shareService.verifyShareToken(shareId, request.get("X-Share-Token"))
|
||||
)
|
||||
throw new ForbiddenException(
|
||||
"This share is password protected",
|
||||
"share_token_required"
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
77
backend/src/share/share.controller.ts
Normal file
77
backend/src/share/share.controller.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
import { MyShareDTO } from "./dto/myShare.dto";
|
||||
import { ShareDTO } from "./dto/share.dto";
|
||||
import { ShareMetaDataDTO } from "./dto/shareMetaData.dto";
|
||||
import { SharePasswordDto } from "./dto/sharePassword.dto";
|
||||
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
||||
import { ShareService } from "./share.service";
|
||||
|
||||
@Controller("shares")
|
||||
export class ShareController {
|
||||
constructor(private shareService: ShareService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtGuard)
|
||||
async getMyShares(@GetUser() user: User) {
|
||||
return new MyShareDTO().fromList(
|
||||
await this.shareService.getSharesByUser(user.id)
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
async get(@Param("id") id: string) {
|
||||
return new ShareDTO().from(await this.shareService.get(id));
|
||||
}
|
||||
|
||||
@Get(":id/metaData")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
async getMetaData(@Param("id") id: string) {
|
||||
return new ShareMetaDataDTO().from(await this.shareService.getMetaData(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
async create(@Body() body: CreateShareDTO, @GetUser() user: User) {
|
||||
return new ShareDTO().from(await this.shareService.create(body, user));
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(JwtGuard)
|
||||
async remove(@Param("id") id: string, @GetUser() user: User) {
|
||||
await this.shareService.remove(id, user.id);
|
||||
}
|
||||
|
||||
@Post(":id/complete")
|
||||
@HttpCode(202)
|
||||
@UseGuards(JwtGuard)
|
||||
async complete(@Param("id") id: string) {
|
||||
return new ShareDTO().from(await this.shareService.complete(id));
|
||||
}
|
||||
|
||||
@Get("isShareIdAvailable/:id")
|
||||
async isShareIdAvailable(@Param("id") id: string) {
|
||||
return this.shareService.isShareIdAvailable(id);
|
||||
}
|
||||
|
||||
@Post(":id/password")
|
||||
async exchangeSharePasswordWithToken(
|
||||
@Param("id") id: string,
|
||||
@Body() body: SharePasswordDto
|
||||
) {
|
||||
return this.shareService.exchangeSharePasswordWithToken(id, body.password);
|
||||
}
|
||||
}
|
||||
14
backend/src/share/share.module.ts
Normal file
14
backend/src/share/share.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule, JwtService } from "@nestjs/jwt";
|
||||
import { AuthModule } from "src/auth/auth.module";
|
||||
import { FileModule } from "src/file/file.module";
|
||||
import { ShareController } from "./share.controller";
|
||||
import { ShareService } from "./share.service";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
})
|
||||
export class ShareModule {}
|
||||
200
backend/src/share/share.service.ts
Normal file
200
backend/src/share/share.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Share, User } from "@prisma/client";
|
||||
import * as archiver from "archiver";
|
||||
import * as argon from "argon2";
|
||||
import * as fs from "fs";
|
||||
import * as moment from "moment";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
|
||||
private config: ConfigService,
|
||||
private jwtService: JwtService
|
||||
) {}
|
||||
|
||||
async create(share: CreateShareDTO, user: User) {
|
||||
if (!(await this.isShareIdAvailable(share.id)).isAvailable)
|
||||
throw new BadRequestException("Share id already in use");
|
||||
|
||||
if (!share.security || Object.keys(share.security).length == 0)
|
||||
share.security = undefined;
|
||||
|
||||
if (share.security?.password) {
|
||||
share.security.password = await argon.hash(share.security.password);
|
||||
}
|
||||
|
||||
const expirationDate = moment()
|
||||
.add(
|
||||
share.expiration.split("-")[0],
|
||||
share.expiration.split("-")[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
|
||||
// Throw error if expiration date is now
|
||||
if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0))
|
||||
throw new BadRequestException("Invalid expiration date");
|
||||
|
||||
return await this.prisma.share.create({
|
||||
data: {
|
||||
...share,
|
||||
expiration: expirationDate,
|
||||
creator: { connect: { id: user.id } },
|
||||
security: { create: share.security },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createZip(shareId: string) {
|
||||
const path = `./uploads/shares/${shareId}`;
|
||||
|
||||
const files = await this.prisma.file.findMany({ where: { shareId } });
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
|
||||
|
||||
for (const file of files) {
|
||||
archive.append(fs.createReadStream(`${path}/${file.id}`), {
|
||||
name: file.name,
|
||||
});
|
||||
}
|
||||
|
||||
archive.pipe(writeStream);
|
||||
await archive.finalize();
|
||||
}
|
||||
|
||||
async complete(id: string) {
|
||||
const moreThanOneFileInShare =
|
||||
(await this.prisma.file.findMany({ where: { shareId: id } })).length != 0;
|
||||
|
||||
if (!moreThanOneFileInShare)
|
||||
throw new BadRequestException(
|
||||
"You need at least on file in your share to complete it."
|
||||
);
|
||||
|
||||
this.createZip(id).then(() =>
|
||||
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
|
||||
);
|
||||
|
||||
return await this.prisma.share.update({
|
||||
where: { id },
|
||||
data: { uploadLocked: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getSharesByUser(userId: string) {
|
||||
return await this.prisma.share.findMany({
|
||||
where: { creator: { id: userId }, expiration: { gt: new Date() } },
|
||||
});
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
let share: any = await this.prisma.share.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
files: true,
|
||||
creator: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!share || !share.uploadLocked)
|
||||
throw new NotFoundException("Share not found");
|
||||
|
||||
share.files = share.files.map((file) => {
|
||||
file["url"] = `http://localhost:8080/file/${file.id}`;
|
||||
return file;
|
||||
});
|
||||
|
||||
await this.increaseViewCount(share);
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
async getMetaData(id: string) {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!share || !share.uploadLocked)
|
||||
throw new NotFoundException("Share not found");
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
async remove(shareId: string, userId: string) {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
if (!share) throw new NotFoundException("Share not found");
|
||||
|
||||
if (share.creatorId != userId) throw new ForbiddenException();
|
||||
|
||||
await this.prisma.share.delete({ where: { id: shareId } });
|
||||
}
|
||||
|
||||
async isShareCompleted(id: string) {
|
||||
return (await this.prisma.share.findUnique({ where: { id } })).uploadLocked;
|
||||
}
|
||||
|
||||
async isShareIdAvailable(id: string) {
|
||||
const share = await this.prisma.share.findUnique({ where: { id } });
|
||||
return { isAvailable: !share };
|
||||
}
|
||||
|
||||
async increaseViewCount(share: Share) {
|
||||
await this.prisma.share.update({
|
||||
where: { id: share.id },
|
||||
data: { views: share.views + 1 },
|
||||
});
|
||||
}
|
||||
|
||||
async exchangeSharePasswordWithToken(shareId: string, password: string) {
|
||||
const sharePassword = (
|
||||
await this.prisma.shareSecurity.findFirst({
|
||||
where: { share: { id: shareId } },
|
||||
})
|
||||
).password;
|
||||
|
||||
if (!(await argon.verify(sharePassword, password)))
|
||||
throw new ForbiddenException("Wrong password");
|
||||
|
||||
const token = this.generateShareToken(shareId);
|
||||
return { token };
|
||||
}
|
||||
|
||||
generateShareToken(shareId: string) {
|
||||
return this.jwtService.sign(
|
||||
{
|
||||
shareId,
|
||||
},
|
||||
{
|
||||
expiresIn: "1h",
|
||||
secret: this.config.get("JWT_SECRET"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
verifyShareToken(shareId: string, token: string) {
|
||||
try {
|
||||
const claims = this.jwtService.verify(token, {
|
||||
secret: this.config.get("JWT_SECRET"),
|
||||
});
|
||||
|
||||
return claims.shareId == shareId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
backend/src/user/user.controller.ts
Normal file
13
backend/src/user/user.controller.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
|
||||
@Controller("users")
|
||||
export class UserController {
|
||||
@Get("me")
|
||||
@UseGuards(JwtGuard)
|
||||
async getCurrentUser(@GetUser() user: User) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
21
backend/tsconfig.json
Normal file
21
backend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user