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