diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..04a0bd6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "terminal.integrated.fontFamily": "Hack", + "[prisma]": { + "editor.defaultFormatter": "Prisma.prisma" + }, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + } +} diff --git a/api/.gitignore b/api/.gitignore index 45dda79..a994235 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -10,5 +10,4 @@ !/.yarn/releases !/.yarn/plugins data -.env -.env.production \ No newline at end of file +.env \ No newline at end of file diff --git a/api/package.json b/api/package.json index 6d3637e..39dd035 100644 --- a/api/package.json +++ b/api/package.json @@ -34,6 +34,7 @@ "class-validator": "^0.13.2", "connect-redis": "^6.1.3", "cron": "^2.1.0", + "cuid": "^2.1.8", "express-session": "^1.17.3", "fast-folder-size": "^1.7.1", "hbs": "^4.2.0", @@ -56,6 +57,7 @@ "@types/body-parser": "^1", "@types/connect-redis": "^0.0.19", "@types/cron": "^2", + "@types/cuid": "^2", "@types/express": "^4.17.13", "@types/express-session": "^1", "@types/jest": "28.1.8", diff --git a/api/prisma/migrations/20221223023352_user_refined/migration.sql b/api/prisma/migrations/20221223023352_user_refined/migration.sql new file mode 100644 index 0000000..398f280 --- /dev/null +++ b/api/prisma/migrations/20221223023352_user_refined/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the `verification_tokens` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "upload_limit" INTEGER NOT NULL DEFAULT 500; + +-- DropTable +DROP TABLE "verification_tokens"; + +-- DropEnum +DROP TYPE "Token"; diff --git a/api/prisma/migrations/20221223141125_user_moderation/migration.sql b/api/prisma/migrations/20221223141125_user_moderation/migration.sql new file mode 100644 index 0000000..2cf87fc --- /dev/null +++ b/api/prisma/migrations/20221223141125_user_moderation/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "disabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index ef48075..66bdfc7 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -11,18 +11,21 @@ datasource db { } model User { - id String @id @default(uuid()) - username String @unique - image String? - email String @unique - password String - role Role @default(USER) - createdAt DateTime @default(now()) @map("created_at") - emailVerified DateTime? @map("email_verified") - invitedBy String? @map("invited_by") - apiKey String @unique @map("api_key") + id String @id @default(uuid()) + username String @unique + image String? + email String @unique + disabled Boolean @default(false) + password String + role Role @default(USER) + createdAt DateTime @default(now()) @map("created_at") + emailVerified DateTime? @map("email_verified") + invitedBy String? @map("invited_by") + apiKey String @unique @default(cuid()) @map("api_key") + uploadLimit Int @default(500) @map("upload_limit") + embed_settings EmbedSettings? - File File[] + files File[] @@map("users") } diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 70212b5..d056e4e 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -12,9 +12,16 @@ import { S3Module } from "./modules/s3/s3.module"; import { PrismaService } from "modules/prisma/prisma.service"; import { UploadModule } from "modules/upload/upload.module"; import { RedisService } from "modules/redis/redis.service"; +import { ThrottlerModule } from "@nestjs/throttler"; +import { APP_GUARD } from "@nestjs/core"; +import { ThrottlerBehindProxyGuard } from "modules/root/root.guard"; @Module({ imports: [ + ThrottlerModule.forRoot({ + ttl: 60, + limit: 10, + }), ConfigModule.forRoot(), AuthModule, UsersModule, @@ -22,7 +29,15 @@ import { RedisService } from "modules/redis/redis.service"; DeleteModule, process.env.UPLOADER === "s3" ? S3Module : UploadModule, ], - providers: [RootService, PrismaService, RedisService], + providers: [ + RootService, + PrismaService, + RedisService, + { + provide: APP_GUARD, + useClass: ThrottlerBehindProxyGuard, + }, + ], controllers: [RootController], }) export class AppModule implements NestModule { diff --git a/api/src/lib/clean.ts b/api/src/lib/clean.ts index a786e74..6245bc0 100644 --- a/api/src/lib/clean.ts +++ b/api/src/lib/clean.ts @@ -15,7 +15,7 @@ const cleanUp = async () => { const currentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const job = new CronJob( - // every 24 hours + // every 24 hours at 12 AM "0 0 * * *", async () => { for (const file of tmpFiles) { diff --git a/api/src/lib/setup.ts b/api/src/lib/setup.ts index 93e3408..331e57f 100644 --- a/api/src/lib/setup.ts +++ b/api/src/lib/setup.ts @@ -2,7 +2,7 @@ import { createWriteStream, existsSync } from "fs"; import { mkdir } from "fs/promises"; import { logsDir, rootDir, uploadDir } from "./constants"; import { PrismaClient } from "@prisma/client"; -import { generateApiKey } from "./utils"; +import { generateRandomString } from "./utils"; import md5 from "md5"; import argon from "argon2"; import { join } from "path"; @@ -40,12 +40,6 @@ const ensure = async () => { } } - if (!existsSync(uploadDir)) { - await mkdir(uploadDir, { recursive: true }); - } else if (!existsSync(logsDir)) { - await mkdir(logsDir, { recursive: true }); - } - const prisma = new PrismaClient(); await prisma.$connect(); @@ -55,7 +49,7 @@ const ensure = async () => { }); if (!user) { - const p = generateApiKey(64); + const p = generateRandomString(64); const password = await argon.hash(p); const stream = createWriteStream( @@ -75,7 +69,7 @@ const ensure = async () => { stream.on("finish", () => { console.log( - "Initial root password has been written to initial_root_password.txt file." + "Initial root password has been written to initial_root_account.txt file." ); }); @@ -83,18 +77,17 @@ const ensure = async () => { console.log(err); }); - const avatarHash = md5("root@localhost"); + const avatarHash = md5(generateRandomString(32) + Date.now().toString()); await prisma.user.create({ data: { email: "root@localhost", password, role: "OWNER", - apiKey: generateApiKey(32), username: "root", emailVerified: new Date(Date.now()), - // image: `https://www.gravatar.com/avatar/${md5("root@localhost")}`, image: `https://avatars.dicebear.com/api/identicon/${avatarHash}.svg`, + uploadLimit: 0, }, }); } @@ -102,4 +95,13 @@ const ensure = async () => { await prisma.$disconnect(); }; +const ensureDirs = async () => { + if (!existsSync(uploadDir)) { + await mkdir(uploadDir, { recursive: true }); + } else if (!existsSync(logsDir)) { + await mkdir(logsDir, { recursive: true }); + } +}; + +ensureDirs(); process.env.NODE_ENV === "production" && ensure(); diff --git a/api/src/lib/types.ts b/api/src/lib/types.ts index b3abcf8..bf537bd 100644 --- a/api/src/lib/types.ts +++ b/api/src/lib/types.ts @@ -1,4 +1,4 @@ -import { User } from "@prisma/client"; +import { Role, User } from "@prisma/client"; import { Request } from "express"; import { Session, SessionData } from "express-session"; import { RegisterDTO } from "modules/auth/dto/register.dto"; @@ -16,6 +16,7 @@ export interface UserResponse { export interface findUserOptions { byId?: boolean; withPassword?: boolean; + totalUsed?: boolean; } export interface IUserService { @@ -44,3 +45,10 @@ export interface ServerSettings { REGISTRATION_ENABLED: boolean; INVITE_MODE: boolean; } + +export interface UpdateUsers { + id: string; + role: Role; + disabled: boolean; + uploadLimit: number; +} diff --git a/api/src/lib/utils.ts b/api/src/lib/utils.ts index 5e59246..55133fe 100644 --- a/api/src/lib/utils.ts +++ b/api/src/lib/utils.ts @@ -20,7 +20,7 @@ export const toFieldError = (errors: string[]) => { return fieldErrors; }; -export const generateApiKey = (len = 32) => { +export const generateRandomString = (len = 32) => { return randomBytes(20).toString("hex").substring(0, len); }; diff --git a/api/src/main.ts b/api/src/main.ts index 20ea68c..a979b80 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -12,6 +12,7 @@ import helmet from "helmet"; import bp from "body-parser"; import "./lib/setup"; import "./lib/clean"; +import { NextFunction, Request, Response } from "express"; async function bootstrap() { const startTime = Date.now(); @@ -22,8 +23,23 @@ async function bootstrap() { app.setBaseViewsDir(join(rootDir, "views")); app.setViewEngine("hbs"); + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.originalUrl.includes("favicon.ico")) { + return res.sendStatus(204).end(); + } + next(); + }); app.use(bp.raw({ type: "application/octet-stream", limit: "100mb" })); - app.use(helmet({ contentSecurityPolicy: false })); + app.use( + helmet({ + crossOriginEmbedderPolicy: false, + contentSecurityPolicy: { + directives: { + "img-src": ["'self'", "https: data:"], + }, + }, + }) + ); app.enableCors({ credentials: true, origin: process.env.CORS_ORIGIN, diff --git a/api/src/modules/admin/admin.controller.ts b/api/src/modules/admin/admin.controller.ts index bdb2b63..4b9a39f 100644 --- a/api/src/modules/admin/admin.controller.ts +++ b/api/src/modules/admin/admin.controller.ts @@ -5,12 +5,16 @@ import { UseGuards, Request, Get, + Query, + Delete, } from "@nestjs/common"; +import { SkipThrottle } from "@nestjs/throttler"; import { Request as ERequest } from "express"; -import { CustomSession } from "lib/types"; +import { CustomSession, UpdateUsers } from "lib/types"; import { AuthGuard } from "modules/auth/guard/auth.guard"; import { AdminService } from "./admin.service"; +@SkipThrottle() @Controller("admin") export class AdminController { constructor(private readonly adminService: AdminService) {} @@ -44,4 +48,38 @@ export class AdminController { (req.session as CustomSession).userId ); } + + @UseGuards(AuthGuard) + @Get("users") + getUsers( + @Request() req: ERequest, + @Query("skip") skip: string, + @Query("take") take: string, + @Query("search") search?: string + ) { + return this.adminService.getUsers( + (req.session as CustomSession).userId, + +skip, + +take, + search + ); + } + + @UseGuards(AuthGuard) + @Post("users") + updateUsers(@Body() data: UpdateUsers[], @Request() req: ERequest) { + return this.adminService.updateUsers( + (req.session as CustomSession).userId, + data + ); + } + + @UseGuards(AuthGuard) + @Delete("purge-files") + purgeFiles(@Request() req: ERequest, @Query("user") user: string) { + return this.adminService.purgeUserFiles( + (req.session as CustomSession).userId, + user + ); + } } diff --git a/api/src/modules/admin/admin.service.ts b/api/src/modules/admin/admin.service.ts index 69e727e..7e92b3c 100644 --- a/api/src/modules/admin/admin.service.ts +++ b/api/src/modules/admin/admin.service.ts @@ -1,8 +1,16 @@ -import { ForbiddenException, Injectable } from "@nestjs/common"; -import { INVITE_PREFIX } from "lib/constants"; -import { generateApiKey } from "lib/utils"; +import { + BadRequestException, + ForbiddenException, + Injectable, +} from "@nestjs/common"; +import { User } from "@prisma/client"; +import { unlink } from "fs/promises"; +import { INVITE_PREFIX, uploadDir } from "lib/constants"; +import { UpdateUsers } from "lib/types"; +import { generateRandomString } from "lib/utils"; import { PrismaService } from "modules/prisma/prisma.service"; import { RedisService } from "modules/redis/redis.service"; +import { join } from "path"; @Injectable() export class AdminService { @@ -82,7 +90,7 @@ export class AdminService { throw new ForbiddenException(); } - const invite = generateApiKey(64); + const invite = generateRandomString(64); await this.redis .redis() @@ -90,4 +98,118 @@ export class AdminService { return invite; } + + async getUsers(id: string, skip: number, take: number, search = "") { + const check = await this.prisma.user.findUnique({ where: { id } }); + + if (!check) { + throw new ForbiddenException(); + } + + if (check.role !== "OWNER") { + throw new ForbiddenException(); + } + + const total = (await this.prisma.user.count()) - 1; + + const totalPages = Math.ceil(total / take); + + // 1 2 3 4 5 6 7 8 9 10 + // ^ + // take = 6, left = 4 + // + + let finalTake = take; + + if (take > total - skip) { + finalTake = total - skip; + } + + const users = await this.prisma.user + .findMany({ + skip, + take: finalTake, + orderBy: { + createdAt: "desc", + }, + where: { username: { contains: search } }, + }) + .then((users) => { + return users + .map((u) => { + delete u.password; + return u; + }) + .filter((u) => u.role !== "OWNER"); + }); + + return { + users, + totalPages, + }; + } + + async updateUsers(id: string, data: UpdateUsers[]) { + const check = await this.prisma.user.findUnique({ where: { id } }); + + if (!check) { + throw new ForbiddenException(); + } + + if (check.role !== "OWNER") { + throw new ForbiddenException(); + } + + const newData: UpdateUsers[] = []; + + for (const u of data) { + let upgrade: number = 500; + if (u.role === "ADMIN") { + upgrade = 2000; + } else if (u.role === "USER") { + upgrade = 500; + } + + const user = await this.prisma.user.update({ + where: { id: u.id }, + data: { + ...u, + uploadLimit: upgrade, + }, + }); + newData.push(user); + } + + return newData; + } + + async purgeUserFiles(id: string, userId: string) { + const check = await this.prisma.user.findUnique({ where: { id } }); + + if (!check) { + throw new ForbiddenException(); + } + + if (check.role !== "OWNER") { + throw new ForbiddenException(); + } + + if (!userId) { + throw new BadRequestException(); + } + + // Delete all files from user and remove from database + const files = await this.prisma.file.findMany({ + where: { userId }, + }); + + for (const file of files) { + const ext = file.filename.split(".").pop(); + await unlink(join(uploadDir, `${file.slug}.${ext}`)).catch(() => {}); + } + + await this.prisma.file.deleteMany({ where: { userId } }); + + return true; + } } diff --git a/api/src/modules/auth/auth.controller.ts b/api/src/modules/auth/auth.controller.ts index 628c9c7..803b78d 100644 --- a/api/src/modules/auth/auth.controller.ts +++ b/api/src/modules/auth/auth.controller.ts @@ -52,15 +52,7 @@ export class AuthController { @UseGuards(AuthGuard) @Get("me") - async me(@Request() req: ERequest, @Response() res: EResponse) { - const user = await this.authService.me( - (req.session as CustomSession).userId - ); - - return res - .setHeader("Cache-Control", "no-store") - .setHeader("Pragma", "no-cache") - .setHeader("Content-Type", "application/json") - .json(user); + me(@Request() req: ERequest) { + return this.authService.me((req.session as CustomSession).userId); } } diff --git a/api/src/modules/auth/auth.service.ts b/api/src/modules/auth/auth.service.ts index fec195f..3c6e71c 100644 --- a/api/src/modules/auth/auth.service.ts +++ b/api/src/modules/auth/auth.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, +} from "@nestjs/common"; import { CustomSession, UserResponse } from "../../lib/types"; import { UsersService } from "../users/users.service"; import { LoginDTO } from "./dto/login.dto"; @@ -13,7 +18,14 @@ export class AuthService { constructor(private readonly usersService: UsersService) {} async me(id: string) { - return this.usersService.findUser(id, { byId: true }); + const user = await this.usersService.findUser(id, { + byId: true, + totalUsed: true, + }); + if (user.disabled) { + throw new ForbiddenException(); + } + return user; } async login(data: LoginDTO, req: Request): Promise { @@ -23,7 +35,6 @@ export class AuthService { if (!user) { throw new BadRequestException({ - user: null, errors: [ { field: "username_email", @@ -41,7 +52,6 @@ export class AuthService { if (!valid) { throw new BadRequestException({ - user: null, errors: [ { field: "username_email", @@ -55,6 +65,17 @@ export class AuthService { }); } + if (user.disabled) { + throw new ForbiddenException({ + errors: [ + { + field: "username_email", + message: "Your account has been disabled", + }, + ], + }); + } + delete user.password; (req.session as CustomSession).userId = user.id; diff --git a/api/src/modules/root/root.controller.ts b/api/src/modules/root/root.controller.ts index 836f9b4..41b6744 100644 --- a/api/src/modules/root/root.controller.ts +++ b/api/src/modules/root/root.controller.ts @@ -7,11 +7,13 @@ import { Request, UseGuards, } from "@nestjs/common"; +import { SkipThrottle } from "@nestjs/throttler"; import { Response as EResponse, Request as ERequest } from "express"; import { AuthGuard } from "modules/auth/guard/auth.guard"; import { RedisService } from "modules/redis/redis.service"; import { RootService } from "./root.service"; +@SkipThrottle() @Controller() export class RootController { constructor( diff --git a/api/src/modules/root/root.guard.ts b/api/src/modules/root/root.guard.ts new file mode 100644 index 0000000..373a94f --- /dev/null +++ b/api/src/modules/root/root.guard.ts @@ -0,0 +1,9 @@ +import { ThrottlerGuard } from "@nestjs/throttler"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class ThrottlerBehindProxyGuard extends ThrottlerGuard { + protected getTracker(req: Record): string { + return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs + } +} diff --git a/api/src/modules/root/root.service.ts b/api/src/modules/root/root.service.ts index 1a1d9c5..f53f8e1 100644 --- a/api/src/modules/root/root.service.ts +++ b/api/src/modules/root/root.service.ts @@ -6,7 +6,7 @@ import { } from "@nestjs/common"; import { Request, Response } from "express"; import fastFolderSize from "fast-folder-size"; -import { createReadStream } from "fs"; +import { createReadStream, existsSync } from "fs"; import { stat } from "fs/promises"; import { thumbnailDir, uploadDir } from "lib/constants"; import { CustomSession } from "lib/types"; @@ -61,64 +61,72 @@ export class RootService { } const protocol = req.headers["x-forwarded-proto"] || req.protocol; - - const baseUrl = `${protocol}://${req.headers.host}`; - const ext = file.filename.split(".").pop(); - return stat(join(uploadDir, `${slug}.${ext}`)) - .then(async (stats) => { - let vw = file.views; - if ((req.session as CustomSession).userId !== file.userId) { - const { views } = await this.prismaService.file.update({ - where: { slug }, - data: { views: file.views + 1 }, - }); - vw = views; - } - const isVideo = lookUp(file.filename).includes("video"); - const isImage = lookUp(file.filename).includes("image"); - const isAudio = lookUp(file.filename).includes("audio"); - const cannotDisplay = !isImage && !isVideo && !isAudio; - const timezone = new Date().getTimezoneOffset() / 60; + let oembed: string; + let url: string; + let baseUrl: string; + let vw = file.views; - const { - user: { embed_settings }, - } = file; + if ( + !existsSync(join(uploadDir, `${file.slug}.${ext}`)) && + process.env.UPLOADER === "local" + ) { + throw new NotFoundException(); + } - return { - oembed: `${baseUrl}/${slug}.json`, - url: `${baseUrl}/${slug}.${ext}`, - title: embed_settings.enabled ? embed_settings?.title : null, - description: embed_settings.enabled - ? embed_settings?.description - : null, - color: embed_settings?.color ?? generateRandomHexColor(), - ogType: isVideo ? "video.other" : isImage ? "image" : "website", - urlType: isVideo ? "video" : isAudio ? "audio" : "image", - mimetype: lookUp(file.filename), - filename: file.filename, - slug: file.slug + "." + file.filename.split(".").pop(), - size: formatBytes(stats.size), - username: file.user.username, - embed_enabled: embed_settings?.enabled, - views: vw, - timestamp: formatDate(file.createdAt) + ` (UTC${timezone})`, - isVideo, - isImage, - isAudio, - cannotDisplay, - id: file.id, - }; - }) - .catch((err) => { - if (err.code === "ENOENT") { - throw new NotFoundException(); - } else { - this.logger.error(err.message); - throw new InternalServerErrorException("Something went wrong"); - } + if ((req.session as CustomSession).userId !== file.userId) { + const { views } = await this.prismaService.file.update({ + where: { slug }, + data: { views: file.views + 1 }, }); + vw = views; + } + + const isVideo = lookUp(file.filename).includes("video"); + const isImage = lookUp(file.filename).includes("image"); + const isAudio = lookUp(file.filename).includes("audio"); + const cannotDisplay = !isImage && !isVideo && !isAudio; + const timezone = new Date().getTimezoneOffset() / 60; + + if (process.env.UPLOADER === "s3") { + baseUrl = + process.env.CDN_URL ?? + "https://" + process.env.BUCKET_NAME + process.env.S3_ENDPOINT; + oembed = `${baseUrl}/${slug}.json`; + url = `${baseUrl}/${slug}.${ext}`; + } else { + baseUrl = `${protocol}://${req.headers.host}`; + oembed = `${baseUrl}/${slug}.json`; + url = `${baseUrl}/${slug}.${ext}`; + } + + const { + user: { embed_settings }, + } = file; + + return { + oembed, + url, + title: embed_settings.enabled ? embed_settings?.title : null, + description: embed_settings.enabled ? embed_settings?.description : null, + color: embed_settings?.color ?? generateRandomHexColor(), + ogType: isVideo ? "video.other" : isImage ? "image" : "website", + urlType: isVideo ? "video" : isAudio ? "audio" : "image", + mimetype: lookUp(file.filename), + filename: file.filename, + slug: file.slug + "." + file.filename.split(".").pop(), + size: formatBytes(file.size), + username: file.user.username, + embed_enabled: embed_settings?.enabled, + views: vw, + timestamp: formatDate(file.createdAt) + ` (UTC${timezone})`, + isVideo, + isImage, + isAudio, + cannotDisplay, + id: file.id, + }; } async getStatistics() { diff --git a/api/src/modules/s3/s3.controller.ts b/api/src/modules/s3/s3.controller.ts index 57e1508..35793a6 100644 --- a/api/src/modules/s3/s3.controller.ts +++ b/api/src/modules/s3/s3.controller.ts @@ -1,20 +1,17 @@ import { Controller, - Get, - Param, Post, Request, - Response, UploadedFile, - UseGuards, UseInterceptors, } from "@nestjs/common"; import { FileInterceptor } from "@nestjs/platform-express"; -import { S3Service } from "./s3.service"; -import { Request as ERequest, Response as EResponse } from "express"; -import { AuthGuard } from "modules/auth/guard/auth.guard"; +import { SkipThrottle } from "@nestjs/throttler"; +import { Request as ERequest } from "express"; import { ROUTES } from "lib/constants"; +import { S3Service } from "./s3.service"; +@SkipThrottle() @Controller(ROUTES.UPLOAD) export class S3Controller { constructor(private readonly s3Service: S3Service) {} @@ -28,13 +25,8 @@ export class S3Controller { return this.s3Service.uploadFile(req, file); } - @UseGuards(AuthGuard) - @UseInterceptors(FileInterceptor("files")) @Post("bulk") - uploadBulk( - @UploadedFile() files: Express.Multer.File[], - @Request() req: ERequest - ) { - return this.s3Service.bulkUpload(req, files); + uploadBulk(@Request() req: ERequest) { + return this.s3Service.bulkUpload(req); } } diff --git a/api/src/modules/s3/s3.service.ts b/api/src/modules/s3/s3.service.ts index 518e354..8722dc6 100644 --- a/api/src/modules/s3/s3.service.ts +++ b/api/src/modules/s3/s3.service.ts @@ -3,14 +3,20 @@ import { Injectable, InternalServerErrorException, Logger, + NotFoundException, UnauthorizedException, } from "@nestjs/common"; import { EmbedSettings } from "@prisma/client"; import { Client, ItemBucketMetadata } from "minio"; import { Request, Response } from "express"; import { CustomSession } from "lib/types"; -import { generateApiKey, lookUp } from "lib/utils"; +import { generateRandomString, lookUp } from "lib/utils"; import { PrismaService } from "modules/prisma/prisma.service"; +import md5 from "md5"; +import { createReadStream, existsSync } from "fs"; +import { appendFile, readFile, unlink, writeFile } from "fs/promises"; +import { join } from "path"; +import { uploadDir } from "lib/constants"; @Injectable() export class S3Service { @@ -45,6 +51,8 @@ export class S3Service { "Content-Type": "application/json", }; + delete data.userId; + return this.s3.putObject( this.bucketName, `${oembed.filename}.json`, @@ -73,7 +81,7 @@ export class S3Service { throw new BadRequestException("Invalid API key"); } - const slug = generateApiKey(12); + const slug = generateRandomString(12); const extension = file.originalname.split(".").pop(); const filename = slug + "." + extension; @@ -92,12 +100,23 @@ export class S3Service { }); } - await this.s3 - .putObject(this.bucketName, filename, file.buffer, params) - .catch((err) => { - this.logger.error(err.message); - throw new InternalServerErrorException("Server error"); - }); + await Promise.all([ + this.prismaService.file.create({ + data: { + filename: file.originalname, + slug, + userId: user.id, + size: file.size, + mimetype: file.mimetype, + }, + }), + this.s3 + .putObject(this.bucketName, filename, file.buffer, params) + .catch((err) => { + this.logger.error(err.message); + throw new InternalServerErrorException("Server error"); + }), + ]); const protocol = req.headers["x-forwarded-proto"] || "http"; @@ -108,28 +127,15 @@ export class S3Service { }; } - async deleteFile(key: string, res: Response) { - if (!key) { - throw new BadRequestException("Missing key."); + async bulkUpload(req: Request) { + const apiKey = req.headers["authorization"] as string; + + if (!apiKey) { + throw new BadRequestException("Authorization header is missing"); } - await this.s3.removeObject(this.bucketName, key, (error) => { - if (error.message.includes("The specified key does not exist.")) { - throw new BadRequestException("File does not exist"); - } else { - this.logger.error(error.message); - throw new InternalServerErrorException("Server error"); - } - }); - - return res.status(200).json({ message: "File deleted successfully" }); - } - - async bulkUpload(req: Request, files: Express.Multer.File[]) { - const userId = (req.session as CustomSession).userId; - const user = await this.prismaService.user.findUnique({ - where: { id: userId }, + where: { apiKey }, include: { embed_settings: true }, }); @@ -137,43 +143,124 @@ export class S3Service { throw new UnauthorizedException("Invalid session"); } - const { embed_settings } = user; - try { - const promises = files.map(async (file) => { - const slug = generateApiKey(12); - const extension = file.originalname.split(".").pop(); + const tmp = await this.prismaService.file.aggregate({ + where: { + user: { apiKey }, + }, + _sum: { size: true }, + }); + + const final = Math.round(tmp._sum.size / 1e6); + + if (final > user.uploadLimit && user.uploadLimit !== 0) { + throw new BadRequestException( + "You have no space left for upload, maybe delete a few files first?" + ); + } + + const name = decodeURIComponent(req.headers["x-file-name"] as string); + const size = req.headers["x-file-size"] as string; + const currentChunk = req.headers["x-current-chunk"] as string; + const totalChunks = req.headers["x-total-chunks"] as string; + + const firstChunk = +currentChunk === 0; + const lastChunk = +currentChunk === +totalChunks - 1; + const ext = name.split(".").pop(); + const data = req.body.toString().split(",")[1]; + const buffer = Buffer.from(data, "base64"); + const id: string = md5(name + req.ip); + const tmpName = `tmp_${id}.${ext}`; + + if (firstChunk && existsSync(join(uploadDir, tmpName))) { + await unlink(join(uploadDir, tmpName)); + } + + await writeFile(join(uploadDir, tmpName), buffer, { flag: "a" }) + .then(() => { + this.logger.debug(`Uploaded chunk ${currentChunk} of ${totalChunks}`); + }) + .catch((err) => { + this.logger.error(err.message); + throw new InternalServerErrorException("Server error"); + }); + + if (lastChunk) { + const { embed_settings } = user; + try { + const slug = generateRandomString(12); + const extension = name.split(".").pop(); const filename = slug + "." + extension; + const mimetype = lookUp(name); const params: ItemBucketMetadata = { - "Content-Length": file.size, - "Content-Type": file.mimetype, + "Content-Length": size, + "Content-Type": mimetype, "Content-Disposition": "inline", }; - if ( - lookUp(file.originalname).includes("image") && - embed_settings?.enabled - ) { + if (mimetype.includes("image") && embed_settings?.enabled) { await this.createOEmbedJSON({ filename: slug, ...embed_settings, }); } - return this.s3.putObject( - this.bucketName, - filename, - file.buffer, - params - ); - }); + await Promise.all([ + this.prismaService.file.create({ + data: { + filename: name, + slug, + userId: user.id, + size: +size, + mimetype, + }, + }), + this.s3.fPutObject( + this.bucketName, + filename, + join(uploadDir, tmpName), + params + ), + ]); - return Promise.all(promises); - } catch (error) { - this.logger.error(error.message); - throw new InternalServerErrorException( - "Something went wrong in our end, please try again later." - ); + return { final: id }; + } catch (error) { + this.logger.error(error.message); + throw new InternalServerErrorException( + "Something went wrong in our end, please try again later." + ); + } } } + + async deleteFile(key: string, res: Response) { + if (!key) { + throw new BadRequestException("Missing key."); + } + + const file = await this.prismaService.file.findUnique({ + where: { id: key }, + }); + + if (!file) { + throw new NotFoundException("File does not exist"); + } + + const ext = file.filename.split(".").pop(); + const full = `${file.slug}.${ext}`; + + await Promise.all([ + this.prismaService.file.delete({ where: { id: key } }), + this.s3.removeObject(this.bucketName, full, (error) => { + if (error.message.toLowerCase().includes("exist")) { + throw new NotFoundException("File does not exist"); + } else { + this.logger.error(error.message); + throw new InternalServerErrorException("Server error"); + } + }), + ]); + + return res.status(200).json({ message: "File deleted successfully" }); + } } diff --git a/api/src/modules/upload/upload.controller.ts b/api/src/modules/upload/upload.controller.ts index 4158fc3..96cccf9 100644 --- a/api/src/modules/upload/upload.controller.ts +++ b/api/src/modules/upload/upload.controller.ts @@ -14,7 +14,9 @@ import { FileInterceptor } from "@nestjs/platform-express"; import { UploadService } from "./upload.service"; import { Request as ERequest, Response as EResponse } from "express"; import { ROUTES } from "lib/constants"; +import { SkipThrottle } from "@nestjs/throttler"; +@SkipThrottle() @Controller(ROUTES.UPLOAD) export class UploadController { constructor(private readonly uploadService: UploadService) {} diff --git a/api/src/modules/upload/upload.service.ts b/api/src/modules/upload/upload.service.ts index e9e316e..a9e6698 100644 --- a/api/src/modules/upload/upload.service.ts +++ b/api/src/modules/upload/upload.service.ts @@ -10,7 +10,7 @@ import { Request, Response } from "express"; import { createWriteStream, existsSync } from "fs"; import { rename, stat, unlink, writeFile } from "fs/promises"; import { uploadDir } from "lib/constants"; -import { generateApiKey, lookUp } from "lib/utils"; +import { generateRandomString, lookUp } from "lib/utils"; import { PrismaService } from "modules/prisma/prisma.service"; import { join } from "path"; import md5 from "md5"; @@ -32,6 +32,8 @@ export class UploadService { const { filename } = oembed; + delete data.userId; + const stream = createWriteStream(join(uploadDir, filename + ".json"), { flags: "w", }); @@ -97,7 +99,25 @@ export class UploadService { throw new BadRequestException("Invalid API key"); } - const slug = generateApiKey(12); + const tmp = await this.prismaService.file.aggregate({ + where: { + user: { + apiKey: apikey, + }, + }, + _sum: { size: true }, + }); + + if ( + (tmp._sum.size > user.uploadLimit && user.role !== "OWNER") || + user.uploadLimit !== 0 + ) { + throw new BadRequestException( + "You have no space left for upload, maybe delete a few files first?" + ); + } + + const slug = generateRandomString(12); const ext = file.originalname.split(".").pop(); const stream = createWriteStream(join(uploadDir, `${slug}.${ext}`), { @@ -167,6 +187,23 @@ export class UploadService { throw new BadRequestException("Invalid API key"); } + const tmp = await this.prismaService.file.aggregate({ + where: { + user: { + apiKey: apikey, + }, + }, + _sum: { size: true }, + }); + + const final = Math.round(tmp._sum.size / 1e6); + + if (final > user.uploadLimit && user.uploadLimit !== 0) { + throw new BadRequestException( + "You have no space left for upload, maybe delete a few files first?" + ); + } + const name = decodeURIComponent(req.headers["x-file-name"] as string); const size = req.headers["x-file-size"] as string; const currentChunk = req.headers["x-current-chunk"] as string; @@ -186,17 +223,21 @@ export class UploadService { await writeFile(join(uploadDir, tmpName), buffer, { flag: "a" }); if (lastChunk) { const mimetype = lookUp(name); - let slug = generateApiKey(12); + let slug = generateRandomString(12); await rename( join(uploadDir, tmpName), join(uploadDir, `${slug}.${ext}`) ).catch(async (reason) => { if (reason === "EEXIST") { - slug = generateApiKey(12); + slug = generateRandomString(12); await rename( join(uploadDir, tmpName), join(uploadDir, `${slug}.${ext}`) ); + await this.prismaService.file.update({ + where: { slug: tmpName.split(".").shift() }, + data: { slug }, + }); } else { this.logger.error(reason); throw new InternalServerErrorException( @@ -214,7 +255,7 @@ export class UploadService { ) { this.createOEmbedJSON({ filename: name, - ...user.embed_settings, + ...embed_settings, }); } diff --git a/api/src/modules/users/users.controller.ts b/api/src/modules/users/users.controller.ts index 91459d3..77a26a8 100644 --- a/api/src/modules/users/users.controller.ts +++ b/api/src/modules/users/users.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Post, Put, @@ -11,6 +12,7 @@ import { UsePipes, ValidationPipe, } from "@nestjs/common"; +import { SkipThrottle, Throttle } from "@nestjs/throttler"; import { Request as ERequest } from "express"; import { ROUTES } from "lib/constants"; import { CustomSession } from "lib/types"; @@ -22,10 +24,12 @@ import { EmbedSettingDTO } from "./dto/EmbedSettingsDTO"; import { ResetPasswordDTO } from "./dto/ResetPasswordDTO"; import { UsersService } from "./users.service"; +@SkipThrottle() @Controller(ROUTES.USERS) export class UsersController { constructor(private readonly usersService: UsersService) {} + @SkipThrottle(false) @UseGuards(AuthGuard) @Post("verify/send") async sendVerifyMail(@Request() req: ERequest) { @@ -40,11 +44,13 @@ export class UsersController { return this.usersService.verifyEmail(token); } + @SkipThrottle(false) @Post("forgot-password") async forgotPassword(@Body() { email }: { email: string }) { return this.usersService.sendForgotPasswordEmail(email); } + @SkipThrottle(false) @Post("check-token") async checkToken(@Body() { token }: { token: string }) { return this.usersService.checkToken(token); @@ -102,6 +108,7 @@ export class UsersController { ); } + @SkipThrottle(false) @UseGuards(AuthGuard) @UsePipes(new ValidationPipe({ transform: true })) @UseFilters(new HttpExceptionFilter()) @@ -118,6 +125,7 @@ export class UsersController { ); } + @SkipThrottle(false) @UseGuards(AuthGuard) @UsePipes(new ValidationPipe({ transform: true })) @UseFilters(new HttpExceptionFilter()) @@ -128,4 +136,22 @@ export class UsersController { ) { return this.usersService.changeUsername(username, newUsername); } + + @SkipThrottle(false) + @Throttle(1, 300) + @UseGuards(AuthGuard) + @Put("regenerate-api-key") + async regnerateApiKey(@Request() req: ERequest) { + return this.usersService.regenerateApiKey( + (req.session as CustomSession).userId + ); + } + + @UseGuards(AuthGuard) + @Delete("delete-account") + async deleteAccount(@Request() req: ERequest) { + return this.usersService.deleteAccount( + (req.session as CustomSession).userId + ); + } } diff --git a/api/src/modules/users/users.service.ts b/api/src/modules/users/users.service.ts index b0af7e8..94dc118 100644 --- a/api/src/modules/users/users.service.ts +++ b/api/src/modules/users/users.service.ts @@ -5,7 +5,7 @@ import { Logger, UnauthorizedException, } from "@nestjs/common"; -import { EmbedSettings, User } from "@prisma/client"; +import { User } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import * as argon from "argon2"; import { MailService } from "../mail/mail.service"; @@ -17,7 +17,7 @@ import { SessionUser, UserResponse, } from "../../lib/types"; -import { formatBytes, generateApiKey } from "../../lib/utils"; +import { formatBytes, generateRandomString } from "../../lib/utils"; import { RegisterDTO } from "../auth/dto/register.dto"; import { PrismaService } from "../prisma/prisma.service"; import { Request } from "express"; @@ -33,6 +33,7 @@ import { import { join } from "path"; import { readFile } from "fs/promises"; import { EmbedSettingDTO } from "./dto/EmbedSettingsDTO"; +import cuid from "cuid"; @Injectable() export class UsersService implements IUserService { @@ -48,15 +49,19 @@ export class UsersService implements IUserService { async findUser( username_or_email: string, - { byId, withPassword }: findUserOptions = { + { byId, withPassword, totalUsed }: findUserOptions = { byId: false, withPassword: false, + totalUsed: false, } ): Promise { if (!username_or_email) { throw new BadRequestException("Invalid request"); } let user: User; + let total: number; + + // get total used space of user if (byId) { user = await this.prisma.user.findUnique({ @@ -64,38 +69,48 @@ export class UsersService implements IUserService { id: username_or_email, }, }); + if (totalUsed) { + const tmp = await this.prisma.file.aggregate({ + where: { + userId: username_or_email, + }, + _sum: { + size: true, + }, + }); + + // convert to mb + total = Math.round(tmp._sum.size / 1000000); + } } else { user = await this.prisma.user.findUnique({ where: username_or_email.includes("@") ? { email: username_or_email } : { username: username_or_email }, }); + + if (totalUsed) { + const tmp = await this.prisma.file.aggregate({ + where: { + userId: user.id, + }, + _sum: { + size: true, + }, + }); + + total = Math.round(tmp._sum.size / 1000000); + } } !withPassword && delete user.password; - return user ?? null; + // @ts-ignore + if (totalUsed) return { ...user, total }; + + return user; } - // validateUserInput(dto: T) { - // const errors = []; - - // for (const [key, value] of Object.entries(dto)) { - // if (value === undefined) { - // errors.push({ - // field: key, - // message: "Field is required", - // }); - // } - // } - - // if (errors.length > 0) { - // throw new BadRequestException({ errors }); - // } - - // return true; - // } - validateUsername(username: string) { if (username.length < 3) { throw new BadRequestException({ @@ -179,7 +194,6 @@ export class UsersService implements IUserService { }); } - await this.redis.del(INVITE_PREFIX + invite); inv = inviter; } @@ -202,19 +216,20 @@ export class UsersService implements IUserService { } try { - const avatarHash = md5(email.trim().toLowerCase()); + const avatarHash = md5(generateRandomString(32) + Date.now().toString()); const hashedPassword = await argon.hash(password); const user = await this.prisma.user.create({ data: { email, username: username ? username : generateUsername("_"), password: hashedPassword, - apiKey: generateApiKey(), - // image: `https://www.gravatar.com/avatar/${avatarHash}`, + apiKey: generateRandomString(), image: `https://avatars.dicebear.com/api/identicon/${avatarHash}.svg`, invitedBy: inviter ? inviter : null, }, }); + await this.redis.del(INVITE_PREFIX + invite); + delete user.password; (req.session as CustomSession).userId = user.id; @@ -278,7 +293,7 @@ export class UsersService implements IUserService { throw new BadRequestException("email already verified"); } try { - const token = generateApiKey(32); + const token = generateRandomString(32); await this.redis.set( CONFIRM_EMAIL_PREFIX + token, @@ -534,6 +549,14 @@ export class UsersService implements IUserService { throw new UnauthorizedException("not authorized"); } + if ( + user.email === "root@localhost" || + user.username === "root" || + user.role === "OWNER" + ) { + throw new BadRequestException("You cannot delete root account"); + } + await this.prisma.user.delete({ where: { id } }); return true; @@ -571,7 +594,7 @@ export class UsersService implements IUserService { } try { - const token = generateApiKey(64); + const token = generateRandomString(64); await this.redis.set( FORGOT_PASSWORD_PREFIX + token, @@ -650,4 +673,21 @@ export class UsersService implements IUserService { return true; } + + async regenerateApiKey(id: string) { + const user = await this.findUser(id, { byId: true }); + + if (!user) { + throw new UnauthorizedException("not authorized"); + } + + const apiKey = cuid(); + + await this.prisma.user.update({ + where: { id: user.id }, + data: { apiKey }, + }); + + return apiKey; + } } diff --git a/api/yarn.lock b/api/yarn.lock index a36b0e5..e03e104 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -1297,6 +1297,15 @@ __metadata: languageName: node linkType: hard +"@types/cuid@npm:^2": + version: 2.0.1 + resolution: "@types/cuid@npm:2.0.1" + dependencies: + cuid: "*" + checksum: 54eef4216441081cb3e6159315a353da7b72d6fda02830af68dc4fd2a4d95908673f32304cbf522ad032053e7bbe4ecb03be94983bdc15e47348cdc982d85b3f + languageName: node + linkType: hard + "@types/eslint-scope@npm:^3.7.3": version: 3.7.4 resolution: "@types/eslint-scope@npm:3.7.4" @@ -2087,6 +2096,7 @@ __metadata: "@types/body-parser": ^1 "@types/connect-redis": ^0.0.19 "@types/cron": ^2 + "@types/cuid": ^2 "@types/express": ^4.17.13 "@types/express-session": ^1 "@types/jest": 28.1.8 @@ -2104,6 +2114,7 @@ __metadata: class-validator: ^0.13.2 connect-redis: ^6.1.3 cron: ^2.1.0 + cuid: ^2.1.8 eslint: ^8.29.0 eslint-config-prettier: ^8.3.0 eslint-plugin-prettier: ^4.0.0 @@ -3216,6 +3227,13 @@ __metadata: languageName: node linkType: hard +"cuid@npm:*, cuid@npm:^2.1.8": + version: 2.1.8 + resolution: "cuid@npm:2.1.8" + checksum: 12b85b3f5150a6f0b9e4f345c8d98299d74647419151751fa1132f6702fe2b5388ceceb1c9b49bad7cab1b3ac033c7cedea8ffc034930f90005b8a4345025288 + languageName: node + linkType: hard + "cycle@npm:1.0.x": version: 1.0.3 resolution: "cycle@npm:1.0.3" diff --git a/web/package.json b/web/package.json index f298676..ad31b65 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "@mantine/dropzone": "^5.8.4", "@mantine/form": "^5.8.3", "@mantine/hooks": "^5.8.2", + "@mantine/modals": "^5.9.5", "@mantine/next": "^5.8.2", "@mantine/notifications": "^5.9.1", "@tabler/icons": "^1.112.0", diff --git a/web/src/components/layouts/RootProvider/AuthWrapper.tsx b/web/src/components/layouts/RootProvider/AuthWrapper.tsx index f015fda..0d2bee0 100644 --- a/web/src/components/layouts/RootProvider/AuthWrapper.tsx +++ b/web/src/components/layouts/RootProvider/AuthWrapper.tsx @@ -1,13 +1,16 @@ import LoadingPage from '@components/pages/LoadingPage'; import { ROUTES } from '@lib/constants'; import { useIsAuth } from '@lib/hooks'; -import React, { FC, Suspense } from 'react'; +import React, { FC, ReactElement, Suspense, useEffect } from 'react'; import dynamic from 'next/dynamic'; +import Router from 'next/router'; +import { CustomPageOptions } from '@lib/types'; const Layout = dynamic(() => import('..'), { suspense: true }); -const AuthWrapper: FC<{ children: any; withLayout?: boolean }> = ({ +const AuthWrapper: FC = ({ children, withLayout, + admin, }) => { const currentUrl = typeof window !== 'undefined' @@ -18,6 +21,18 @@ const AuthWrapper: FC<{ children: any; withLayout?: boolean }> = ({ callbackUrl: encodeURIComponent(currentUrl), }); + useEffect(() => { + if ( + !isLoading && + data && + admin && + data.role !== 'ADMIN' && + data.role !== 'OWNER' + ) { + void Router.push(ROUTES.ROOT); + } + }, [admin, data, isLoading]); + if (isLoading) return ; return withLayout ? ( diff --git a/web/src/components/layouts/RootProvider/index.tsx b/web/src/components/layouts/RootProvider/index.tsx index b41019b..91a96b7 100644 --- a/web/src/components/layouts/RootProvider/index.tsx +++ b/web/src/components/layouts/RootProvider/index.tsx @@ -4,6 +4,7 @@ import { NotificationsProvider } from '@mantine/notifications'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { FC } from 'react'; import dynamic from 'next/dynamic'; +import { ModalsProvider } from '@mantine/modals'; const AuthWrapper = dynamic(() => import('./AuthWrapper')); const RootProvider: FC = ({ pageProps, Component }) => { @@ -21,13 +22,15 @@ const RootProvider: FC = ({ pageProps, Component }) => { > - {Component.options?.auth ? ( - + + {Component.options?.auth ? ( + + + + ) : ( - - ) : ( - - )} + )} + diff --git a/web/src/components/layouts/Sidebar/index.tsx b/web/src/components/layouts/Sidebar/index.tsx index e79b867..9edcd2e 100644 --- a/web/src/components/layouts/Sidebar/index.tsx +++ b/web/src/components/layouts/Sidebar/index.tsx @@ -5,7 +5,9 @@ import { Burger, Drawer, Group, + LoadingOverlay, NavLink, + Progress, Stack, Text, UnstyledButton, @@ -14,8 +16,13 @@ import { import { useMediaQuery } from '@mantine/hooks'; import { IconBrandAppgallery, + IconBrandDiscord, + IconCircleDashed, + IconDots, IconGauge, IconHome2, + IconMailForward, + IconServer, IconSettings, IconUser, } from '@tabler/icons'; @@ -52,17 +59,17 @@ const items: Item[] = [ href: ROUTES.SETTINGS, children: [ { - icon: IconSettings, + icon: IconCircleDashed, label: 'General', href: ROUTES.SETTINGS, }, { - icon: IconSettings, + icon: IconBrandDiscord, label: 'Embed', href: ROUTES.SETTINGS + '/embed', }, { - icon: IconSettings, + icon: IconDots, label: 'Domains', href: ROUTES.SETTINGS + '/domains', }, @@ -75,17 +82,23 @@ const items: Item[] = [ admin: true, children: [ { - icon: IconUser, + icon: IconServer, label: 'Server', href: ROUTES.ADMIN + '/server', admin: true, }, { - icon: IconUser, + icon: IconMailForward, label: 'Invites', href: ROUTES.ADMIN + '/invites', admin: true, }, + { + icon: IconUser, + label: 'Users', + href: ROUTES.ADMIN + '/users', + owner: true, + }, ], }, ]; @@ -97,9 +110,48 @@ const Sidebar = () => { const mobile_screens = useMediaQuery('(max-width: 480px)'); const theme = useMantineTheme(); const admin = user?.role === 'OWNER' || user?.role === 'ADMIN'; + const owner = user?.role === 'OWNER'; + + const storageUsed = useMemo(() => { + if (!user) { + return ; + } + + // calculate storage used in percentage + const used = ((user.total ?? 0) / user.uploadLimit) * 100; + + return ( + + + {user.uploadLimit === 0 + ? `${user.total ?? 0} MB / Unlimited` + : `${user.total ?? 0} MB / ${user.uploadLimit} MB`} + + user.uploadLimit + ? 'red' + : 'blue' + } + /> + + ); + }, [user]); const links = useMemo(() => { - return (admin ? items : items.filter((item) => !item.admin)).map( + const final = owner + ? items + : items.map((item) => ({ + ...item, + children: item.children?.filter((child) => !child.owner), + })); + + return (admin ? final : final.filter((item) => !item.admin)).map( (item, index) => ( } @@ -117,13 +169,14 @@ const Sidebar = () => { component="a" key={index} active={router.pathname === child.href} + icon={} color="violet" /> ))} /> ) ); - }, [admin, router.pathname]); + }, [admin, owner, router.pathname]); return ( { opened={opened} onClose={() => setOpened(false)} > - - setOpened(!opened)} - /> - + + + setOpened(!opened)} + /> + - - {links} + + {links} + + + {storageUsed} ); diff --git a/web/src/components/pages/AdminPage/UserPage/TableContent.tsx b/web/src/components/pages/AdminPage/UserPage/TableContent.tsx new file mode 100644 index 0000000..b747422 --- /dev/null +++ b/web/src/components/pages/AdminPage/UserPage/TableContent.tsx @@ -0,0 +1,135 @@ +import { API_URL, API_ROUTES } from '@lib/constants'; +import { SessionUser, UpdateUsers } from '@lib/types'; +import { Switch, NumberInput, Group, Button, Text } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { IconCheck, IconX } from '@tabler/icons'; +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import React, { FC } from 'react'; +import { openConfirmModal } from '@mantine/modals'; + +const TableContent: FC<{ + element: SessionUser; + setData: React.Dispatch[]>>; +}> = ({ element, setData }) => { + const { mutateAsync: purgeFiles, isLoading: purgeLoading } = useMutation( + ['purge-files'], + (id: string) => + axios.delete(API_URL + API_ROUTES.PURGE_FILES + `?user=${id}`, { + withCredentials: true, + }) + ); + const confirmModal = () => + openConfirmModal({ + title: 'DANGER ZONE', + children: ( + <> + Are you sure you want to delete this user? + This action cannot be undone. + + ), + labels: { confirm: 'Delete', cancel: 'Cancel' }, + confirmProps: { color: 'red' }, + centered: true, + }); + + return ( + + + {element.username} + + + { + setData((prev) => [ + ...prev, + { + id: element.id, + role: e.target.checked ? 'ADMIN' : 'USER', + }, + ]); + }} + /> + + + { + setData((prev) => [ + ...prev, + { + id: element.id, + disabled: e.target.checked, + }, + ]); + }} + /> + + + { + setData((prev) => [...prev, { uploadLimit: e, id: element.id }]); + }} + /> + + + + + + + + + + + + + ); +}; + +export default TableContent; diff --git a/web/src/components/pages/AdminPage/UserPage/UserTable.tsx b/web/src/components/pages/AdminPage/UserPage/UserTable.tsx new file mode 100644 index 0000000..b262104 --- /dev/null +++ b/web/src/components/pages/AdminPage/UserPage/UserTable.tsx @@ -0,0 +1,95 @@ +import { API_URL, API_ROUTES } from '@lib/constants'; +import { UpdateUsers } from '@lib/types'; +import { Table, Group, Button, Text } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { IconCheck } from '@tabler/icons'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { FC, ReactElement } from 'react'; + +const UserTable: FC<{ + rows: JSX.Element | JSX.Element[]; + children: ReactElement; + userData: Partial[]; +}> = ({ rows, children, userData }) => { + const queryClient = useQueryClient(); + const { + mutateAsync: updateUsers, + isLoading: loading, + data: newData, + } = useMutation(['update-users'], (data: Partial[]) => + axios + .post(API_URL + API_ROUTES.MANAGE_USERS, data, { + withCredentials: true, + }) + .then((res) => res.data) + ); + + return ( +
+ + + + + + + + + + + + {rows} +
+ Username + AdminBanned + Upload Limit (MB) +
+ + 0 = Unlimited + +
+ Purge Files + + Delete User +
+ {children} + + + +
+ ); +}; + +export default UserTable; diff --git a/web/src/components/pages/AdminPage/UserPage/index.tsx b/web/src/components/pages/AdminPage/UserPage/index.tsx new file mode 100644 index 0000000..8968c72 --- /dev/null +++ b/web/src/components/pages/AdminPage/UserPage/index.tsx @@ -0,0 +1,80 @@ +import { API_ROUTES, API_URL, ROUTES } from '@lib/constants'; +import { SessionUser, UpdateUsers } from '@lib/types'; +import { + Divider, + Loader, + Pagination, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import Router from 'next/router'; +import React, { useMemo } from 'react'; +import LoadingPage from '@pages/LoadingPage'; +import UserTable from './UserTable'; +import TableContent from './TableContent'; + +const UserPage = () => { + const [page, setPage] = React.useState(1); + const [perPage] = React.useState(10); + const [search, setSearch] = React.useState(''); + const [userData, setData] = React.useState[]>([]); + const { data, isLoading } = useQuery( + ['manage-users'], + () => + axios + .get<{ users: SessionUser[]; totalPages: number }>( + API_URL + + API_ROUTES.MANAGE_USERS + + `?take=${perPage}&skip=${ + perPage * (page - 1) ?? 0 + }&search=${search}`, + { + withCredentials: true, + } + ) + .then((res) => res.data) + .catch((err) => { + if (err.response.status === 403) { + Router.replace(ROUTES.ROOT); + } + }), + { keepPreviousData: true, refetchOnWindowFocus: false } + ); + + const rows = useMemo(() => { + return data?.users ? ( + data.users.map((element, idx) => ( + + )) + ) : ( + + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data?.users]); + + if (isLoading) { + return ; + } + + return ( + <> + + Manage Users + + setSearch(e.currentTarget.value)} + label="Search Username" + w={{ base: '100%', lg: 420, md: 400, sm: 400 }} + /> + + + + + + ); +}; + +export default UserPage; diff --git a/web/src/components/pages/ProfilePage/DeleteAccount.tsx b/web/src/components/pages/ProfilePage/DeleteAccount.tsx new file mode 100644 index 0000000..82655b0 --- /dev/null +++ b/web/src/components/pages/ProfilePage/DeleteAccount.tsx @@ -0,0 +1,98 @@ +import { Button, Group, Text, TextInput } from '@mantine/core'; +import { useState } from 'react'; +import dynamic from 'next/dynamic'; +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import { API_ROUTES, API_URL } from '@lib/constants'; +import { useSignOut } from '@lib/hooks'; +import { showNotification } from '@mantine/notifications'; +import { IconCheck, IconExclamationMark } from '@tabler/icons'; +const Modal = dynamic(() => import('@mantine/core').then((mod) => mod.Modal)); + +const DeleteAccount = () => { + const [opened, setOpened] = useState(false); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const { signOut } = useSignOut(); + const { mutateAsync, isLoading } = useMutation(['delete-account'], () => + axios + .delete(API_URL + API_ROUTES.DELETE_ACCOUNT, { withCredentials: true }) + .catch((err) => { + throw new Error(err.response.data.message); + }) + ); + + return ( + <> + setOpened(false)} + centered + withCloseButton={false} + > + + Danger Zone + + + { + setError(''); + setConfirm(e.currentTarget.value); + }} + /> + + + + + + + + ); +}; + +export default DeleteAccount; diff --git a/web/src/components/pages/ProfilePage/index.tsx b/web/src/components/pages/ProfilePage/index.tsx index f893c66..dc8619e 100644 --- a/web/src/components/pages/ProfilePage/index.tsx +++ b/web/src/components/pages/ProfilePage/index.tsx @@ -20,6 +20,7 @@ import CredentialsForm from './CredentialsForm'; import { useSendVerificationEmail, useSignOut } from '@lib/hooks'; import { showNotification } from '@mantine/notifications'; import { profileStyles } from './styles'; +import DeleteAccount from './DeleteAccount'; const Modal = dynamic(() => import('@mantine/core').then((mod) => mod.Modal)); const ChangePasswordForm = dynamic(() => import('./ChangePasswordForm')); @@ -148,6 +149,8 @@ const ProfilePage = () => { + + ); diff --git a/web/src/components/pages/SettingPage/index.tsx b/web/src/components/pages/SettingPage/index.tsx index 2dd6db0..087447a 100644 --- a/web/src/components/pages/SettingPage/index.tsx +++ b/web/src/components/pages/SettingPage/index.tsx @@ -5,6 +5,7 @@ import { flameshotScript, APP_NAME, } from '@lib/constants'; +import { useRegenerateApiKey } from '@lib/hooks/useRegenerateApiKey'; import { Tabs, SimpleGrid, @@ -14,6 +15,8 @@ import { PasswordInput, Text, } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { IconCheck, IconX } from '@tabler/icons'; import { useAtom } from 'jotai'; import Head from 'next/head'; import { useRouter } from 'next/router'; @@ -22,7 +25,8 @@ import { useEffect, useCallback, FC } from 'react'; const SettingPage: FC<{ children?: any }> = ({ children }) => { const router = useRouter(); const activeTab = router.pathname.split('/')[3] || 'index'; - const [user] = useAtom(userAtom); + const [user, setUser] = useAtom(userAtom); + const { regen, isLoading } = useRegenerateApiKey(); useEffect(() => { router.prefetch(ROUTES.SETTINGS); @@ -113,7 +117,34 @@ const SettingPage: FC<{ children?: any }> = ({ children }) => { API Key - diff --git a/web/src/components/pages/UploadPage/ProgressCard.tsx b/web/src/components/pages/UploadPage/ProgressCard.tsx index adc996a..6c81c45 100644 --- a/web/src/components/pages/UploadPage/ProgressCard.tsx +++ b/web/src/components/pages/UploadPage/ProgressCard.tsx @@ -1,11 +1,13 @@ -import { Group, Paper, Progress, Stack, Text } from '@mantine/core'; +import { Avatar, Group, Paper, Progress, Stack, Text } from '@mantine/core'; +import { IconExclamationMark } from '@tabler/icons'; import { FC } from 'react'; const ProgressCard: FC<{ progress: number; filename: string; speed: string; -}> = ({ filename, progress, speed }) => { + error: boolean; +}> = ({ filename, progress, speed, error }) => { return ( <> @@ -20,11 +22,31 @@ const ProgressCard: FC<{ > {filename.length > 67 ? filename.slice(0, 67) + '...' : filename} - - {speed} - {progress}% - + {error ? ( + + + + ) : progress > 98 && progress < 100 ? ( + + Finalizing upload, please wait... + + ) : ( + + {progress === 100 ? 'Done' : speed} - {progress}% + + )} - + 98 && progress < 100 + ? 'yellow' + : progress === 100 + ? 'teal' + : 'blue' + } + /> diff --git a/web/src/components/pages/UploadPage/index.tsx b/web/src/components/pages/UploadPage/index.tsx index 10a5e6d..2af9f16 100644 --- a/web/src/components/pages/UploadPage/index.tsx +++ b/web/src/components/pages/UploadPage/index.tsx @@ -6,7 +6,13 @@ import { useMantineTheme, } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { IconCheck, IconCloudUpload, IconDownload, IconX } from '@tabler/icons'; +import { + IconCheck, + IconCloudUpload, + IconDownload, + IconExclamationMark, + IconX, +} from '@tabler/icons'; import dynamic from 'next/dynamic'; import { useEffect, useRef, useState } from 'react'; import { uploadStyles } from './styles'; @@ -23,6 +29,7 @@ const UploadZone = () => { const [user] = useAtom(userAtom); const theme = useMantineTheme(); const openRef = useRef<() => void>(null); + const [error, setError] = useState(false); const [files, setFiles] = useState([]); const [currentFileIndex, setCurrentFileIndex] = useState(null); const [lastUploadedFileIndex, setLastUploadedFileIndex] = useState< @@ -32,6 +39,7 @@ const UploadZone = () => { null ); const { classes } = uploadStyles(); + const [uploading, setUploading] = useState(false); const uploadChunk = (e: ProgressEvent) => { if (currentFileIndex === null) return; @@ -45,6 +53,7 @@ const UploadZone = () => { 'X-Total-Chunks': Math.ceil(file.size / CHUNK_SIZE), Authorization: user?.apiKey, }; + setUploading(true); axios .post(API_URL + API_ROUTES.UPLOAD_FILE, data, { headers, @@ -79,10 +88,31 @@ const UploadZone = () => { if (isLastFile) { setLastUploadedFileIndex(currentFileIndex); setCurrentFileIndex(null); + setUploading(false); } } else { setCurrentChunkIndex(currentChunkIndex! + 1); } + }) + .catch((err) => { + setError(true); + if (err.response.status === 400) { + showNotification({ + title: 'Error', + message: err.response.data.message, + color: 'red', + icon: , + autoClose: 5000, + }); + } else { + showNotification({ + title: 'Error', + message: 'Something went wrong, please try again later', + color: 'red', + icon: , + autoClose: 5000, + }); + } }); }; @@ -134,6 +164,7 @@ const UploadZone = () => { <>
setFiles([...files, ...dropzoneFiles])} onReject={(err) => { @@ -222,6 +253,7 @@ const UploadZone = () => { } return ( { const { data, isLoading, error, refetch } = useQuery(['invites'], () => @@ -11,6 +12,9 @@ export const useGetInvites = () => { }) .then((res) => res.data) .catch((error) => { + if (error.response.status === 403) { + Router.replace(ROUTES.ROOT); + } throw new Error(error.response.data.message); }) ); diff --git a/web/src/lib/hooks/useIsAuth.tsx b/web/src/lib/hooks/useIsAuth.tsx index 8c3f27a..e67963f 100644 --- a/web/src/lib/hooks/useIsAuth.tsx +++ b/web/src/lib/hooks/useIsAuth.tsx @@ -34,6 +34,7 @@ export const useIsAuth = ({ Router.push( `${redirectTo}${callbackUrl ? `?callbackUrl=${callbackUrl}` : ''}` ); + setUser(null); return null; }), { refetchOnWindowFocus: false } diff --git a/web/src/lib/hooks/useLogin.tsx b/web/src/lib/hooks/useLogin.tsx index 83d7198..4dc4984 100644 --- a/web/src/lib/hooks/useLogin.tsx +++ b/web/src/lib/hooks/useLogin.tsx @@ -23,7 +23,14 @@ export const useLogin = ({ callback }: { callback?: string } = {}) => { callback ? Router.push(callback) : Router.push(ROUTES.ROOT); } }) - .catch((err) => form.setErrors(toErrorMap(err.response.data.errors))) + .catch((err) => { + if (err.response.status === 429) { + form.setErrors({ + username_email: 'Too many login attempts, please try again later', + }); + } + form.setErrors(toErrorMap(err.response.data.errors)); + }) ); return { diff --git a/web/src/lib/hooks/useRegenerateApiKey.tsx b/web/src/lib/hooks/useRegenerateApiKey.tsx new file mode 100644 index 0000000..9b179a9 --- /dev/null +++ b/web/src/lib/hooks/useRegenerateApiKey.tsx @@ -0,0 +1,19 @@ +import { API_ROUTES, API_URL } from '@lib/constants'; +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; + +export const useRegenerateApiKey = () => { + const { mutateAsync, isLoading, data } = useMutation(() => + axios + .put( + API_URL + API_ROUTES.REGENERATE_API_KEY, + {}, + { withCredentials: true } + ) + .catch((err) => { + throw new Error(err.response.data.message); + }) + ); + + return { regen: mutateAsync, isLoading, apiKey: data }; +}; diff --git a/web/src/lib/hooks/useUpdateEmbedSettings.tsx b/web/src/lib/hooks/useUpdateEmbedSettings.tsx index 1886894..67cf3a7 100644 --- a/web/src/lib/hooks/useUpdateEmbedSettings.tsx +++ b/web/src/lib/hooks/useUpdateEmbedSettings.tsx @@ -8,7 +8,6 @@ import { useMutation } from '@tanstack/react-query'; import axios from 'axios'; export const useUpdateEmbedSettings = (data: Partial) => { - delete data.userId; const form = useForm>({ initialValues: { ...data, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d85531c..595fc09 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -19,6 +19,7 @@ export interface CustomAppProps extends AppProps { export interface CustomPageOptions { auth?: boolean; withLayout?: boolean; + admin?: boolean; } export type CustomNextPage

= NextPage & { @@ -41,6 +42,9 @@ export type SessionUser = { createdAt: Date; emailVerified: Date; apiKey: string; + total: number; + uploadLimit: number; + disabled: boolean; }; export interface Item { @@ -49,7 +53,8 @@ export interface Item { href: string; active?: boolean; admin?: boolean; - children?: Item[]; + owner?: boolean; + children?: Omit[]; } export interface NavbarLinkProps { @@ -138,3 +143,10 @@ export interface Chunk { start: number; end: number; } + +export interface UpdateUsers { + id: string; + role: Role; + disabled: boolean; + uploadLimit: number; +} diff --git a/web/src/pages/dashboard/admin/invites.tsx b/web/src/pages/dashboard/admin/invites.tsx index c1159e7..2c0eee2 100644 --- a/web/src/pages/dashboard/admin/invites.tsx +++ b/web/src/pages/dashboard/admin/invites.tsx @@ -32,4 +32,5 @@ export default AdminDash; AdminDash.options = { auth: true, withLayout: true, + admin: true, }; diff --git a/web/src/pages/dashboard/admin/server.tsx b/web/src/pages/dashboard/admin/server.tsx index 9e64535..780e977 100644 --- a/web/src/pages/dashboard/admin/server.tsx +++ b/web/src/pages/dashboard/admin/server.tsx @@ -17,8 +17,9 @@ const Owner: CustomNextPage = () => { ); useEffect(() => { - if (user?.role !== 'OWNER' && user?.role !== 'ADMIN') - router.push('/dashboard'); + if (user?.role !== 'OWNER' && user?.role !== 'ADMIN') { + void router.push('/dashboard'); + } }, [router, user?.role]); return isLoading ? : ; @@ -29,4 +30,5 @@ export default Owner; Owner.options = { auth: true, withLayout: true, + admin: true, }; diff --git a/web/src/pages/dashboard/admin/users.tsx b/web/src/pages/dashboard/admin/users.tsx new file mode 100644 index 0000000..6da4ef4 --- /dev/null +++ b/web/src/pages/dashboard/admin/users.tsx @@ -0,0 +1,22 @@ +import UserPage from '@pages/AdminPage/UserPage'; +import Head from 'next/head'; + +const ManageUsers = () => { + return ( + <> + + Dashboard | Manage Users + + + + + ); +}; + +export default ManageUsers; + +ManageUsers.options = { + auth: true, + withLayout: true, + admin: true, +}; diff --git a/web/src/pages/index.tsx b/web/src/pages/index.tsx index 80fad8e..baaacd8 100644 --- a/web/src/pages/index.tsx +++ b/web/src/pages/index.tsx @@ -1,25 +1,17 @@ import HomePage from '@pages/HomePage'; -import { API_ROUTES, API_URL } from '@lib/constants'; -import { SessionUser } from '@lib/types'; -import axios from 'axios'; -import { NextPage } from 'next'; -import { useEffect, useState } from 'react'; +import { useIsAuth } from '@lib/hooks'; +import LoadingPage from '@components/pages/LoadingPage'; -const Home: NextPage = () => { - const [user, setUser] = useState(); +const Home = () => { + const { data, isLoading } = useIsAuth(); - useEffect(() => { - axios - .get(API_URL + API_ROUTES.ME, { withCredentials: true }) - .then((res) => { - setUser(res.data); - }) - .catch(() => null); - }, []); + if (isLoading) { + return ; + } return ( <> - + ); }; diff --git a/web/yarn.lock b/web/yarn.lock index 20ffd2a..42e0a59 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -381,6 +381,20 @@ __metadata: languageName: node linkType: hard +"@mantine/modals@npm:^5.9.5": + version: 5.9.5 + resolution: "@mantine/modals@npm:5.9.5" + dependencies: + "@mantine/utils": 5.9.5 + peerDependencies: + "@mantine/core": 5.9.5 + "@mantine/hooks": 5.9.5 + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 6a10de6fe887f681845fa9f37fd58961687d42a29d06f9c5f53d239085b56b82986404aa2c3b1e16e62b9f997f213f6458948624158dacae312ca6a75701e636 + languageName: node + linkType: hard + "@mantine/next@npm:^5.8.2": version: 5.8.2 resolution: "@mantine/next@npm:5.8.2" @@ -466,6 +480,15 @@ __metadata: languageName: node linkType: hard +"@mantine/utils@npm:5.9.5": + version: 5.9.5 + resolution: "@mantine/utils@npm:5.9.5" + peerDependencies: + react: ">=16.8.0" + checksum: 12a4874b2319f418a5bf0287e2af33e7bcd0f8a3e8afce4f48d387f60bc0e1431423e402c821bfbf14462d7d7dd7f631c4ea70be1ca0737fbd85b98640b49288 + languageName: node + linkType: hard + "@next/env@npm:13.0.4": version: 13.0.4 resolution: "@next/env@npm:13.0.4" @@ -1163,6 +1186,7 @@ __metadata: "@mantine/dropzone": ^5.8.4 "@mantine/form": ^5.8.3 "@mantine/hooks": ^5.8.2 + "@mantine/modals": ^5.9.5 "@mantine/next": ^5.8.2 "@mantine/notifications": ^5.9.1 "@tabler/icons": ^1.112.0