"use server"; import { readFile, readdir, writeFile } from "fs/promises"; import { PLATFORM_MOD_ID, RESTRICT_ACCESS_LIST } from "./constants"; import mongo, { Account, AuditLogEntry, ChannelInvite, EmailClassification, createDM, fetchAccountById, findDM, } from "./db"; import { publishMessage, sendChatMessage } from "./redis"; import { ulid } from "ulid"; import { AccountInfo, AccountStrike, Bot, Channel, File, Invite, Member, Message, Report, Server, SessionInfo, User, } from "revolt-api"; import { checkPermission } from "./accessPermissions"; import { Long } from "mongodb"; import { nanoid } from "nanoid"; export async function sendAlert(userId: string, content: string) { await checkPermission("users/create/alert", userId, { content }); const messageId = ulid(); let dm = await findDM(PLATFORM_MOD_ID, userId); if (!dm) dm = await createDM(PLATFORM_MOD_ID, userId, messageId); await sendChatMessage({ _id: messageId, author: PLATFORM_MOD_ID, channel: dm._id, content, }); } export async function createStrike( userId: string, givenReason: string, additionalContext: string ) { if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; await checkPermission("users/create/strike", userId, { givenReason, additionalContext, }); const strike: AccountStrike & { moderator_id: string } = { _id: ulid(), user_id: userId, moderator_id: "01EX2NCWQ0CHS3QJF0FEQS1GR4", // TODO reason: additionalContext ? givenReason + " - " + additionalContext : givenReason, }; await mongo() .db("revolt") .collection<{ _id: string }>("safety_strikes") .insertOne(strike); await sendAlert( userId, `You have received an account strike, for one or more reasons: - ${givenReason} Further violations will result in suspension or a permanent ban depending on severity, please abide by the [Acceptable Usage Policy](https://revolt.chat/aup).` ); return strike; } export async function updateReportNotes(reportId: string, notes: string) { await checkPermission("reports/update/notes", reportId, { notes }); return await mongo() .db("revolt") .collection("safety_reports") .updateOne( { _id: reportId }, { $set: { notes, }, } ); } export async function resolveReport(reportId: string) { await checkPermission("reports/update/resolve", reportId); const $set = { status: "Resolved", closed_at: new Date().toISOString(), } as Report; await mongo().db("revolt").collection("safety_reports").updateOne( { _id: reportId }, { $set, } ); return $set; } export async function rejectReport(reportId: string, reason: string) { await checkPermission("reports/update/reject", reportId, { reason }); const $set = { status: "Rejected", rejection_reason: reason, closed_at: new Date().toISOString(), } as Report; await mongo().db("revolt").collection("safety_reports").updateOne( { _id: reportId }, { $set, } ); return $set; } export async function reopenReport(reportId: string) { await checkPermission("reports/update/reopen", reportId); const $set = { status: "Created", } as Report; await mongo() .db("revolt") .collection("safety_reports") .updateOne( { _id: reportId }, { $set, $unset: { closed_at: 1, } as never, } ); return $set; } export async function closeReportsByUser(userId: string) { await checkPermission("reports/update/bulk-close/by-user", userId); return await mongo() .db("revolt") .collection("safety_reports") .updateMany( { status: "Created", author_id: userId, }, { $set: { status: "Rejected", rejection_reason: "bulk close", closed_at: new Date().toISOString(), }, } ) .then((res) => res.modifiedCount); } export async function disableAccount(userId: string) { if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; await checkPermission("accounts/disable", userId); await mongo() .db("revolt") .collection("accounts") .updateOne({ _id: userId }, { $set: { disabled: true } }); await mongo().db("revolt").collection("sessions").deleteMany({ user_id: userId, }); } export async function deleteMFARecoveryCodes(userId: string) { if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; await checkPermission("accounts/update/mfa", userId); await mongo() .db("revolt") .collection("accounts") .updateOne( { _id: userId }, { $unset: { "mfa.recovery_codes": 1, }, }, ) } export async function disableMFA(userId: string) { if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; await checkPermission("accounts/update/mfa", userId); await mongo() .db("revolt") .collection("accounts") .updateOne( { _id: userId }, { $unset: { "mfa.totp_token": 1, }, }, ) } export async function changeAccountEmail(userId: string, email: string) { if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; await checkPermission("accounts/update/email", userId); const SPLIT_RE = /([^@]+)(@.+)/; const SYMBOL_RE = /\+.+|\./g; const segments = SPLIT_RE.exec(email); if (!segments) throw "invalid email"; await mongo() .db("revolt") .collection("accounts") .updateOne( { _id: userId }, { $set: { email: email, email_normalised: segments[1].replace(SYMBOL_RE, "") + segments[2], verification: { status: "Verified" }, }, } ); } export async function verifyAccountEmail(userId: string) { if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; await checkPermission("accounts/update/email", userId); const account = await fetchAccountById(userId); if (!account) throw new Error("couldn't find account"); if (account.verification.status == "Verified") throw new Error("already verified"); let email = account.email; if (account.verification.status == "Moving") { email = account.verification.new_email; } await mongo() .db("revolt") .collection("accounts") .updateOne( { _id: userId }, { $set: { email: email, email_normalised: email, // <-- should be fine but someone should fix this in the future verification: { status: "Verified" }, }, } ); } export async function lookupEmail(email: string): Promise { await checkPermission("accounts/fetch/by-email", email); const accounts = mongo() .db("revolt") .collection("accounts"); let result = await accounts.findOne({ email: email }); if (result) return result._id; result = await accounts.findOne({ email_normalised: email }); if (result) return result._id; return false; } export async function suspendUser(userId: string) { if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; await checkPermission("users/action/suspend", userId); await disableAccount(userId); await mongo() .db("revolt") .collection("users") .updateOne( { _id: userId, }, { $set: { flags: 1, }, } ); const memberships = await mongo() .db("revolt") .collection<{ _id: { user: string; server: string } }>("server_members") .find({ "_id.user": userId }) .toArray(); for (const topic of memberships.map((x) => x._id.server)) { await publishMessage(topic, { type: "UserUpdate", id: userId, data: { flags: 1, }, clear: [], }); } } export async function updateUserBadges(userId: string, badges: number) { await checkPermission("users/update/badges", userId, { badges }); await mongo().db("revolt").collection("users").updateOne( { _id: userId, }, { $set: { badges, }, } ); const memberships = await mongo() .db("revolt") .collection<{ _id: { user: string; server: string } }>("server_members") .find({ "_id.user": userId }) .toArray(); for (const topic of [userId, ...memberships.map((x) => x._id.server)]) { await publishMessage(topic, { type: "UserUpdate", id: userId, data: { badges, }, clear: [], }); } } export async function wipeUser(userId: string, flags = 4) { if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; await checkPermission("users/action/wipe", userId, { flags }); const user = await mongo() .db("revolt") .collection("users") .findOne({ _id: userId }); const messages = await mongo() .db("revolt") .collection("messages") .find({ author: userId }, { sort: { _id: -1 } }) .toArray(); const dms = await mongo() .db("revolt") .collection("channels") .find({ channel_type: "DirectMessage", recipients: userId, }) .toArray(); const memberships = await mongo() .db("revolt") .collection<{ _id: { user: string; server: string } }>("server_members") .find({ "_id.user": userId }) .toArray(); // retrieve messages, dm channels, relationships, server memberships const backup = { _event: "wipe", user, messages, dms, memberships, }; await writeFile( `./exports/${new Date().toISOString()} - ${userId}.json`, JSON.stringify(backup) ); // mark all attachments as deleted + reported const attachmentIds = backup.messages .filter((message) => message.attachments) .map((message) => message.attachments) .flat() .filter((attachment) => attachment) .map((attachment) => attachment!._id); if (backup.user?.avatar) { attachmentIds.push(backup.user.avatar._id); } if (backup.user?.profile?.background) { attachmentIds.push(backup.user.profile.background._id); } if (attachmentIds.length) { await mongo() .db("revolt") .collection("attachments") .updateMany( { _id: { $in: attachmentIds } }, { $set: { reported: true, deleted: true, }, } ); } // delete messages await mongo().db("revolt").collection("messages").deleteMany({ author: userId, }); // delete server memberships await mongo().db("revolt").collection("server_members").deleteMany({ "_id.user": userId, }); // disable account await disableAccount(userId); // clear user profile await mongo() .db("revolt") .collection("users") .updateOne( { _id: userId, }, { $set: { flags, }, $unset: { avatar: 1, profile: 1, status: 1, }, } ); // broadcast wipe event for (const topic of [ ...backup.dms.map((x) => x._id), ...backup.memberships.map((x) => x._id.server), ]) { await publishMessage(topic, { type: "UserPlatformWipe", user_id: userId, flags, }); } } export async function banUser(userId: string) { if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; await checkPermission("users/action/ban", userId); return await wipeUser(userId, 4); } export async function unsuspendUser(userId: string) { if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; await checkPermission("users/action/unsuspend", userId); await restoreAccount(userId); await mongo() .db("revolt") .collection("users") .updateOne( { _id: userId, }, { $unset: { flags: 1, }, } ); } export async function wipeUserProfile( userId: string, fields: { banner: boolean; avatar: boolean; bio: boolean; displayName: boolean; status: boolean; } ) { await checkPermission("users/action/wipe-profile", userId); await mongo() .db("revolt") .collection("users") .updateOne( { _id: userId, }, { $unset: { ...(fields.banner ? { "profile.background": 1 } : {}), ...(fields.bio ? { "profile.content": 1 } : {}), ...(fields.status ? { "status.text": 1 } : {}), ...(fields.avatar ? { avatar: 1 } : {}), ...(fields.displayName ? { display_name: 1 } : {}), }, } ); } export async function updateServerFlags(serverId: string, flags: number) { await checkPermission("servers/update/flags", serverId, { flags }); await mongo().db("revolt").collection("servers").updateOne( { _id: serverId, }, { $set: { flags, }, } ); await publishMessage(serverId, { type: "ServerUpdate", id: serverId, data: { flags, }, clear: [], }); } export async function updateServerDiscoverability( serverId: string, state: boolean ) { await checkPermission("servers/update/discoverability", serverId, { state }); await mongo() .db("revolt") .collection("servers") .updateOne( { _id: serverId, }, { $set: { analytics: state, discoverable: state, }, } ); } export async function updateServerOwner(serverId: string, userId: string) { await checkPermission("servers/update/owner", { serverId, userId }); await mongo() .db("revolt") .collection("servers") .updateOne( { _id: serverId }, { $set: { owner: userId } }, ); await publishMessage(serverId, { type: "ServerUpdate", id: serverId, data: { owner: userId, }, clear: [], }); } export async function addServerMember(serverId: string, userId: string, withEvent: boolean) { await checkPermission("servers/update/add-member", { serverId, userId, withEvent }); const server = await mongo() .db("revolt") .collection("servers") .findOne({ _id: serverId }); const channels = await mongo() .db("revolt") .collection("channels") .find({ server: serverId }) .toArray(); const member = await mongo() .db("revolt") .collection("server_members") .findOne({ _id: { server: serverId, user: userId } }); if (!server) throw new Error("server doesn't exist"); if (member) throw new Error("already a member"); await mongo() .db("revolt") .collection("server_members") .insertOne({ _id: { server: serverId, user: userId }, joined_at: Long.fromNumber(Date.now()) as unknown as string, }); await publishMessage(userId + '!', { type: "ServerCreate", id: serverId, channels: channels, server: server, }); if (withEvent) { await publishMessage(serverId, { type: "ServerMemberJoin", id: serverId, user: userId, }); } } export async function quarantineServer(serverId: string, message: string) { await checkPermission("servers/update/quarantine", { serverId, message }); const server = await mongo() .db("revolt") .collection("servers") .findOne({ _id: serverId }); const members = await mongo() .db("revolt") .collection("server_members") .find({ "_id.server": serverId }) .toArray(); const invites = await mongo() .db("revolt") .collection("channel_invites") .find({ type: "Server", server: serverId }) .toArray(); if (!server) throw new Error("server doesn't exist"); const backup = { _event: "quarantine", server, members, invites, } await writeFile( `./exports/${new Date().toISOString()} - ${serverId}.json`, JSON.stringify(backup), ); await mongo() .db("revolt") .collection("servers") .updateOne( { _id: serverId }, { $set: { owner: "0".repeat(26), analytics: false, discoverable: false, } } ); await mongo() .db("revolt") .collection("server_members") .deleteMany({ "_id.server": serverId }); await mongo() .db("revolt") .collection("channel_invites") .deleteMany({ type: "Server", server: serverId }); await publishMessage(serverId, { type: "ServerDelete", id: serverId, }); while (members.length) { const m = members.splice(0, 50); await Promise.allSettled( m.map(async (member) => { const messageId = ulid(); let dm = await findDM(PLATFORM_MOD_ID, member._id.user); if (!dm) dm = await createDM(PLATFORM_MOD_ID, member._id.user, messageId); await sendChatMessage({ _id: messageId, author: PLATFORM_MOD_ID, channel: dm._id, content: message, }); }) ); } } export async function deleteInvite(invite: string) { await checkPermission("channels/update/invites", invite); if (!invite) throw new Error("invite is empty"); await mongo() .db("revolt") .collection("channel_invites") .deleteOne({ _id: invite }); } export async function editInvite(invite: string, newInvite: string) { await checkPermission("channels/update/invites", { invite, newInvite }); if (!invite) throw new Error("invite is empty"); if (!newInvite) throw new Error("new invite is empty"); const { value } = await mongo() .db("revolt") .collection("channel_invites") .findOneAndDelete({ _id: invite }); if (!value) throw new Error("invite doesn't exist"); await mongo() .db("revolt") .collection("channel_invites") .insertOne({ ...value, _id: newInvite }); } export async function editInviteChannel(invite: string, newChannel: string) { await checkPermission("channels/update/invites", { invite, newChannel }); if (!invite) throw new Error("invite is empty"); await mongo() .db("revolt") .collection("channel_invites") .updateOne({ _id: invite }, { $set: { channel: newChannel } }); } export async function createInvite(invite: ChannelInvite) { await checkPermission("channels/update/invites", invite); await mongo() .db("revolt") .collection("channel_invites") .insertOne(invite); } export async function bulkDeleteInvites(invites: string[]) { await checkPermission("channels/update/invites", invites); await mongo() .db("revolt") .collection("channel_invites") .deleteMany({ _id: { $in: invites } }); } export async function updateBotDiscoverability(botId: string, state: boolean) { await checkPermission("bots/update/discoverability", botId, { state }); await mongo() .db("revolt") .collection("bots") .updateOne( { _id: botId, }, { $set: { analytics: state, discoverable: state, }, } ); } export async function resetBotToken(botId: string) { await checkPermission("bots/update/reset-token", { botId }); // Should generate tokens the exact same as the backend generates them: // https://github.com/revoltchat/backend/blob/41f20c2239ed6307ad821b321d13240dc6ff3327/crates/core/database/src/models/bots/model.rs#L106 await mongo() .db("revolt") .collection("bots") .updateOne( { _id: botId, }, { $set: { token: nanoid(64), }, }, ); } export async function transferBot(botId: string, ownerId: string, resetToken: boolean) { await checkPermission("bots/update/owner", { botId, ownerId, resetToken }); if (resetToken) { await checkPermission("bots/update/reset-token", { botId }); } await mongo() .db("revolt") .collection("bots") .updateOne( { _id: botId, }, { $set: { owner: ownerId, ...( resetToken ? { token: nanoid(64), } : {} ), }, }, ); await mongo() .db("revolt") .collection("users") .updateOne( { _id: botId, }, { $set: { "bot.owner": ownerId, }, }, ); // This doesn't appear to work, maybe Revite can't handle it. I'll leave it in regardless. await publishMessage( botId, { type: "UserUpdate", id: botId, data: { bot: { owner: ownerId, }, }, }, ); } export async function restoreAccount(accountId: string) { if (RESTRICT_ACCESS_LIST.includes(accountId)) throw "restricted access"; await checkPermission("accounts/restore", accountId); await mongo() .db("revolt") .collection("accounts") .updateOne( { _id: accountId, }, { $unset: { disabled: 1, }, } ); } export async function queueAccountDeletion(accountId: string) { if (RESTRICT_ACCESS_LIST.includes(accountId)) throw "restricted access"; await checkPermission("accounts/deletion/queue", accountId); const twoWeeksFuture = new Date(); twoWeeksFuture.setDate(twoWeeksFuture.getDate() + 14); const $set: Partial = { disabled: true, deletion: { status: "Scheduled", after: twoWeeksFuture.toISOString(), }, }; await disableAccount(accountId); await mongo().db("revolt").collection("accounts").updateOne( { _id: accountId, }, { $set, } ); return $set; } export async function cancelAccountDeletion(accountId: string) { if (RESTRICT_ACCESS_LIST.includes(accountId)) throw "restricted access"; await checkPermission("accounts/deletion/cancel", accountId); await mongo() .db("revolt") .collection("accounts") .updateOne( { _id: accountId, }, { $unset: { deletion: 1, }, } ); } export async function fetchBackups() { await checkPermission("backup/fetch", null); return await Promise.all( (await readdir("./exports", { withFileTypes: true })) .filter((file) => file.isFile() && file.name.endsWith(".json")) .map(async (file) => { let type: string | null = null; try { type = JSON.parse((await readFile(`./exports/${file.name}`)).toString("utf-8"))._event; } catch(e) {} return { name: file.name, type: type } }) ); } export async function fetchBackup(name: string) { await checkPermission("backup/fetch/by-name", null); return JSON.parse((await readFile(`./exports/${name}`)).toString("utf-8")); } export async function fetchEmailClassifications(): Promise { await checkPermission("authifier/classification/fetch", null); return await mongo() .db("authifier") .collection("email_classification") .find({}) .toArray(); } export async function createEmailClassification(domain: string, classification: string) { await checkPermission("authifier/classification/create", { domain, classification }); await mongo() .db("authifier") .collection("email_classification") .insertOne( { _id: domain, classification }, ); } export async function updateEmailClassification(domain: string, classification: string) { await checkPermission("authifier/classification/update", { domain, classification }); await mongo() .db("authifier") .collection("email_classification") .updateOne( { _id: domain }, { $set: { classification } }, ); } export async function deleteEmailClassification(domain: string) { await checkPermission("authifier/classification/delete", domain); await mongo() .db("authifier") .collection("email_classification") .deleteOne( { _id: domain }, ); } export async function searchUserByTag(username: string, discriminator: string): Promise { await checkPermission("users/fetch/by-tag", { username, discriminator }); const result = await mongo() .db("revolt") .collection("users") .findOne({ username, discriminator, }); return result?._id || false; } export async function fetchUsersByUsername(username: string) { await checkPermission("users/fetch/bulk-by-username", { username }); return await mongo() .db("revolt") .collection("users") .find({ username, }) .toArray(); } export async function fetchAuditLogEvents() { await checkPermission("audit/fetch", {}); return await mongo() .db("revolt") .collection("safety_audit") .find({}) // .sort({ _id: -1 }) .limit(100) .toArray(); }