mirror of https://github.com/renzynx/bliss.git
s3 support and ui refactor
This commit is contained in:
parent
90cb15753f
commit
aaafe46472
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"terminal.integrated.fontFamily": "Hack",
|
||||||
|
"[prisma]": {
|
||||||
|
"editor.defaultFormatter": "Prisma.prisma"
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": true
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,5 +10,4 @@
|
||||||
!/.yarn/releases
|
!/.yarn/releases
|
||||||
!/.yarn/plugins
|
!/.yarn/plugins
|
||||||
data
|
data
|
||||||
.env
|
.env
|
||||||
.env.production
|
|
|
@ -34,6 +34,7 @@
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.13.2",
|
||||||
"connect-redis": "^6.1.3",
|
"connect-redis": "^6.1.3",
|
||||||
"cron": "^2.1.0",
|
"cron": "^2.1.0",
|
||||||
|
"cuid": "^2.1.8",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"fast-folder-size": "^1.7.1",
|
"fast-folder-size": "^1.7.1",
|
||||||
"hbs": "^4.2.0",
|
"hbs": "^4.2.0",
|
||||||
|
@ -56,6 +57,7 @@
|
||||||
"@types/body-parser": "^1",
|
"@types/body-parser": "^1",
|
||||||
"@types/connect-redis": "^0.0.19",
|
"@types/connect-redis": "^0.0.19",
|
||||||
"@types/cron": "^2",
|
"@types/cron": "^2",
|
||||||
|
"@types/cuid": "^2",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/express-session": "^1",
|
"@types/express-session": "^1",
|
||||||
"@types/jest": "28.1.8",
|
"@types/jest": "28.1.8",
|
||||||
|
|
|
@ -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";
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "disabled" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -11,18 +11,21 @@ datasource db {
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
image String?
|
image String?
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
disabled Boolean @default(false)
|
||||||
role Role @default(USER)
|
password String
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
role Role @default(USER)
|
||||||
emailVerified DateTime? @map("email_verified")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
invitedBy String? @map("invited_by")
|
emailVerified DateTime? @map("email_verified")
|
||||||
apiKey String @unique @map("api_key")
|
invitedBy String? @map("invited_by")
|
||||||
|
apiKey String @unique @default(cuid()) @map("api_key")
|
||||||
|
uploadLimit Int @default(500) @map("upload_limit")
|
||||||
|
|
||||||
embed_settings EmbedSettings?
|
embed_settings EmbedSettings?
|
||||||
File File[]
|
files File[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,9 +12,16 @@ import { S3Module } from "./modules/s3/s3.module";
|
||||||
import { PrismaService } from "modules/prisma/prisma.service";
|
import { PrismaService } from "modules/prisma/prisma.service";
|
||||||
import { UploadModule } from "modules/upload/upload.module";
|
import { UploadModule } from "modules/upload/upload.module";
|
||||||
import { RedisService } from "modules/redis/redis.service";
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ThrottlerModule.forRoot({
|
||||||
|
ttl: 60,
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
ConfigModule.forRoot(),
|
ConfigModule.forRoot(),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
@ -22,7 +29,15 @@ import { RedisService } from "modules/redis/redis.service";
|
||||||
DeleteModule,
|
DeleteModule,
|
||||||
process.env.UPLOADER === "s3" ? S3Module : UploadModule,
|
process.env.UPLOADER === "s3" ? S3Module : UploadModule,
|
||||||
],
|
],
|
||||||
providers: [RootService, PrismaService, RedisService],
|
providers: [
|
||||||
|
RootService,
|
||||||
|
PrismaService,
|
||||||
|
RedisService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerBehindProxyGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
controllers: [RootController],
|
controllers: [RootController],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|
|
@ -15,7 +15,7 @@ const cleanUp = async () => {
|
||||||
const currentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const currentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
const job = new CronJob(
|
const job = new CronJob(
|
||||||
// every 24 hours
|
// every 24 hours at 12 AM
|
||||||
"0 0 * * *",
|
"0 0 * * *",
|
||||||
async () => {
|
async () => {
|
||||||
for (const file of tmpFiles) {
|
for (const file of tmpFiles) {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { createWriteStream, existsSync } from "fs";
|
||||||
import { mkdir } from "fs/promises";
|
import { mkdir } from "fs/promises";
|
||||||
import { logsDir, rootDir, uploadDir } from "./constants";
|
import { logsDir, rootDir, uploadDir } from "./constants";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { generateApiKey } from "./utils";
|
import { generateRandomString } from "./utils";
|
||||||
import md5 from "md5";
|
import md5 from "md5";
|
||||||
import argon from "argon2";
|
import argon from "argon2";
|
||||||
import { join } from "path";
|
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();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
await prisma.$connect();
|
await prisma.$connect();
|
||||||
|
@ -55,7 +49,7 @@ const ensure = async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const p = generateApiKey(64);
|
const p = generateRandomString(64);
|
||||||
const password = await argon.hash(p);
|
const password = await argon.hash(p);
|
||||||
|
|
||||||
const stream = createWriteStream(
|
const stream = createWriteStream(
|
||||||
|
@ -75,7 +69,7 @@ const ensure = async () => {
|
||||||
|
|
||||||
stream.on("finish", () => {
|
stream.on("finish", () => {
|
||||||
console.log(
|
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);
|
console.log(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
const avatarHash = md5("root@localhost");
|
const avatarHash = md5(generateRandomString(32) + Date.now().toString());
|
||||||
|
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: "root@localhost",
|
email: "root@localhost",
|
||||||
password,
|
password,
|
||||||
role: "OWNER",
|
role: "OWNER",
|
||||||
apiKey: generateApiKey(32),
|
|
||||||
username: "root",
|
username: "root",
|
||||||
emailVerified: new Date(Date.now()),
|
emailVerified: new Date(Date.now()),
|
||||||
// image: `https://www.gravatar.com/avatar/${md5("root@localhost")}`,
|
|
||||||
image: `https://avatars.dicebear.com/api/identicon/${avatarHash}.svg`,
|
image: `https://avatars.dicebear.com/api/identicon/${avatarHash}.svg`,
|
||||||
|
uploadLimit: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -102,4 +95,13 @@ const ensure = async () => {
|
||||||
await prisma.$disconnect();
|
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();
|
process.env.NODE_ENV === "production" && ensure();
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { User } from "@prisma/client";
|
import { Role, User } from "@prisma/client";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { Session, SessionData } from "express-session";
|
import { Session, SessionData } from "express-session";
|
||||||
import { RegisterDTO } from "modules/auth/dto/register.dto";
|
import { RegisterDTO } from "modules/auth/dto/register.dto";
|
||||||
|
@ -16,6 +16,7 @@ export interface UserResponse {
|
||||||
export interface findUserOptions {
|
export interface findUserOptions {
|
||||||
byId?: boolean;
|
byId?: boolean;
|
||||||
withPassword?: boolean;
|
withPassword?: boolean;
|
||||||
|
totalUsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserService {
|
export interface IUserService {
|
||||||
|
@ -44,3 +45,10 @@ export interface ServerSettings {
|
||||||
REGISTRATION_ENABLED: boolean;
|
REGISTRATION_ENABLED: boolean;
|
||||||
INVITE_MODE: boolean;
|
INVITE_MODE: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateUsers {
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
disabled: boolean;
|
||||||
|
uploadLimit: number;
|
||||||
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ export const toFieldError = (errors: string[]) => {
|
||||||
return fieldErrors;
|
return fieldErrors;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateApiKey = (len = 32) => {
|
export const generateRandomString = (len = 32) => {
|
||||||
return randomBytes(20).toString("hex").substring(0, len);
|
return randomBytes(20).toString("hex").substring(0, len);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import helmet from "helmet";
|
||||||
import bp from "body-parser";
|
import bp from "body-parser";
|
||||||
import "./lib/setup";
|
import "./lib/setup";
|
||||||
import "./lib/clean";
|
import "./lib/clean";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
@ -22,8 +23,23 @@ async function bootstrap() {
|
||||||
app.setBaseViewsDir(join(rootDir, "views"));
|
app.setBaseViewsDir(join(rootDir, "views"));
|
||||||
app.setViewEngine("hbs");
|
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(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({
|
app.enableCors({
|
||||||
credentials: true,
|
credentials: true,
|
||||||
origin: process.env.CORS_ORIGIN,
|
origin: process.env.CORS_ORIGIN,
|
||||||
|
|
|
@ -5,12 +5,16 @@ import {
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
Get,
|
Get,
|
||||||
|
Query,
|
||||||
|
Delete,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import { Request as ERequest } from "express";
|
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 { AuthGuard } from "modules/auth/guard/auth.guard";
|
||||||
import { AdminService } from "./admin.service";
|
import { AdminService } from "./admin.service";
|
||||||
|
|
||||||
|
@SkipThrottle()
|
||||||
@Controller("admin")
|
@Controller("admin")
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(private readonly adminService: AdminService) {}
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
@ -44,4 +48,38 @@ export class AdminController {
|
||||||
(req.session as CustomSession).userId
|
(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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,16 @@
|
||||||
import { ForbiddenException, Injectable } from "@nestjs/common";
|
import {
|
||||||
import { INVITE_PREFIX } from "lib/constants";
|
BadRequestException,
|
||||||
import { generateApiKey } from "lib/utils";
|
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 { PrismaService } from "modules/prisma/prisma.service";
|
||||||
import { RedisService } from "modules/redis/redis.service";
|
import { RedisService } from "modules/redis/redis.service";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
|
@ -82,7 +90,7 @@ export class AdminService {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const invite = generateApiKey(64);
|
const invite = generateRandomString(64);
|
||||||
|
|
||||||
await this.redis
|
await this.redis
|
||||||
.redis()
|
.redis()
|
||||||
|
@ -90,4 +98,118 @@ export class AdminService {
|
||||||
|
|
||||||
return invite;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,15 +52,7 @@ export class AuthController {
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Get("me")
|
@Get("me")
|
||||||
async me(@Request() req: ERequest, @Response() res: EResponse) {
|
me(@Request() req: ERequest) {
|
||||||
const user = await this.authService.me(
|
return this.authService.me((req.session as CustomSession).userId);
|
||||||
(req.session as CustomSession).userId
|
|
||||||
);
|
|
||||||
|
|
||||||
return res
|
|
||||||
.setHeader("Cache-Control", "no-store")
|
|
||||||
.setHeader("Pragma", "no-cache")
|
|
||||||
.setHeader("Content-Type", "application/json")
|
|
||||||
.json(user);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { CustomSession, UserResponse } from "../../lib/types";
|
||||||
import { UsersService } from "../users/users.service";
|
import { UsersService } from "../users/users.service";
|
||||||
import { LoginDTO } from "./dto/login.dto";
|
import { LoginDTO } from "./dto/login.dto";
|
||||||
|
@ -13,7 +18,14 @@ export class AuthService {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
async me(id: string) {
|
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> {
|
async login(data: LoginDTO, req: Request): Promise<UserResponse> {
|
||||||
|
@ -23,7 +35,6 @@ export class AuthService {
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
user: null,
|
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
field: "username_email",
|
field: "username_email",
|
||||||
|
@ -41,7 +52,6 @@ export class AuthService {
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
user: null,
|
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
field: "username_email",
|
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;
|
delete user.password;
|
||||||
|
|
||||||
(req.session as CustomSession).userId = user.id;
|
(req.session as CustomSession).userId = user.id;
|
||||||
|
|
|
@ -7,11 +7,13 @@ import {
|
||||||
Request,
|
Request,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import { Response as EResponse, Request as ERequest } from "express";
|
import { Response as EResponse, Request as ERequest } from "express";
|
||||||
import { AuthGuard } from "modules/auth/guard/auth.guard";
|
import { AuthGuard } from "modules/auth/guard/auth.guard";
|
||||||
import { RedisService } from "modules/redis/redis.service";
|
import { RedisService } from "modules/redis/redis.service";
|
||||||
import { RootService } from "./root.service";
|
import { RootService } from "./root.service";
|
||||||
|
|
||||||
|
@SkipThrottle()
|
||||||
@Controller()
|
@Controller()
|
||||||
export class RootController {
|
export class RootController {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ import {
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import fastFolderSize from "fast-folder-size";
|
import fastFolderSize from "fast-folder-size";
|
||||||
import { createReadStream } from "fs";
|
import { createReadStream, existsSync } from "fs";
|
||||||
import { stat } from "fs/promises";
|
import { stat } from "fs/promises";
|
||||||
import { thumbnailDir, uploadDir } from "lib/constants";
|
import { thumbnailDir, uploadDir } from "lib/constants";
|
||||||
import { CustomSession } from "lib/types";
|
import { CustomSession } from "lib/types";
|
||||||
|
@ -61,64 +61,72 @@ export class RootService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocol = req.headers["x-forwarded-proto"] || req.protocol;
|
const protocol = req.headers["x-forwarded-proto"] || req.protocol;
|
||||||
|
|
||||||
const baseUrl = `${protocol}://${req.headers.host}`;
|
|
||||||
|
|
||||||
const ext = file.filename.split(".").pop();
|
const ext = file.filename.split(".").pop();
|
||||||
|
|
||||||
return stat(join(uploadDir, `${slug}.${ext}`))
|
let oembed: string;
|
||||||
.then(async (stats) => {
|
let url: string;
|
||||||
let vw = file.views;
|
let baseUrl: string;
|
||||||
if ((req.session as CustomSession).userId !== file.userId) {
|
let vw = file.views;
|
||||||
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;
|
|
||||||
|
|
||||||
const {
|
if (
|
||||||
user: { embed_settings },
|
!existsSync(join(uploadDir, `${file.slug}.${ext}`)) &&
|
||||||
} = file;
|
process.env.UPLOADER === "local"
|
||||||
|
) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
if ((req.session as CustomSession).userId !== file.userId) {
|
||||||
oembed: `${baseUrl}/${slug}.json`,
|
const { views } = await this.prismaService.file.update({
|
||||||
url: `${baseUrl}/${slug}.${ext}`,
|
where: { slug },
|
||||||
title: embed_settings.enabled ? embed_settings?.title : null,
|
data: { views: file.views + 1 },
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
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() {
|
async getStatistics() {
|
||||||
|
|
|
@ -1,20 +1,17 @@
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Post,
|
Post,
|
||||||
Request,
|
Request,
|
||||||
Response,
|
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { FileInterceptor } from "@nestjs/platform-express";
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import { S3Service } from "./s3.service";
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import { Request as ERequest, Response as EResponse } from "express";
|
import { Request as ERequest } from "express";
|
||||||
import { AuthGuard } from "modules/auth/guard/auth.guard";
|
|
||||||
import { ROUTES } from "lib/constants";
|
import { ROUTES } from "lib/constants";
|
||||||
|
import { S3Service } from "./s3.service";
|
||||||
|
|
||||||
|
@SkipThrottle()
|
||||||
@Controller(ROUTES.UPLOAD)
|
@Controller(ROUTES.UPLOAD)
|
||||||
export class S3Controller {
|
export class S3Controller {
|
||||||
constructor(private readonly s3Service: S3Service) {}
|
constructor(private readonly s3Service: S3Service) {}
|
||||||
|
@ -28,13 +25,8 @@ export class S3Controller {
|
||||||
return this.s3Service.uploadFile(req, file);
|
return this.s3Service.uploadFile(req, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@UseInterceptors(FileInterceptor("files"))
|
|
||||||
@Post("bulk")
|
@Post("bulk")
|
||||||
uploadBulk(
|
uploadBulk(@Request() req: ERequest) {
|
||||||
@UploadedFile() files: Express.Multer.File[],
|
return this.s3Service.bulkUpload(req);
|
||||||
@Request() req: ERequest
|
|
||||||
) {
|
|
||||||
return this.s3Service.bulkUpload(req, files);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,14 +3,20 @@ import {
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Logger,
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { EmbedSettings } from "@prisma/client";
|
import { EmbedSettings } from "@prisma/client";
|
||||||
import { Client, ItemBucketMetadata } from "minio";
|
import { Client, ItemBucketMetadata } from "minio";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { CustomSession } from "lib/types";
|
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 { 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()
|
@Injectable()
|
||||||
export class S3Service {
|
export class S3Service {
|
||||||
|
@ -45,6 +51,8 @@ export class S3Service {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
delete data.userId;
|
||||||
|
|
||||||
return this.s3.putObject(
|
return this.s3.putObject(
|
||||||
this.bucketName,
|
this.bucketName,
|
||||||
`${oembed.filename}.json`,
|
`${oembed.filename}.json`,
|
||||||
|
@ -73,7 +81,7 @@ export class S3Service {
|
||||||
throw new BadRequestException("Invalid API key");
|
throw new BadRequestException("Invalid API key");
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = generateApiKey(12);
|
const slug = generateRandomString(12);
|
||||||
const extension = file.originalname.split(".").pop();
|
const extension = file.originalname.split(".").pop();
|
||||||
const filename = slug + "." + extension;
|
const filename = slug + "." + extension;
|
||||||
|
|
||||||
|
@ -92,12 +100,23 @@ export class S3Service {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.s3
|
await Promise.all([
|
||||||
.putObject(this.bucketName, filename, file.buffer, params)
|
this.prismaService.file.create({
|
||||||
.catch((err) => {
|
data: {
|
||||||
this.logger.error(err.message);
|
filename: file.originalname,
|
||||||
throw new InternalServerErrorException("Server error");
|
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";
|
const protocol = req.headers["x-forwarded-proto"] || "http";
|
||||||
|
|
||||||
|
@ -108,28 +127,15 @@ export class S3Service {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFile(key: string, res: Response) {
|
async bulkUpload(req: Request) {
|
||||||
if (!key) {
|
const apiKey = req.headers["authorization"] as string;
|
||||||
throw new BadRequestException("Missing key.");
|
|
||||||
|
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({
|
const user = await this.prismaService.user.findUnique({
|
||||||
where: { id: userId },
|
where: { apiKey },
|
||||||
include: { embed_settings: true },
|
include: { embed_settings: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -137,43 +143,124 @@ export class S3Service {
|
||||||
throw new UnauthorizedException("Invalid session");
|
throw new UnauthorizedException("Invalid session");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { embed_settings } = user;
|
const tmp = await this.prismaService.file.aggregate({
|
||||||
try {
|
where: {
|
||||||
const promises = files.map(async (file) => {
|
user: { apiKey },
|
||||||
const slug = generateApiKey(12);
|
},
|
||||||
const extension = file.originalname.split(".").pop();
|
_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 filename = slug + "." + extension;
|
||||||
|
const mimetype = lookUp(name);
|
||||||
|
|
||||||
const params: ItemBucketMetadata = {
|
const params: ItemBucketMetadata = {
|
||||||
"Content-Length": file.size,
|
"Content-Length": size,
|
||||||
"Content-Type": file.mimetype,
|
"Content-Type": mimetype,
|
||||||
"Content-Disposition": "inline",
|
"Content-Disposition": "inline",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (mimetype.includes("image") && embed_settings?.enabled) {
|
||||||
lookUp(file.originalname).includes("image") &&
|
|
||||||
embed_settings?.enabled
|
|
||||||
) {
|
|
||||||
await this.createOEmbedJSON({
|
await this.createOEmbedJSON({
|
||||||
filename: slug,
|
filename: slug,
|
||||||
...embed_settings,
|
...embed_settings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.s3.putObject(
|
await Promise.all([
|
||||||
this.bucketName,
|
this.prismaService.file.create({
|
||||||
filename,
|
data: {
|
||||||
file.buffer,
|
filename: name,
|
||||||
params
|
slug,
|
||||||
);
|
userId: user.id,
|
||||||
});
|
size: +size,
|
||||||
|
mimetype,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.s3.fPutObject(
|
||||||
|
this.bucketName,
|
||||||
|
filename,
|
||||||
|
join(uploadDir, tmpName),
|
||||||
|
params
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
return Promise.all(promises);
|
return { final: id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(error.message);
|
this.logger.error(error.message);
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
"Something went wrong in our end, please try again later."
|
"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" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,9 @@ import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import { UploadService } from "./upload.service";
|
import { UploadService } from "./upload.service";
|
||||||
import { Request as ERequest, Response as EResponse } from "express";
|
import { Request as ERequest, Response as EResponse } from "express";
|
||||||
import { ROUTES } from "lib/constants";
|
import { ROUTES } from "lib/constants";
|
||||||
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
|
|
||||||
|
@SkipThrottle()
|
||||||
@Controller(ROUTES.UPLOAD)
|
@Controller(ROUTES.UPLOAD)
|
||||||
export class UploadController {
|
export class UploadController {
|
||||||
constructor(private readonly uploadService: UploadService) {}
|
constructor(private readonly uploadService: UploadService) {}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { Request, Response } from "express";
|
||||||
import { createWriteStream, existsSync } from "fs";
|
import { createWriteStream, existsSync } from "fs";
|
||||||
import { rename, stat, unlink, writeFile } from "fs/promises";
|
import { rename, stat, unlink, writeFile } from "fs/promises";
|
||||||
import { uploadDir } from "lib/constants";
|
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 { PrismaService } from "modules/prisma/prisma.service";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import md5 from "md5";
|
import md5 from "md5";
|
||||||
|
@ -32,6 +32,8 @@ export class UploadService {
|
||||||
|
|
||||||
const { filename } = oembed;
|
const { filename } = oembed;
|
||||||
|
|
||||||
|
delete data.userId;
|
||||||
|
|
||||||
const stream = createWriteStream(join(uploadDir, filename + ".json"), {
|
const stream = createWriteStream(join(uploadDir, filename + ".json"), {
|
||||||
flags: "w",
|
flags: "w",
|
||||||
});
|
});
|
||||||
|
@ -97,7 +99,25 @@ export class UploadService {
|
||||||
throw new BadRequestException("Invalid API key");
|
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 ext = file.originalname.split(".").pop();
|
||||||
|
|
||||||
const stream = createWriteStream(join(uploadDir, `${slug}.${ext}`), {
|
const stream = createWriteStream(join(uploadDir, `${slug}.${ext}`), {
|
||||||
|
@ -167,6 +187,23 @@ export class UploadService {
|
||||||
throw new BadRequestException("Invalid API key");
|
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 name = decodeURIComponent(req.headers["x-file-name"] as string);
|
||||||
const size = req.headers["x-file-size"] as string;
|
const size = req.headers["x-file-size"] as string;
|
||||||
const currentChunk = req.headers["x-current-chunk"] 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" });
|
await writeFile(join(uploadDir, tmpName), buffer, { flag: "a" });
|
||||||
if (lastChunk) {
|
if (lastChunk) {
|
||||||
const mimetype = lookUp(name);
|
const mimetype = lookUp(name);
|
||||||
let slug = generateApiKey(12);
|
let slug = generateRandomString(12);
|
||||||
await rename(
|
await rename(
|
||||||
join(uploadDir, tmpName),
|
join(uploadDir, tmpName),
|
||||||
join(uploadDir, `${slug}.${ext}`)
|
join(uploadDir, `${slug}.${ext}`)
|
||||||
).catch(async (reason) => {
|
).catch(async (reason) => {
|
||||||
if (reason === "EEXIST") {
|
if (reason === "EEXIST") {
|
||||||
slug = generateApiKey(12);
|
slug = generateRandomString(12);
|
||||||
await rename(
|
await rename(
|
||||||
join(uploadDir, tmpName),
|
join(uploadDir, tmpName),
|
||||||
join(uploadDir, `${slug}.${ext}`)
|
join(uploadDir, `${slug}.${ext}`)
|
||||||
);
|
);
|
||||||
|
await this.prismaService.file.update({
|
||||||
|
where: { slug: tmpName.split(".").shift() },
|
||||||
|
data: { slug },
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(reason);
|
this.logger.error(reason);
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
|
@ -214,7 +255,7 @@ export class UploadService {
|
||||||
) {
|
) {
|
||||||
this.createOEmbedJSON({
|
this.createOEmbedJSON({
|
||||||
filename: name,
|
filename: name,
|
||||||
...user.embed_settings,
|
...embed_settings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
@ -11,6 +12,7 @@ import {
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { SkipThrottle, Throttle } from "@nestjs/throttler";
|
||||||
import { Request as ERequest } from "express";
|
import { Request as ERequest } from "express";
|
||||||
import { ROUTES } from "lib/constants";
|
import { ROUTES } from "lib/constants";
|
||||||
import { CustomSession } from "lib/types";
|
import { CustomSession } from "lib/types";
|
||||||
|
@ -22,10 +24,12 @@ import { EmbedSettingDTO } from "./dto/EmbedSettingsDTO";
|
||||||
import { ResetPasswordDTO } from "./dto/ResetPasswordDTO";
|
import { ResetPasswordDTO } from "./dto/ResetPasswordDTO";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
|
|
||||||
|
@SkipThrottle()
|
||||||
@Controller(ROUTES.USERS)
|
@Controller(ROUTES.USERS)
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@SkipThrottle(false)
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Post("verify/send")
|
@Post("verify/send")
|
||||||
async sendVerifyMail(@Request() req: ERequest) {
|
async sendVerifyMail(@Request() req: ERequest) {
|
||||||
|
@ -40,11 +44,13 @@ export class UsersController {
|
||||||
return this.usersService.verifyEmail(token);
|
return this.usersService.verifyEmail(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SkipThrottle(false)
|
||||||
@Post("forgot-password")
|
@Post("forgot-password")
|
||||||
async forgotPassword(@Body() { email }: { email: string }) {
|
async forgotPassword(@Body() { email }: { email: string }) {
|
||||||
return this.usersService.sendForgotPasswordEmail(email);
|
return this.usersService.sendForgotPasswordEmail(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SkipThrottle(false)
|
||||||
@Post("check-token")
|
@Post("check-token")
|
||||||
async checkToken(@Body() { token }: { token: string }) {
|
async checkToken(@Body() { token }: { token: string }) {
|
||||||
return this.usersService.checkToken(token);
|
return this.usersService.checkToken(token);
|
||||||
|
@ -102,6 +108,7 @@ export class UsersController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SkipThrottle(false)
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@UsePipes(new ValidationPipe({ transform: true }))
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
@UseFilters(new HttpExceptionFilter())
|
@UseFilters(new HttpExceptionFilter())
|
||||||
|
@ -118,6 +125,7 @@ export class UsersController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SkipThrottle(false)
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@UsePipes(new ValidationPipe({ transform: true }))
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
@UseFilters(new HttpExceptionFilter())
|
@UseFilters(new HttpExceptionFilter())
|
||||||
|
@ -128,4 +136,22 @@ export class UsersController {
|
||||||
) {
|
) {
|
||||||
return this.usersService.changeUsername(username, newUsername);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {
|
||||||
Logger,
|
Logger,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { EmbedSettings, User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import { MailService } from "../mail/mail.service";
|
import { MailService } from "../mail/mail.service";
|
||||||
|
@ -17,7 +17,7 @@ import {
|
||||||
SessionUser,
|
SessionUser,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
} from "../../lib/types";
|
} from "../../lib/types";
|
||||||
import { formatBytes, generateApiKey } from "../../lib/utils";
|
import { formatBytes, generateRandomString } from "../../lib/utils";
|
||||||
import { RegisterDTO } from "../auth/dto/register.dto";
|
import { RegisterDTO } from "../auth/dto/register.dto";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
@ -33,6 +33,7 @@ import {
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile } from "fs/promises";
|
||||||
import { EmbedSettingDTO } from "./dto/EmbedSettingsDTO";
|
import { EmbedSettingDTO } from "./dto/EmbedSettingsDTO";
|
||||||
|
import cuid from "cuid";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService implements IUserService {
|
export class UsersService implements IUserService {
|
||||||
|
@ -48,15 +49,19 @@ export class UsersService implements IUserService {
|
||||||
|
|
||||||
async findUser(
|
async findUser(
|
||||||
username_or_email: string,
|
username_or_email: string,
|
||||||
{ byId, withPassword }: findUserOptions = {
|
{ byId, withPassword, totalUsed }: findUserOptions = {
|
||||||
byId: false,
|
byId: false,
|
||||||
withPassword: false,
|
withPassword: false,
|
||||||
|
totalUsed: false,
|
||||||
}
|
}
|
||||||
): Promise<User | null> {
|
): Promise<User | null> {
|
||||||
if (!username_or_email) {
|
if (!username_or_email) {
|
||||||
throw new BadRequestException("Invalid request");
|
throw new BadRequestException("Invalid request");
|
||||||
}
|
}
|
||||||
let user: User;
|
let user: User;
|
||||||
|
let total: number;
|
||||||
|
|
||||||
|
// get total used space of user
|
||||||
|
|
||||||
if (byId) {
|
if (byId) {
|
||||||
user = await this.prisma.user.findUnique({
|
user = await this.prisma.user.findUnique({
|
||||||
|
@ -64,38 +69,48 @@ export class UsersService implements IUserService {
|
||||||
id: username_or_email,
|
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 {
|
} else {
|
||||||
user = await this.prisma.user.findUnique({
|
user = await this.prisma.user.findUnique({
|
||||||
where: username_or_email.includes("@")
|
where: username_or_email.includes("@")
|
||||||
? { email: username_or_email }
|
? { email: username_or_email }
|
||||||
: { username: 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;
|
!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) {
|
validateUsername(username: string) {
|
||||||
if (username.length < 3) {
|
if (username.length < 3) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
|
@ -179,7 +194,6 @@ export class UsersService implements IUserService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.redis.del(INVITE_PREFIX + invite);
|
|
||||||
inv = inviter;
|
inv = inviter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -202,19 +216,20 @@ export class UsersService implements IUserService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const avatarHash = md5(email.trim().toLowerCase());
|
const avatarHash = md5(generateRandomString(32) + Date.now().toString());
|
||||||
const hashedPassword = await argon.hash(password);
|
const hashedPassword = await argon.hash(password);
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
username: username ? username : generateUsername("_"),
|
username: username ? username : generateUsername("_"),
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
apiKey: generateApiKey(),
|
apiKey: generateRandomString(),
|
||||||
// image: `https://www.gravatar.com/avatar/${avatarHash}`,
|
|
||||||
image: `https://avatars.dicebear.com/api/identicon/${avatarHash}.svg`,
|
image: `https://avatars.dicebear.com/api/identicon/${avatarHash}.svg`,
|
||||||
invitedBy: inviter ? inviter : null,
|
invitedBy: inviter ? inviter : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.redis.del(INVITE_PREFIX + invite);
|
||||||
|
|
||||||
delete user.password;
|
delete user.password;
|
||||||
|
|
||||||
(req.session as CustomSession).userId = user.id;
|
(req.session as CustomSession).userId = user.id;
|
||||||
|
@ -278,7 +293,7 @@ export class UsersService implements IUserService {
|
||||||
throw new BadRequestException("email already verified");
|
throw new BadRequestException("email already verified");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const token = generateApiKey(32);
|
const token = generateRandomString(32);
|
||||||
|
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
CONFIRM_EMAIL_PREFIX + token,
|
CONFIRM_EMAIL_PREFIX + token,
|
||||||
|
@ -534,6 +549,14 @@ export class UsersService implements IUserService {
|
||||||
throw new UnauthorizedException("not authorized");
|
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 } });
|
await this.prisma.user.delete({ where: { id } });
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -571,7 +594,7 @@ export class UsersService implements IUserService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = generateApiKey(64);
|
const token = generateRandomString(64);
|
||||||
|
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
FORGOT_PASSWORD_PREFIX + token,
|
FORGOT_PASSWORD_PREFIX + token,
|
||||||
|
@ -650,4 +673,21 @@ export class UsersService implements IUserService {
|
||||||
|
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1297,6 +1297,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/eslint-scope@npm:^3.7.3":
|
||||||
version: 3.7.4
|
version: 3.7.4
|
||||||
resolution: "@types/eslint-scope@npm:3.7.4"
|
resolution: "@types/eslint-scope@npm:3.7.4"
|
||||||
|
@ -2087,6 +2096,7 @@ __metadata:
|
||||||
"@types/body-parser": ^1
|
"@types/body-parser": ^1
|
||||||
"@types/connect-redis": ^0.0.19
|
"@types/connect-redis": ^0.0.19
|
||||||
"@types/cron": ^2
|
"@types/cron": ^2
|
||||||
|
"@types/cuid": ^2
|
||||||
"@types/express": ^4.17.13
|
"@types/express": ^4.17.13
|
||||||
"@types/express-session": ^1
|
"@types/express-session": ^1
|
||||||
"@types/jest": 28.1.8
|
"@types/jest": 28.1.8
|
||||||
|
@ -2104,6 +2114,7 @@ __metadata:
|
||||||
class-validator: ^0.13.2
|
class-validator: ^0.13.2
|
||||||
connect-redis: ^6.1.3
|
connect-redis: ^6.1.3
|
||||||
cron: ^2.1.0
|
cron: ^2.1.0
|
||||||
|
cuid: ^2.1.8
|
||||||
eslint: ^8.29.0
|
eslint: ^8.29.0
|
||||||
eslint-config-prettier: ^8.3.0
|
eslint-config-prettier: ^8.3.0
|
||||||
eslint-plugin-prettier: ^4.0.0
|
eslint-plugin-prettier: ^4.0.0
|
||||||
|
@ -3216,6 +3227,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"cycle@npm:1.0.x":
|
||||||
version: 1.0.3
|
version: 1.0.3
|
||||||
resolution: "cycle@npm:1.0.3"
|
resolution: "cycle@npm:1.0.3"
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"@mantine/dropzone": "^5.8.4",
|
"@mantine/dropzone": "^5.8.4",
|
||||||
"@mantine/form": "^5.8.3",
|
"@mantine/form": "^5.8.3",
|
||||||
"@mantine/hooks": "^5.8.2",
|
"@mantine/hooks": "^5.8.2",
|
||||||
|
"@mantine/modals": "^5.9.5",
|
||||||
"@mantine/next": "^5.8.2",
|
"@mantine/next": "^5.8.2",
|
||||||
"@mantine/notifications": "^5.9.1",
|
"@mantine/notifications": "^5.9.1",
|
||||||
"@tabler/icons": "^1.112.0",
|
"@tabler/icons": "^1.112.0",
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
import LoadingPage from '@components/pages/LoadingPage';
|
import LoadingPage from '@components/pages/LoadingPage';
|
||||||
import { ROUTES } from '@lib/constants';
|
import { ROUTES } from '@lib/constants';
|
||||||
import { useIsAuth } from '@lib/hooks';
|
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 dynamic from 'next/dynamic';
|
||||||
|
import Router from 'next/router';
|
||||||
|
import { CustomPageOptions } from '@lib/types';
|
||||||
const Layout = dynamic(() => import('..'), { suspense: true });
|
const Layout = dynamic(() => import('..'), { suspense: true });
|
||||||
|
|
||||||
const AuthWrapper: FC<{ children: any; withLayout?: boolean }> = ({
|
const AuthWrapper: FC<CustomPageOptions & { children: ReactElement }> = ({
|
||||||
children,
|
children,
|
||||||
withLayout,
|
withLayout,
|
||||||
|
admin,
|
||||||
}) => {
|
}) => {
|
||||||
const currentUrl =
|
const currentUrl =
|
||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
|
@ -18,6 +21,18 @@ const AuthWrapper: FC<{ children: any; withLayout?: boolean }> = ({
|
||||||
callbackUrl: encodeURIComponent(currentUrl),
|
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" />;
|
if (isLoading) return <LoadingPage color="yellow" />;
|
||||||
|
|
||||||
return withLayout ? (
|
return withLayout ? (
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { NotificationsProvider } from '@mantine/notifications';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
const AuthWrapper = dynamic(() => import('./AuthWrapper'));
|
const AuthWrapper = dynamic(() => import('./AuthWrapper'));
|
||||||
|
|
||||||
const RootProvider: FC<CustomAppProps> = ({ pageProps, Component }) => {
|
const RootProvider: FC<CustomAppProps> = ({ pageProps, Component }) => {
|
||||||
|
@ -21,13 +22,15 @@ const RootProvider: FC<CustomAppProps> = ({ pageProps, Component }) => {
|
||||||
>
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<NotificationsProvider>
|
<NotificationsProvider>
|
||||||
{Component.options?.auth ? (
|
<ModalsProvider>
|
||||||
<AuthWrapper withLayout={Component.options.withLayout}>
|
{Component.options?.auth ? (
|
||||||
|
<AuthWrapper withLayout={Component.options.withLayout}>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</AuthWrapper>
|
||||||
|
) : (
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</AuthWrapper>
|
)}
|
||||||
) : (
|
</ModalsProvider>
|
||||||
<Component {...pageProps} />
|
|
||||||
)}
|
|
||||||
</NotificationsProvider>
|
</NotificationsProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
|
|
@ -5,7 +5,9 @@ import {
|
||||||
Burger,
|
Burger,
|
||||||
Drawer,
|
Drawer,
|
||||||
Group,
|
Group,
|
||||||
|
LoadingOverlay,
|
||||||
NavLink,
|
NavLink,
|
||||||
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
|
@ -14,8 +16,13 @@ import {
|
||||||
import { useMediaQuery } from '@mantine/hooks';
|
import { useMediaQuery } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconBrandAppgallery,
|
IconBrandAppgallery,
|
||||||
|
IconBrandDiscord,
|
||||||
|
IconCircleDashed,
|
||||||
|
IconDots,
|
||||||
IconGauge,
|
IconGauge,
|
||||||
IconHome2,
|
IconHome2,
|
||||||
|
IconMailForward,
|
||||||
|
IconServer,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconUser,
|
IconUser,
|
||||||
} from '@tabler/icons';
|
} from '@tabler/icons';
|
||||||
|
@ -52,17 +59,17 @@ const items: Item[] = [
|
||||||
href: ROUTES.SETTINGS,
|
href: ROUTES.SETTINGS,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
icon: IconSettings,
|
icon: IconCircleDashed,
|
||||||
label: 'General',
|
label: 'General',
|
||||||
href: ROUTES.SETTINGS,
|
href: ROUTES.SETTINGS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconSettings,
|
icon: IconBrandDiscord,
|
||||||
label: 'Embed',
|
label: 'Embed',
|
||||||
href: ROUTES.SETTINGS + '/embed',
|
href: ROUTES.SETTINGS + '/embed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconSettings,
|
icon: IconDots,
|
||||||
label: 'Domains',
|
label: 'Domains',
|
||||||
href: ROUTES.SETTINGS + '/domains',
|
href: ROUTES.SETTINGS + '/domains',
|
||||||
},
|
},
|
||||||
|
@ -75,17 +82,23 @@ const items: Item[] = [
|
||||||
admin: true,
|
admin: true,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
icon: IconUser,
|
icon: IconServer,
|
||||||
label: 'Server',
|
label: 'Server',
|
||||||
href: ROUTES.ADMIN + '/server',
|
href: ROUTES.ADMIN + '/server',
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconUser,
|
icon: IconMailForward,
|
||||||
label: 'Invites',
|
label: 'Invites',
|
||||||
href: ROUTES.ADMIN + '/invites',
|
href: ROUTES.ADMIN + '/invites',
|
||||||
admin: true,
|
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 mobile_screens = useMediaQuery('(max-width: 480px)');
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
const admin = user?.role === 'OWNER' || user?.role === 'ADMIN';
|
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(() => {
|
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) => (
|
(item, index) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
icon={<item.icon />}
|
icon={<item.icon />}
|
||||||
|
@ -117,13 +169,14 @@ const Sidebar = () => {
|
||||||
component="a"
|
component="a"
|
||||||
key={index}
|
key={index}
|
||||||
active={router.pathname === child.href}
|
active={router.pathname === child.href}
|
||||||
|
icon={<child.icon />}
|
||||||
color="violet"
|
color="violet"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}, [admin, router.pathname]);
|
}, [admin, owner, router.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
|
@ -140,24 +193,34 @@ const Sidebar = () => {
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={() => setOpened(false)}
|
onClose={() => setOpened(false)}
|
||||||
>
|
>
|
||||||
<Group
|
<Stack
|
||||||
position="right"
|
spacing={0}
|
||||||
p="xl"
|
justify="space-between"
|
||||||
sx={{
|
sx={{ height: '100%' }}
|
||||||
width: '100%',
|
align="center"
|
||||||
alignItems: 'center',
|
|
||||||
borderBottom: '1px solid #2C2E33',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Burger
|
<Stack spacing={0} sx={{ width: '100%' }}>
|
||||||
sx={{ marginLeft: 8 }}
|
<Group
|
||||||
opened={opened}
|
position="right"
|
||||||
onClick={() => setOpened(!opened)}
|
p="xl"
|
||||||
/>
|
sx={{
|
||||||
</Group>
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottom: '1px solid #2C2E33',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Burger
|
||||||
|
sx={{ marginLeft: 8 }}
|
||||||
|
opened={opened}
|
||||||
|
onClick={() => setOpened(!opened)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Stack mt={20} spacing={0}>
|
<Stack mt={20} spacing={0}>
|
||||||
{links}
|
{links}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
{storageUsed}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -20,6 +20,7 @@ import CredentialsForm from './CredentialsForm';
|
||||||
import { useSendVerificationEmail, useSignOut } from '@lib/hooks';
|
import { useSendVerificationEmail, useSignOut } from '@lib/hooks';
|
||||||
import { showNotification } from '@mantine/notifications';
|
import { showNotification } from '@mantine/notifications';
|
||||||
import { profileStyles } from './styles';
|
import { profileStyles } from './styles';
|
||||||
|
import DeleteAccount from './DeleteAccount';
|
||||||
const Modal = dynamic(() => import('@mantine/core').then((mod) => mod.Modal));
|
const Modal = dynamic(() => import('@mantine/core').then((mod) => mod.Modal));
|
||||||
const ChangePasswordForm = dynamic(() => import('./ChangePasswordForm'));
|
const ChangePasswordForm = dynamic(() => import('./ChangePasswordForm'));
|
||||||
|
|
||||||
|
@ -148,6 +149,8 @@ const ProfilePage = () => {
|
||||||
</Group>
|
</Group>
|
||||||
<Divider mt="xl" mb="xl" />
|
<Divider mt="xl" mb="xl" />
|
||||||
<CredentialsForm username={user?.username!} />
|
<CredentialsForm username={user?.username!} />
|
||||||
|
<Divider mt="xl" mb="xl" />
|
||||||
|
<DeleteAccount />
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
flameshotScript,
|
flameshotScript,
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
} from '@lib/constants';
|
} from '@lib/constants';
|
||||||
|
import { useRegenerateApiKey } from '@lib/hooks/useRegenerateApiKey';
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
|
@ -14,6 +15,8 @@ import {
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
Text,
|
Text,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import { showNotification } from '@mantine/notifications';
|
||||||
|
import { IconCheck, IconX } from '@tabler/icons';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
@ -22,7 +25,8 @@ import { useEffect, useCallback, FC } from 'react';
|
||||||
const SettingPage: FC<{ children?: any }> = ({ children }) => {
|
const SettingPage: FC<{ children?: any }> = ({ children }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const activeTab = router.pathname.split('/')[3] || 'index';
|
const activeTab = router.pathname.split('/')[3] || 'index';
|
||||||
const [user] = useAtom(userAtom);
|
const [user, setUser] = useAtom(userAtom);
|
||||||
|
const { regen, isLoading } = useRegenerateApiKey();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
router.prefetch(ROUTES.SETTINGS);
|
router.prefetch(ROUTES.SETTINGS);
|
||||||
|
@ -113,7 +117,34 @@ const SettingPage: FC<{ children?: any }> = ({ children }) => {
|
||||||
API Key
|
API Key
|
||||||
</Text>
|
</Text>
|
||||||
<PasswordInput sx={{ width: '100%' }} value={user?.apiKey} />
|
<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
|
Regenerate API Key
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -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';
|
import { FC } from 'react';
|
||||||
|
|
||||||
const ProgressCard: FC<{
|
const ProgressCard: FC<{
|
||||||
progress: number;
|
progress: number;
|
||||||
filename: string;
|
filename: string;
|
||||||
speed: string;
|
speed: string;
|
||||||
}> = ({ filename, progress, speed }) => {
|
error: boolean;
|
||||||
|
}> = ({ filename, progress, speed, error }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Paper withBorder p="lg" my="xs" sx={{ width: '100%' }}>
|
<Paper withBorder p="lg" my="xs" sx={{ width: '100%' }}>
|
||||||
|
@ -20,11 +22,31 @@ const ProgressCard: FC<{
|
||||||
>
|
>
|
||||||
{filename.length > 67 ? filename.slice(0, 67) + '...' : filename}
|
{filename.length > 67 ? filename.slice(0, 67) + '...' : filename}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" color="dimmed">
|
{error ? (
|
||||||
{speed} - {progress}%
|
<Avatar>
|
||||||
</Text>
|
<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>
|
</Group>
|
||||||
<Progress size="sm" value={progress} />
|
<Progress
|
||||||
|
size="sm"
|
||||||
|
value={progress}
|
||||||
|
color={
|
||||||
|
progress > 98 && progress < 100
|
||||||
|
? 'yellow'
|
||||||
|
: progress === 100
|
||||||
|
? 'teal'
|
||||||
|
: 'blue'
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -6,7 +6,13 @@ import {
|
||||||
useMantineTheme,
|
useMantineTheme,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
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 dynamic from 'next/dynamic';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { uploadStyles } from './styles';
|
import { uploadStyles } from './styles';
|
||||||
|
@ -23,6 +29,7 @@ const UploadZone = () => {
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
const openRef = useRef<() => void>(null);
|
const openRef = useRef<() => void>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [currentFileIndex, setCurrentFileIndex] = useState<number | null>(null);
|
const [currentFileIndex, setCurrentFileIndex] = useState<number | null>(null);
|
||||||
const [lastUploadedFileIndex, setLastUploadedFileIndex] = useState<
|
const [lastUploadedFileIndex, setLastUploadedFileIndex] = useState<
|
||||||
|
@ -32,6 +39,7 @@ const UploadZone = () => {
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const { classes } = uploadStyles();
|
const { classes } = uploadStyles();
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const uploadChunk = (e: ProgressEvent<FileReader>) => {
|
const uploadChunk = (e: ProgressEvent<FileReader>) => {
|
||||||
if (currentFileIndex === null) return;
|
if (currentFileIndex === null) return;
|
||||||
|
@ -45,6 +53,7 @@ const UploadZone = () => {
|
||||||
'X-Total-Chunks': Math.ceil(file.size / CHUNK_SIZE),
|
'X-Total-Chunks': Math.ceil(file.size / CHUNK_SIZE),
|
||||||
Authorization: user?.apiKey,
|
Authorization: user?.apiKey,
|
||||||
};
|
};
|
||||||
|
setUploading(true);
|
||||||
axios
|
axios
|
||||||
.post(API_URL + API_ROUTES.UPLOAD_FILE, data, {
|
.post(API_URL + API_ROUTES.UPLOAD_FILE, data, {
|
||||||
headers,
|
headers,
|
||||||
|
@ -79,10 +88,31 @@ const UploadZone = () => {
|
||||||
if (isLastFile) {
|
if (isLastFile) {
|
||||||
setLastUploadedFileIndex(currentFileIndex);
|
setLastUploadedFileIndex(currentFileIndex);
|
||||||
setCurrentFileIndex(null);
|
setCurrentFileIndex(null);
|
||||||
|
setUploading(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setCurrentChunkIndex(currentChunkIndex! + 1);
|
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}>
|
<div className={classes.wrapper}>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
|
loading={uploading}
|
||||||
openRef={openRef}
|
openRef={openRef}
|
||||||
onDrop={(dropzoneFiles) => setFiles([...files, ...dropzoneFiles])}
|
onDrop={(dropzoneFiles) => setFiles([...files, ...dropzoneFiles])}
|
||||||
onReject={(err) => {
|
onReject={(err) => {
|
||||||
|
@ -222,6 +253,7 @@ const UploadZone = () => {
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ProgressCard
|
<ProgressCard
|
||||||
|
error={error}
|
||||||
key={idx}
|
key={idx}
|
||||||
filename={file.name}
|
filename={file.name}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
|
|
|
@ -27,14 +27,18 @@ export enum API_ROUTES {
|
||||||
EMBED_SETTINGS = '/users/embed-settings',
|
EMBED_SETTINGS = '/users/embed-settings',
|
||||||
CHANGE_PASSWORD = '/users/change-password',
|
CHANGE_PASSWORD = '/users/change-password',
|
||||||
CHANGE_USERNAME = '/users/change-username',
|
CHANGE_USERNAME = '/users/change-username',
|
||||||
|
REGENERATE_API_KEY = '/users/regenerate-api-key',
|
||||||
FORGOT_PASSWORD = '/users/forgot-password',
|
FORGOT_PASSWORD = '/users/forgot-password',
|
||||||
RESET_PASSWORD = '/users/reset-password',
|
RESET_PASSWORD = '/users/reset-password',
|
||||||
|
DELETE_ACCOUNT = '/users/delete-account',
|
||||||
CHECK_TOKEN = '/users/check-token',
|
CHECK_TOKEN = '/users/check-token',
|
||||||
SEND_VERIFICATION_EMAIL = '/users/verify/send',
|
SEND_VERIFICATION_EMAIL = '/users/verify/send',
|
||||||
VERIFY_EMAIL = '/users/verify',
|
VERIFY_EMAIL = '/users/verify',
|
||||||
SERVER_SETTINGS = '/server-settings',
|
SERVER_SETTINGS = '/server-settings',
|
||||||
UPDATE_SERVER_SETTINGS = '/admin/server-settings',
|
UPDATE_SERVER_SETTINGS = '/admin/server-settings',
|
||||||
INVITE_CODE = '/admin/invites',
|
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';
|
export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME ?? 'Bliss V2';
|
||||||
|
|
|
@ -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 { Invite } from '@lib/types';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import Router from 'next/router';
|
||||||
|
|
||||||
export const useGetInvites = () => {
|
export const useGetInvites = () => {
|
||||||
const { data, isLoading, error, refetch } = useQuery(['invites'], () =>
|
const { data, isLoading, error, refetch } = useQuery(['invites'], () =>
|
||||||
|
@ -11,6 +12,9 @@ export const useGetInvites = () => {
|
||||||
})
|
})
|
||||||
.then((res) => res.data)
|
.then((res) => res.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (error.response.status === 403) {
|
||||||
|
Router.replace(ROUTES.ROOT);
|
||||||
|
}
|
||||||
throw new Error(error.response.data.message);
|
throw new Error(error.response.data.message);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -34,6 +34,7 @@ export const useIsAuth = ({
|
||||||
Router.push(
|
Router.push(
|
||||||
`${redirectTo}${callbackUrl ? `?callbackUrl=${callbackUrl}` : ''}`
|
`${redirectTo}${callbackUrl ? `?callbackUrl=${callbackUrl}` : ''}`
|
||||||
);
|
);
|
||||||
|
setUser(null);
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
{ refetchOnWindowFocus: false }
|
{ refetchOnWindowFocus: false }
|
||||||
|
|
|
@ -23,7 +23,14 @@ export const useLogin = ({ callback }: { callback?: string } = {}) => {
|
||||||
callback ? Router.push(callback) : Router.push(ROUTES.ROOT);
|
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 {
|
return {
|
||||||
|
|
|
@ -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 };
|
||||||
|
};
|
|
@ -8,7 +8,6 @@ import { useMutation } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
export const useUpdateEmbedSettings = (data: Partial<EmbedSettings>) => {
|
export const useUpdateEmbedSettings = (data: Partial<EmbedSettings>) => {
|
||||||
delete data.userId;
|
|
||||||
const form = useForm<Partial<EmbedSettings>>({
|
const form = useForm<Partial<EmbedSettings>>({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
...data,
|
...data,
|
||||||
|
|
|
@ -19,6 +19,7 @@ export interface CustomAppProps extends AppProps {
|
||||||
export interface CustomPageOptions {
|
export interface CustomPageOptions {
|
||||||
auth?: boolean;
|
auth?: boolean;
|
||||||
withLayout?: boolean;
|
withLayout?: boolean;
|
||||||
|
admin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CustomNextPage<P = {}, IP = P> = NextPage<P, IP> & {
|
export type CustomNextPage<P = {}, IP = P> = NextPage<P, IP> & {
|
||||||
|
@ -41,6 +42,9 @@ export type SessionUser = {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
emailVerified: Date;
|
emailVerified: Date;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
total: number;
|
||||||
|
uploadLimit: number;
|
||||||
|
disabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
|
@ -49,7 +53,8 @@ export interface Item {
|
||||||
href: string;
|
href: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
children?: Item[];
|
owner?: boolean;
|
||||||
|
children?: Omit<Item, 'children'>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavbarLinkProps {
|
export interface NavbarLinkProps {
|
||||||
|
@ -138,3 +143,10 @@ export interface Chunk {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateUsers {
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
disabled: boolean;
|
||||||
|
uploadLimit: number;
|
||||||
|
}
|
||||||
|
|
|
@ -32,4 +32,5 @@ export default AdminDash;
|
||||||
AdminDash.options = {
|
AdminDash.options = {
|
||||||
auth: true,
|
auth: true,
|
||||||
withLayout: true,
|
withLayout: true,
|
||||||
|
admin: true,
|
||||||
};
|
};
|
||||||
|
|
|
@ -17,8 +17,9 @@ const Owner: CustomNextPage = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.role !== 'OWNER' && user?.role !== 'ADMIN')
|
if (user?.role !== 'OWNER' && user?.role !== 'ADMIN') {
|
||||||
router.push('/dashboard');
|
void router.push('/dashboard');
|
||||||
|
}
|
||||||
}, [router, user?.role]);
|
}, [router, user?.role]);
|
||||||
|
|
||||||
return isLoading ? <LoadingPage /> : <ServerPage {...data} />;
|
return isLoading ? <LoadingPage /> : <ServerPage {...data} />;
|
||||||
|
@ -29,4 +30,5 @@ export default Owner;
|
||||||
Owner.options = {
|
Owner.options = {
|
||||||
auth: true,
|
auth: true,
|
||||||
withLayout: true,
|
withLayout: true,
|
||||||
|
admin: true,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
|
@ -1,25 +1,17 @@
|
||||||
import HomePage from '@pages/HomePage';
|
import HomePage from '@pages/HomePage';
|
||||||
import { API_ROUTES, API_URL } from '@lib/constants';
|
import { useIsAuth } from '@lib/hooks';
|
||||||
import { SessionUser } from '@lib/types';
|
import LoadingPage from '@components/pages/LoadingPage';
|
||||||
import axios from 'axios';
|
|
||||||
import { NextPage } from 'next';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
const Home: NextPage = () => {
|
const Home = () => {
|
||||||
const [user, setUser] = useState<SessionUser>();
|
const { data, isLoading } = useIsAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
if (isLoading) {
|
||||||
axios
|
return <LoadingPage color="orange" />;
|
||||||
.get(API_URL + API_ROUTES.ME, { withCredentials: true })
|
}
|
||||||
.then((res) => {
|
|
||||||
setUser(res.data);
|
|
||||||
})
|
|
||||||
.catch(() => null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HomePage user={user} />
|
<HomePage user={data!} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -381,6 +381,20 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@mantine/next@npm:^5.8.2":
|
||||||
version: 5.8.2
|
version: 5.8.2
|
||||||
resolution: "@mantine/next@npm:5.8.2"
|
resolution: "@mantine/next@npm:5.8.2"
|
||||||
|
@ -466,6 +480,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@next/env@npm:13.0.4":
|
||||||
version: 13.0.4
|
version: 13.0.4
|
||||||
resolution: "@next/env@npm:13.0.4"
|
resolution: "@next/env@npm:13.0.4"
|
||||||
|
@ -1163,6 +1186,7 @@ __metadata:
|
||||||
"@mantine/dropzone": ^5.8.4
|
"@mantine/dropzone": ^5.8.4
|
||||||
"@mantine/form": ^5.8.3
|
"@mantine/form": ^5.8.3
|
||||||
"@mantine/hooks": ^5.8.2
|
"@mantine/hooks": ^5.8.2
|
||||||
|
"@mantine/modals": ^5.9.5
|
||||||
"@mantine/next": ^5.8.2
|
"@mantine/next": ^5.8.2
|
||||||
"@mantine/notifications": ^5.9.1
|
"@mantine/notifications": ^5.9.1
|
||||||
"@tabler/icons": ^1.112.0
|
"@tabler/icons": ^1.112.0
|
||||||
|
|
Loading…
Reference in New Issue