From e9b07431a9f6c7742a912797c7457f1f54829d92 Mon Sep 17 00:00:00 2001 From: Supertiger Date: Tue, 14 Jan 2025 16:11:54 +0000 Subject: [PATCH] Add scheduled deletion handling for servers --- .../migration.sql | 11 +++++++ .../20250114114134_update/migration.sql | 5 +++ .../20250114155528_update/migration.sql | 11 +++++++ prisma/schema.prisma | 15 +++++++++ src/cache/ServerCache.ts | 13 ++++---- src/index.ts | 31 +++++++++++++++++++ src/middleware/channelVerification.ts | 7 +++-- src/middleware/serverMemberVerification.ts | 14 ++++----- src/routes/moderation/getServer.ts | 13 ++------ src/routes/moderation/getServers.ts | 1 + src/routes/moderation/getUser.ts | 1 + src/routes/moderation/searchServers.ts | 1 + src/routes/moderation/serverDelete.ts | 29 ++++++++++++----- src/routes/servers/serverDelete.ts | 2 +- src/services/Server.ts | 5 +++ 15 files changed, 123 insertions(+), 36 deletions(-) create mode 100644 prisma/migrations/20250114102407_add_schedule_server_delete_model/migration.sql create mode 100644 prisma/migrations/20250114114134_update/migration.sql create mode 100644 prisma/migrations/20250114155528_update/migration.sql diff --git a/prisma/migrations/20250114102407_add_schedule_server_delete_model/migration.sql b/prisma/migrations/20250114102407_add_schedule_server_delete_model/migration.sql new file mode 100644 index 00000000..298ac0f5 --- /dev/null +++ b/prisma/migrations/20250114102407_add_schedule_server_delete_model/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "ScheduleServerDelete" ( + "serverId" TEXT NOT NULL, + "scheduledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "ScheduleServerDelete_serverId_key" ON "ScheduleServerDelete"("serverId"); + +-- AddForeignKey +ALTER TABLE "ScheduleServerDelete" ADD CONSTRAINT "ScheduleServerDelete_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250114114134_update/migration.sql b/prisma/migrations/20250114114134_update/migration.sql new file mode 100644 index 00000000..e76eedd8 --- /dev/null +++ b/prisma/migrations/20250114114134_update/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "ScheduleServerDelete" DROP CONSTRAINT "ScheduleServerDelete_serverId_fkey"; + +-- AddForeignKey +ALTER TABLE "ScheduleServerDelete" ADD CONSTRAINT "ScheduleServerDelete_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250114155528_update/migration.sql b/prisma/migrations/20250114155528_update/migration.sql new file mode 100644 index 00000000..f2d6c747 --- /dev/null +++ b/prisma/migrations/20250114155528_update/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `scheduledByUserId` to the `ScheduleServerDelete` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "ScheduleServerDelete" ADD COLUMN "scheduledByUserId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "ScheduleServerDelete" ADD CONSTRAINT "ScheduleServerDelete_scheduledByUserId_fkey" FOREIGN KEY ("scheduledByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3bd1d6c6..bdff1528 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -152,6 +152,9 @@ model User { pinnedPosts PinnedPost[] + + + scheduledServerDeletions ScheduleServerDelete[] } model UserConnection { @@ -304,6 +307,9 @@ model Server { channelPermissions ServerChannelPermissions[] + scheduledForDeletion ScheduleServerDelete? + + } // Personal settings for joined servers @@ -667,6 +673,15 @@ model ScheduleAccountContentDelete { scheduledAt DateTime @default(now()) } +// Only used when Nerimity mod deletes a server. +model ScheduleServerDelete { + serverId String @unique + server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) + scheduledByUserId String + scheduledBy User @relation(fields: [scheduledByUserId], references: [id]) + scheduledAt DateTime @default(now()) +} + model MessageReaction { id String @id name String diff --git a/src/cache/ServerCache.ts b/src/cache/ServerCache.ts index ab09f84c..91057f3c 100644 --- a/src/cache/ServerCache.ts +++ b/src/cache/ServerCache.ts @@ -10,16 +10,17 @@ export interface ServerCache { hexColor: string; defaultRoleId: string; public: boolean; + scheduledForDeletion?: { scheduledAt: Date } | null; } export const getServerCache = async (serverId: string) => { const key = SERVER_KEY_STRING(serverId); const serverString = await redisClient.get(key); - if (serverString) return JSON.parse(serverString); + if (serverString) return JSON.parse(serverString) as ServerCache; const server = await prisma.server.findFirst({ where: { id: serverId }, - include: { PublicServer: true }, + include: { PublicServer: true, scheduledForDeletion: { select: { scheduledAt: true } } }, }); if (!server) return null; @@ -31,10 +32,11 @@ export const getServerCache = async (serverId: string) => { hexColor: server.hexColor, defaultRoleId: server.defaultRoleId, public: server.PublicServer ? true : false, + scheduledForDeletion: server.scheduledForDeletion, }; const serverCacheString = JSON.stringify(serverCache); await redisClient.set(key, serverCacheString); - return JSON.parse(serverCacheString); + return JSON.parse(serverCacheString) as ServerCache; }; export const deleteServerCache = async (serverId: string) => { @@ -42,10 +44,7 @@ export const deleteServerCache = async (serverId: string) => { await redisClient.del(key); }; -export const updateServerCache = async ( - serverId: string, - update: Partial -) => { +export const updateServerCache = async (serverId: string, update: Partial) => { const key = SERVER_KEY_STRING(serverId); const cache = await getServerCache(serverId); if (!cache) return; diff --git a/src/index.ts b/src/index.ts index 881ac067..6ff083b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { deleteAccount, deleteAllApplications, deleteOrLeaveAllServers } from '. import { createHash } from 'node:crypto'; import { addToObjectIfExists } from './common/addToObjectIfExists'; import { createQueueProcessor } from '@nerimity/mimiqueue'; +import { deleteServer } from './services/Server'; (Date.prototype.toJSON as unknown as (this: Date) => number) = function () { return this.getTime(); @@ -47,6 +48,7 @@ if (cluster.isPrimary) { removeIPAddressSchedule(); schedulePostViews(); scheduleSuspendedAccountDeletion(); + scheduleServerDeletion(); removeExpiredBannedIpsSchedule(); }); @@ -329,3 +331,32 @@ function scheduleSuspendedAccountDeletion() { scheduleSuspendedAccountDeletion(); }, oneMinuteToMilliseconds); } +function scheduleServerDeletion() { + const oneMinuteToMilliseconds = 1 * 60 * 1000; + const fiveDaysInThePast = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + setTimeout(async () => { + const scheduleItem = await prisma.scheduleServerDelete.findFirst({ + where: { + scheduledAt: { + lte: fiveDaysInThePast, + }, + }, + select: { + server: { + select: { name: true }, + }, + serverId: true, + scheduledByUserId: true, + }, + }); + if (scheduleItem) { + try { + await deleteServer(scheduleItem.serverId, scheduleItem.scheduledByUserId); + } catch (err) { + console.error(err); + } + } + + scheduleServerDeletion(); + }, oneMinuteToMilliseconds); +} diff --git a/src/middleware/channelVerification.ts b/src/middleware/channelVerification.ts index 8e5741f0..2ca56783 100644 --- a/src/middleware/channelVerification.ts +++ b/src/middleware/channelVerification.ts @@ -8,11 +8,9 @@ import { ChannelType } from '../types/Channel'; interface Options { allowBot?: boolean; + ignoreScheduledDeletion?: boolean; } -interface Options {} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars export function channelVerification(opts?: Options) { return async (req: Request, res: Response, next: NextFunction) => { const { channelId } = req.params; @@ -28,6 +26,9 @@ export function channelVerification(opts?: Options) { } const isServerChannel = channel.type === ChannelType.CATEGORY || channel.type === ChannelType.SERVER_TEXT; + if (!opts?.ignoreScheduledDeletion && isServerChannel && channel.server.scheduledForDeletion) { + return res.status(403).json(generateError('This server is scheduled for deletion.')); + } if (isServerChannel) { const [memberCache, error] = await getServerMemberCache(channel.server.id, req.userCache.id); diff --git a/src/middleware/serverMemberVerification.ts b/src/middleware/serverMemberVerification.ts index de63199b..59496ea5 100644 --- a/src/middleware/serverMemberVerification.ts +++ b/src/middleware/serverMemberVerification.ts @@ -5,20 +5,14 @@ import { generateError } from '../common/errorHandler'; interface Options { allowBot?: boolean; + ignoreScheduledDeletion?: boolean; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface Options {} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars export function serverMemberVerification(opts?: Options) { return async (req: Request, res: Response, next: NextFunction) => { const { serverId } = req.params; - const [memberCache, error] = await getServerMemberCache( - serverId, - req.userCache.id - ); + const [memberCache, error] = await getServerMemberCache(serverId, req.userCache.id); if (error !== null) { return res.status(403).json(generateError(error)); } @@ -28,6 +22,10 @@ export function serverMemberVerification(opts?: Options) { return res.status(403).json(generateError('Server does not exist.')); } + if (!opts?.ignoreScheduledDeletion && server.scheduledForDeletion) { + return res.status(403).json(generateError('This server is scheduled for deletion.')); + } + req.serverMemberCache = memberCache; req.serverCache = server; diff --git a/src/routes/moderation/getServer.ts b/src/routes/moderation/getServer.ts index 43568b03..0117b495 100644 --- a/src/routes/moderation/getServer.ts +++ b/src/routes/moderation/getServer.ts @@ -5,12 +5,7 @@ import { isModMiddleware } from './isModMiddleware'; import { isExpired } from '../../services/User/User'; export function getServer(Router: Router) { - Router.get( - '/moderation/servers/:serverId', - authenticate(), - isModMiddleware, - route - ); + Router.get('/moderation/servers/:serverId', authenticate(), isModMiddleware, route); } async function route(req: Request, res: Response) { @@ -25,6 +20,7 @@ async function route(req: Request, res: Response) { verified: true, hexColor: true, id: true, + scheduledForDeletion: true, createdAt: true, createdBy: { select: { @@ -42,10 +38,7 @@ async function route(req: Request, res: Response) { }, }); - if ( - server?.createdBy.suspension?.expireAt && - isExpired(server.createdBy.suspension.expireAt) - ) { + if (server?.createdBy.suspension?.expireAt && isExpired(server.createdBy.suspension.expireAt)) { server.createdBy.suspension = null; } diff --git a/src/routes/moderation/getServers.ts b/src/routes/moderation/getServers.ts index f42d0d2c..08f2f124 100644 --- a/src/routes/moderation/getServers.ts +++ b/src/routes/moderation/getServers.ts @@ -24,6 +24,7 @@ async function route(req: Request, res: Response) { take: limit, ...(after ? { cursor: { id: after } } : undefined), select: { + scheduledForDeletion: true, name: true, hexColor: true, id: true, diff --git a/src/routes/moderation/getUser.ts b/src/routes/moderation/getUser.ts index 05635ffc..bb648c5d 100644 --- a/src/routes/moderation/getUser.ts +++ b/src/routes/moderation/getUser.ts @@ -23,6 +23,7 @@ async function route(req: Request, res: Response) { devices: { orderBy: { createdAt: 'desc' } }, servers: { select: { + scheduledForDeletion: true, verified: true, name: true, hexColor: true, diff --git a/src/routes/moderation/searchServers.ts b/src/routes/moderation/searchServers.ts index c038f603..2ac704e1 100644 --- a/src/routes/moderation/searchServers.ts +++ b/src/routes/moderation/searchServers.ts @@ -27,6 +27,7 @@ async function route(req: Request, res: Response) { take: limit, ...(after ? { cursor: { id: after } } : undefined), select: { + scheduledForDeletion: true, name: true, hexColor: true, id: true, diff --git a/src/routes/moderation/serverDelete.ts b/src/routes/moderation/serverDelete.ts index fc693397..a3638629 100644 --- a/src/routes/moderation/serverDelete.ts +++ b/src/routes/moderation/serverDelete.ts @@ -5,7 +5,7 @@ import { customExpressValidatorResult, generateError } from '../../common/errorH import { authenticate } from '../../middleware/authenticate'; import { isModMiddleware } from './isModMiddleware'; -import { deleteServer } from '../../services/Server'; +// import { deleteServer } from '../../services/Server'; import { generateId } from '../../common/flakeId'; import { ModAuditLogType } from '../../common/ModAuditLog'; import { checkUserPassword } from '../../services/UserAuthentication'; @@ -31,7 +31,7 @@ async function route(req: Request, res: Response) { } const account = await prisma.account.findFirst({ - where: { id: req.userCache.account.id }, + where: { id: req.userCache.account!.id }, select: { password: true }, }); if (!account) return res.status(404).json(generateError('Something went wrong. Try again later.')); @@ -45,14 +45,29 @@ async function route(req: Request, res: Response) { if (!server) return res.status(404).json(generateError('Server does not exist.')); - const [, error] = await deleteServer(req.params.serverId, req.userCache.id); - if (error) { - return res.status(403).json(error); + // const [, error] = await deleteServer(req.params.serverId, req.userCache.id); + // if (error) { + // return res.status(403).json(error); + // } + + const scheduledDeletion = await prisma.scheduleServerDelete + .create({ + data: { + serverId: server.id, + scheduledByUserId: req.userCache.id, + }, + }) + .catch((e) => { + console.error(e); + return null; + }); + if (!scheduledDeletion) { + return res.status(500).json(generateError('Failed to schedule server deletion.')); } await warnUsersBatch({ userIds: [server.createdById], - reason: `${server.name} deleted: ` + req.body.reason, + reason: `${server.name} scheduled deletion: ${req.body.reason}`, modUserId: req.userCache.id, skipAuditLog: true, }); @@ -68,5 +83,5 @@ async function route(req: Request, res: Response) { }, }); - res.status(200).json({ success: true }); + res.status(200).json({ success: true, scheduledDeletion }); } diff --git a/src/routes/servers/serverDelete.ts b/src/routes/servers/serverDelete.ts index 28ae0f05..bf1a1f01 100644 --- a/src/routes/servers/serverDelete.ts +++ b/src/routes/servers/serverDelete.ts @@ -5,7 +5,7 @@ import { deleteServer } from '../../services/Server'; import { Log } from '../../common/Log'; export function serverDelete(Router: Router) { - Router.delete('/servers/:serverId', authenticate(), serverMemberVerification(), route); + Router.delete('/servers/:serverId', authenticate(), serverMemberVerification({ ignoreScheduledDeletion: true }), route); } async function route(req: Request, res: Response) { diff --git a/src/services/Server.ts b/src/services/Server.ts index 82df8071..da6c7bc6 100644 --- a/src/services/Server.ts +++ b/src/services/Server.ts @@ -221,6 +221,7 @@ export const joinServer = async ( const server = await prisma.server.findFirst({ where: { id: serverId }, include: { + scheduledForDeletion: true, _count: { select: { welcomeQuestions: true } }, customEmojis: { select: { gif: true, id: true, name: true }, @@ -231,6 +232,10 @@ export const joinServer = async ( return [null, generateError('Server does not exist.')] as const; } + if (server.scheduledForDeletion) { + return [null, generateError('Server is scheduled for deletion.')] as const; + } + // check if user is already in server const isInServer = await exists(prisma.serverMember, { where: { serverId, userId },