s3 support and ui refactor

This commit is contained in:
renzynx 2022-12-25 14:45:18 +07:00
parent 90cb15753f
commit aaafe46472
50 changed files with 1398 additions and 264 deletions

10
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"terminal.integrated.fontFamily": "Hack",
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma"
},
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}

3
api/.gitignore vendored
View File

@ -10,5 +10,4 @@
!/.yarn/releases
!/.yarn/plugins
data
.env
.env.production
.env

View File

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

View File

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

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "disabled" BOOLEAN NOT NULL DEFAULT false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, any>): string {
return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<User | null> {
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<T>(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;
}
}

View File

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

View File

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

View File

@ -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<CustomPageOptions & { children: ReactElement }> = ({
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 <LoadingPage color="yellow" />;
return withLayout ? (

View File

@ -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<CustomAppProps> = ({ pageProps, Component }) => {
@ -21,13 +22,15 @@ const RootProvider: FC<CustomAppProps> = ({ pageProps, Component }) => {
>
<QueryClientProvider client={queryClient}>
<NotificationsProvider>
{Component.options?.auth ? (
<AuthWrapper withLayout={Component.options.withLayout}>
<ModalsProvider>
{Component.options?.auth ? (
<AuthWrapper withLayout={Component.options.withLayout}>
<Component {...pageProps} />
</AuthWrapper>
) : (
<Component {...pageProps} />
</AuthWrapper>
) : (
<Component {...pageProps} />
)}
)}
</ModalsProvider>
</NotificationsProvider>
</QueryClientProvider>
</MantineProvider>

View File

@ -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 <LoadingOverlay visible={!!user} />;
}
// calculate storage used in percentage
const used = ((user.total ?? 0) / user.uploadLimit) * 100;
return (
<Stack mx="auto" my="xl" spacing={5} sx={{ width: '90%' }}>
<Text align="center" size="sm">
{user.uploadLimit === 0
? `${user.total ?? 0} MB / Unlimited`
: `${user.total ?? 0} MB / ${user.uploadLimit} MB`}
</Text>
<Progress
mx="auto"
sx={{ width: '100%' }}
value={user.uploadLimit === 0 ? 100 : used}
color={
user.uploadLimit === 0
? 'violet'
: user.total > user.uploadLimit
? 'red'
: 'blue'
}
/>
</Stack>
);
}, [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) => (
<NavLink
icon={<item.icon />}
@ -117,13 +169,14 @@ const Sidebar = () => {
component="a"
key={index}
active={router.pathname === child.href}
icon={<child.icon />}
color="violet"
/>
))}
/>
)
);
}, [admin, router.pathname]);
}, [admin, owner, router.pathname]);
return (
<Drawer
@ -140,24 +193,34 @@ const Sidebar = () => {
opened={opened}
onClose={() => setOpened(false)}
>
<Group
position="right"
p="xl"
sx={{
width: '100%',
alignItems: 'center',
borderBottom: '1px solid #2C2E33',
}}
<Stack
spacing={0}
justify="space-between"
sx={{ height: '100%' }}
align="center"
>
<Burger
sx={{ marginLeft: 8 }}
opened={opened}
onClick={() => setOpened(!opened)}
/>
</Group>
<Stack spacing={0} sx={{ width: '100%' }}>
<Group
position="right"
p="xl"
sx={{
width: '100%',
alignItems: 'center',
borderBottom: '1px solid #2C2E33',
}}
>
<Burger
sx={{ marginLeft: 8 }}
opened={opened}
onClick={() => setOpened(!opened)}
/>
</Group>
<Stack mt={20} spacing={0}>
{links}
<Stack mt={20} spacing={0}>
{links}
</Stack>
</Stack>
{storageUsed}
</Stack>
</Drawer>
);

View File

@ -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<React.SetStateAction<Partial<UpdateUsers>[]>>;
}> = ({ 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: (
<>
<Text>Are you sure you want to delete this user?</Text>
<Text>This action cannot be undone.</Text>
</>
),
labels: { confirm: 'Delete', cancel: 'Cancel' },
confirmProps: { color: 'red' },
centered: true,
});
return (
<tr>
<td>
<Text align="center">{element.username}</Text>
</td>
<td>
<Switch
size="md"
defaultChecked={element.role === 'ADMIN'}
value={String(element.role === 'ADMIN')}
onLabel="ON"
offLabel="OFF"
onChange={(e) => {
setData((prev) => [
...prev,
{
id: element.id,
role: e.target.checked ? 'ADMIN' : 'USER',
},
]);
}}
/>
</td>
<td>
<Switch
size="md"
defaultChecked={element.disabled}
value={String(element.disabled)}
onLabel="ON"
offLabel="OFF"
onChange={(e) => {
setData((prev) => [
...prev,
{
id: element.id,
disabled: e.target.checked,
},
]);
}}
/>
</td>
<td style={{ width: '13%' }}>
<NumberInput
mx="auto"
value={element.uploadLimit}
onChange={(e) => {
setData((prev) => [...prev, { uploadLimit: e, id: element.id }]);
}}
/>
</td>
<td>
<Group>
<Button
loading={purgeLoading}
onClick={() => {
purgeFiles(element.id)
.then(() => {
showNotification({
title: 'Success',
message: `Purged files for ${element.username}`,
color: 'green',
icon: <IconCheck />,
autoClose: 3000,
});
})
.catch(() => {
showNotification({
title: 'Error',
message: 'Something went wrong',
color: 'red',
icon: <IconX />,
autoClose: 3000,
});
});
}}
variant="outline"
color="red"
mx="auto"
>
Purge Files
</Button>
</Group>
</td>
<td>
<Group>
<Button
onClick={confirmModal}
variant="outline"
color="red"
mx="auto"
>
Delete User
</Button>
</Group>
</td>
</tr>
);
};
export default TableContent;

View File

@ -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<UpdateUsers>[];
}> = ({ rows, children, userData }) => {
const queryClient = useQueryClient();
const {
mutateAsync: updateUsers,
isLoading: loading,
data: newData,
} = useMutation(['update-users'], (data: Partial<UpdateUsers>[]) =>
axios
.post<UpdateUsers[]>(API_URL + API_ROUTES.MANAGE_USERS, data, {
withCredentials: true,
})
.then((res) => res.data)
);
return (
<div>
<Table highlightOnHover withBorder>
<thead>
<tr>
<th>
<Text align="center">Username</Text>
</th>
<th>Admin</th>
<th>Banned</th>
<th>
Upload Limit (MB)
<br />
<Text size="xs" color="dimmed">
0 = Unlimited
</Text>
</th>
<th>
<Text align="center">Purge Files</Text>
</th>
<th>
<Text align="center">Delete User</Text>
</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
<Group position="center">{children}</Group>
<Group position="center" mt="xl">
<Button
variant="outline"
color="green"
loading={loading}
onClick={() => {
updateUsers(userData).then((data) => {
queryClient.setQueryData<{ users: UpdateUsers[] }>(
['manage-users'],
(prev) => {
if (!prev) return { users: [] };
const { users } = prev;
for (let i = 0; i < users.length; i++) {
for (let j = 0; j < data.length; j++) {
if (users[i].id === data[j].id) {
users[i] = data[j];
}
}
}
return { users };
}
);
showNotification({
title: 'Success',
message: 'Successfully updated users',
color: 'green',
icon: <IconCheck />,
autoClose: 3000,
});
});
}}
>
Save Changes
</Button>
</Group>
</div>
);
};
export default UserTable;

View File

@ -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<Partial<UpdateUsers>[]>([]);
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) => (
<TableContent element={element} setData={setData} key={idx} />
))
) : (
<Loader mx="auto" variant="dots" color="yellow" />
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.users]);
if (isLoading) {
return <LoadingPage color="yellow" />;
}
return (
<>
<Stack my="xl">
<Text size="xl">Manage Users</Text>
<Divider sx={{ width: '100%' }} />
<TextInput
onChange={(e) => setSearch(e.currentTarget.value)}
label="Search Username"
w={{ base: '100%', lg: 420, md: 400, sm: 400 }}
/>
</Stack>
<UserTable userData={userData} rows={rows}>
<Pagination onChange={setPage} mt="lg" total={data?.totalPages!} />
</UserTable>
</>
);
};
export default UserPage;

View File

@ -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 (
<>
<Modal
opened={opened}
onClose={() => setOpened(false)}
centered
withCloseButton={false}
>
<Text weight="bold" size="lg" color="red" my="xs">
Danger Zone
</Text>
<TextInput
label="Type 'delete' to confirm"
description="This action cannot be undone"
error={error}
value={confirm}
placeholder="delete"
onChange={(e) => {
setError('');
setConfirm(e.currentTarget.value);
}}
/>
<Group position="right" mt="md">
<Button variant="default" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
color="red"
loading={isLoading}
onClick={() => {
if (confirm === 'delete') {
mutateAsync()
.then(() => {
showNotification({
title: 'Success',
message:
'Your account has been deleted, you will be redirected to the home page in 5 seconds',
color: 'green',
icon: <IconCheck />,
autoClose: 5000,
});
setTimeout(() => {
signOut();
}, 5000);
})
.catch((err) => {
setError(err.message);
showNotification({
title: 'Error',
message: err.message,
color: 'red',
icon: <IconExclamationMark />,
autoClose: 5000,
});
});
} else {
setError('Please type "delete" to confirm');
}
}}
>
Delete Account
</Button>
</Group>
</Modal>
<Button onClick={() => setOpened(true)} color="red">
Delete Account
</Button>
</>
);
};
export default DeleteAccount;

View File

@ -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 = () => {
</Group>
<Divider mt="xl" mb="xl" />
<CredentialsForm username={user?.username!} />
<Divider mt="xl" mb="xl" />
<DeleteAccount />
</Box>
</Paper>
);

View File

@ -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
</Text>
<PasswordInput sx={{ width: '100%' }} value={user?.apiKey} />
<Button variant="light" color="violet">
<Button
loading={isLoading}
onClick={() =>
regen()
.then((res) => {
// @ts-ignore
setUser({ ...user, apiKey: res.data });
showNotification({
title: 'Success',
message: 'Your API Key has been regenerated',
color: 'green',
icon: <IconCheck />,
autoClose: 5000,
});
})
.catch((err) => {
showNotification({
title: 'Error',
message: err.message,
color: 'red',
icon: <IconX />,
autoClose: 5000,
});
})
}
variant="light"
color="violet"
>
Regenerate API Key
</Button>
</Stack>

View File

@ -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 (
<>
<Paper withBorder p="lg" my="xs" sx={{ width: '100%' }}>
@ -20,11 +22,31 @@ const ProgressCard: FC<{
>
{filename.length > 67 ? filename.slice(0, 67) + '...' : filename}
</Text>
<Text size="sm" color="dimmed">
{speed} - {progress}%
</Text>
{error ? (
<Avatar>
<IconExclamationMark size={20} color="red" />
</Avatar>
) : progress > 98 && progress < 100 ? (
<Text size="sm" color="dimmed">
Finalizing upload, please wait...
</Text>
) : (
<Text size="sm" color="dimmed">
{progress === 100 ? 'Done' : speed} - {progress}%
</Text>
)}
</Group>
<Progress size="sm" value={progress} />
<Progress
size="sm"
value={progress}
color={
progress > 98 && progress < 100
? 'yellow'
: progress === 100
? 'teal'
: 'blue'
}
/>
</Stack>
</Paper>
</>

View File

@ -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<File[]>([]);
const [currentFileIndex, setCurrentFileIndex] = useState<number | null>(null);
const [lastUploadedFileIndex, setLastUploadedFileIndex] = useState<
@ -32,6 +39,7 @@ const UploadZone = () => {
null
);
const { classes } = uploadStyles();
const [uploading, setUploading] = useState(false);
const uploadChunk = (e: ProgressEvent<FileReader>) => {
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: <IconExclamationMark />,
autoClose: 5000,
});
} else {
showNotification({
title: 'Error',
message: 'Something went wrong, please try again later',
color: 'red',
icon: <IconExclamationMark />,
autoClose: 5000,
});
}
});
};
@ -134,6 +164,7 @@ const UploadZone = () => {
<>
<div className={classes.wrapper}>
<Dropzone
loading={uploading}
openRef={openRef}
onDrop={(dropzoneFiles) => setFiles([...files, ...dropzoneFiles])}
onReject={(err) => {
@ -222,6 +253,7 @@ const UploadZone = () => {
}
return (
<ProgressCard
error={error}
key={idx}
filename={file.name}
progress={progress}

View File

@ -27,14 +27,18 @@ export enum API_ROUTES {
EMBED_SETTINGS = '/users/embed-settings',
CHANGE_PASSWORD = '/users/change-password',
CHANGE_USERNAME = '/users/change-username',
REGENERATE_API_KEY = '/users/regenerate-api-key',
FORGOT_PASSWORD = '/users/forgot-password',
RESET_PASSWORD = '/users/reset-password',
DELETE_ACCOUNT = '/users/delete-account',
CHECK_TOKEN = '/users/check-token',
SEND_VERIFICATION_EMAIL = '/users/verify/send',
VERIFY_EMAIL = '/users/verify',
SERVER_SETTINGS = '/server-settings',
UPDATE_SERVER_SETTINGS = '/admin/server-settings',
INVITE_CODE = '/admin/invites',
MANAGE_USERS = '/admin/users',
PURGE_FILES = '/admin/purge-files',
}
export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME ?? 'Bliss V2';

View File

@ -1,7 +1,8 @@
import { API_ROUTES, API_URL } from '@lib/constants';
import { API_ROUTES, API_URL, ROUTES } from '@lib/constants';
import { Invite } from '@lib/types';
import { useMutation, useQuery } from '@tanstack/react-query';
import axios from 'axios';
import Router from 'next/router';
export const useGetInvites = () => {
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);
})
);

View File

@ -34,6 +34,7 @@ export const useIsAuth = ({
Router.push(
`${redirectTo}${callbackUrl ? `?callbackUrl=${callbackUrl}` : ''}`
);
setUser(null);
return null;
}),
{ refetchOnWindowFocus: false }

View File

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

View File

@ -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<string>(
API_URL + API_ROUTES.REGENERATE_API_KEY,
{},
{ withCredentials: true }
)
.catch((err) => {
throw new Error(err.response.data.message);
})
);
return { regen: mutateAsync, isLoading, apiKey: data };
};

View File

@ -8,7 +8,6 @@ import { useMutation } from '@tanstack/react-query';
import axios from 'axios';
export const useUpdateEmbedSettings = (data: Partial<EmbedSettings>) => {
delete data.userId;
const form = useForm<Partial<EmbedSettings>>({
initialValues: {
...data,

View File

@ -19,6 +19,7 @@ export interface CustomAppProps extends AppProps {
export interface CustomPageOptions {
auth?: boolean;
withLayout?: boolean;
admin?: boolean;
}
export type CustomNextPage<P = {}, IP = P> = NextPage<P, IP> & {
@ -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<Item, 'children'>[];
}
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;
}

View File

@ -32,4 +32,5 @@ export default AdminDash;
AdminDash.options = {
auth: true,
withLayout: true,
admin: true,
};

View File

@ -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 ? <LoadingPage /> : <ServerPage {...data} />;
@ -29,4 +30,5 @@ export default Owner;
Owner.options = {
auth: true,
withLayout: true,
admin: true,
};

View File

@ -0,0 +1,22 @@
import UserPage from '@pages/AdminPage/UserPage';
import Head from 'next/head';
const ManageUsers = () => {
return (
<>
<Head>
<title>Dashboard | Manage Users</title>
</Head>
<UserPage />
</>
);
};
export default ManageUsers;
ManageUsers.options = {
auth: true,
withLayout: true,
admin: true,
};

View File

@ -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<SessionUser>();
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 <LoadingPage color="orange" />;
}
return (
<>
<HomePage user={user} />
<HomePage user={data!} />
</>
);
};

View File

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