mirror of
https://github.com/PretendoNetwork/website.git
synced 2026-08-24 01:26:57 -05:00
feat: implement discord role syncing
This commit is contained in:
@@ -33,6 +33,7 @@ export default defineNuxtConfig({
|
||||
|
||||
githubApiToken: '',
|
||||
stripeSecretKey: '',
|
||||
stripeNotificationEmail: '',
|
||||
grpcHost: '',
|
||||
grpcApiKey: '',
|
||||
mongoConnectionString: '',
|
||||
@@ -43,6 +44,10 @@ export default defineNuxtConfig({
|
||||
smtpSecure: true,
|
||||
smtpFromEmail: '',
|
||||
smtpFromName: '',
|
||||
discordBotToken: '',
|
||||
discordGuildId: '',
|
||||
discordTesterRoleId: '',
|
||||
discordSupporterRoleId: '',
|
||||
|
||||
public: {
|
||||
apiBase: 'https://api.pretendo.cc',
|
||||
|
||||
107
package-lock.json
generated
107
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@discordjs/rest": "^2.6.3",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@nuxt/content": "^3.4.0",
|
||||
"@nuxt/eslint": "^1.3.0",
|
||||
@@ -18,6 +19,7 @@
|
||||
"@pinia/nuxt": "^1.0.1",
|
||||
"@pretendonetwork/grpc": "^2.5.4",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"discord-api-types": "^0.38.53",
|
||||
"eslint": "^9.39.5",
|
||||
"feed": "^6.0.0",
|
||||
"mongodb": "^7.5.0",
|
||||
@@ -537,6 +539,65 @@
|
||||
"integrity": "sha512-3PxTH8itZzltK0U9jTwVVnjLXvnDYuq3m+QXsHkENxWiPRh4WaoLcs1SQjqgZ55kS+QyirpH5BVwzP2gMVG6EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@discordjs/collection": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/rest": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz",
|
||||
"integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/collection": "^2.1.1",
|
||||
"@discordjs/util": "^1.2.0",
|
||||
"@sapphire/async-queue": "^1.5.3",
|
||||
"@sapphire/snowflake": "^3.5.5",
|
||||
"@vladfrangu/async_event_emitter": "^2.4.6",
|
||||
"discord-api-types": "^0.38.50",
|
||||
"magic-bytes.js": "^1.13.0",
|
||||
"tslib": "^2.6.3",
|
||||
"undici": "^6.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/rest/node_modules/undici": {
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/util": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
|
||||
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"discord-api-types": "^0.38.33"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@dxup/nuxt": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.5.6.tgz",
|
||||
@@ -6011,6 +6072,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sapphire/async-queue": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
|
||||
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/snowflake": {
|
||||
"version": "3.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",
|
||||
"integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/core": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.2.tgz",
|
||||
@@ -7108,6 +7189,16 @@
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vladfrangu/async_event_emitter": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
|
||||
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/language-core": {
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
|
||||
@@ -9453,6 +9544,15 @@
|
||||
"integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/discord-api-types": {
|
||||
"version": "0.38.53",
|
||||
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz",
|
||||
"integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"scripts/actions/documentation"
|
||||
]
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -14212,6 +14312,12 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-bytes.js": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz",
|
||||
"integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-regexp": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz",
|
||||
@@ -20511,7 +20617,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/rest": "^2.6.3",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@nuxt/content": "^3.4.0",
|
||||
"@nuxt/eslint": "^1.3.0",
|
||||
@@ -22,6 +23,7 @@
|
||||
"@pinia/nuxt": "^1.0.1",
|
||||
"@pretendonetwork/grpc": "^2.5.4",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"discord-api-types": "^0.38.53",
|
||||
"eslint": "^9.39.5",
|
||||
"feed": "^6.0.0",
|
||||
"mongodb": "^7.5.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Stripe } from "stripe";
|
||||
import { handleStripeEvent } from "~~/server/utils/handleStripeWebhook";
|
||||
|
||||
export default defineEventHandler(async (event): Promise<{ success: boolean, message?: string }> => {
|
||||
const config = useRuntimeConfig(event);
|
||||
@@ -21,7 +22,9 @@ export default defineEventHandler(async (event): Promise<{ success: boolean, mes
|
||||
}
|
||||
}
|
||||
|
||||
await handleStripeEvent(stripe, webhookEvent);
|
||||
const notificationEmails: string[] = [];
|
||||
if (config.stripeNotificationEmail) notificationEmails.push(config.stripeNotificationEmail);
|
||||
await handleStripeEvent(event, stripe, webhookEvent, notificationEmails);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
56
server/utils/discord.ts
Normal file
56
server/utils/discord.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { REST } from '@discordjs/rest';
|
||||
import { Routes } from 'discord-api-types/v10';
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
type DiscordIds = { guildId: string, supporterRoleId: string | null, testerRoleId: string | null }
|
||||
|
||||
type DiscordInstance = {
|
||||
rest: REST,
|
||||
ids: DiscordIds,
|
||||
}
|
||||
|
||||
let discordInstance: DiscordInstance | null = null;
|
||||
|
||||
export function useDiscord(event: H3Event): DiscordInstance | null {
|
||||
if (!discordInstance) {
|
||||
const config = useRuntimeConfig(event);
|
||||
if (config.discordBotToken && config.discordGuildId) {
|
||||
discordInstance = {
|
||||
rest: new REST({ version: '10' }).setToken(config.discordBotToken),
|
||||
ids: {
|
||||
guildId: config.discordGuildId,
|
||||
testerRoleId: config.discordTesterRoleId ? config.discordTesterRoleId : null,
|
||||
supporterRoleId: config.discordSupporterRoleId ? config.discordSupporterRoleId : null,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return discordInstance;
|
||||
}
|
||||
|
||||
export async function assignDiscordMemberSupporterRole(discord: DiscordInstance, memberId: string, roleId: string) {
|
||||
if (discord.ids.supporterRoleId) {
|
||||
await discord.rest.put(Routes.guildMemberRole(discord.ids.guildId, memberId, discord.ids.supporterRoleId));
|
||||
}
|
||||
await discord.rest.put(Routes.guildMemberRole(discord.ids.guildId, memberId, roleId));
|
||||
}
|
||||
|
||||
export async function assignDiscordMemberTesterRole(discord: DiscordInstance, memberId: string) {
|
||||
if (discord.ids.testerRoleId) {
|
||||
await discord.rest.put(Routes.guildMemberRole(discord.ids.guildId, memberId, discord.ids.testerRoleId));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDiscordMemberSupporterRole(discord: DiscordInstance, memberId: string, roleId: string) {
|
||||
if (discord.ids.supporterRoleId) {
|
||||
await discord.rest.delete(Routes.guildMemberRole(discord.ids.guildId, memberId, discord.ids.supporterRoleId));
|
||||
}
|
||||
await discord.rest.delete(Routes.guildMemberRole(discord.ids.guildId, memberId, roleId));
|
||||
}
|
||||
|
||||
export async function removeDiscordMemberTesterRole(discord: DiscordInstance, memberId: string) {
|
||||
if (discord.ids.testerRoleId) {
|
||||
await discord.rest.delete(Routes.guildMemberRole(discord.ids.guildId, memberId, discord.ids.testerRoleId));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { PnidDocument, usePapr } from "./papr";
|
||||
import { Transporter } from "nodemailer";
|
||||
import { PaprMatchKeysAndValues } from "papr";
|
||||
import type { H3Event } from 'h3';
|
||||
import { assignDiscordMemberSupporterRole, assignDiscordMemberTesterRole, removeDiscordMemberSupporterRole, removeDiscordMemberTesterRole, useDiscord } from "./discord";
|
||||
|
||||
async function sendEmailToCustomer(mailer: Transporter, customer: Stripe.Customer, ops: { pid: number, title: string, body: string }): Promise<void> {
|
||||
try {
|
||||
@@ -35,6 +36,7 @@ async function sendToNotificationEmails(mailer: Transporter, notificationEmails:
|
||||
|
||||
export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: Stripe.Event, notificationEmails: string[]) {
|
||||
const mailer = useMailer(event);
|
||||
const discord = useDiscord(event);
|
||||
const papr = await usePapr(event);
|
||||
|
||||
if (!mailer || !papr) {
|
||||
@@ -134,14 +136,18 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
|
||||
if (subscription.status === 'canceled' && currentSubscriptionId && subscription.id !== currentSubscriptionId) {
|
||||
// Canceling old subscription, do nothing but update webhook date and remove Discord roles
|
||||
if (product.metadata.beta === 'true') {
|
||||
await util.removeDiscordMemberTesterRole(discordId).catch((error) => {
|
||||
console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
|
||||
});
|
||||
if (discord && discordId) {
|
||||
await removeDiscordMemberTesterRole(discord, discordId).catch((error) => {
|
||||
console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
|
||||
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
|
||||
});
|
||||
if (discord && discordId && product.metadata.discord_role_id) {
|
||||
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
|
||||
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
|
||||
});
|
||||
}
|
||||
|
||||
await papr.Pnid.updateOne({
|
||||
pid,
|
||||
@@ -172,9 +178,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
|
||||
updateData.server_access_level = 'test';
|
||||
}
|
||||
|
||||
await util.assignDiscordMemberTesterRole(discordId).catch((error) => {
|
||||
console.error(`Error assigning user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
|
||||
});
|
||||
if (discord && discordId) {
|
||||
await assignDiscordMemberTesterRole(discord, discordId).catch((error) => {
|
||||
console.error(`Error assigning user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// * Assume any status other than active means payment has not been fulfilled
|
||||
// * Once the payment goes through, status should update to active
|
||||
@@ -183,9 +191,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
|
||||
updateData.server_access_level = 'prod';
|
||||
}
|
||||
|
||||
await util.removeDiscordMemberTesterRole(discordId).catch((error) => {
|
||||
console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
|
||||
});
|
||||
if (discord && discordId) {
|
||||
await removeDiscordMemberTesterRole(discord, discordId).catch((error) => {
|
||||
console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,9 +235,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
|
||||
body: `Thank you for purchasing the ${product.name} tier! We greatly value your support, thank you for helping keep Pretendo Network alive!\nIt may take a moment for your account dashboard to reflect these changes. Please wait a moment and refresh the dashboard to see them!`
|
||||
})
|
||||
|
||||
await util.assignDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
|
||||
console.error(`Error assigning user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
|
||||
});
|
||||
if (discord && discordId && product.metadata.discord_role_id) {
|
||||
await assignDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
|
||||
console.error(`Error assigning user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
|
||||
});
|
||||
}
|
||||
|
||||
await sendToNotificationEmails(mailer, notificationEmails, {
|
||||
title: `New ${product.name} subscription`,
|
||||
@@ -240,9 +252,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
|
||||
body: `Your subscription for the ${product.name} tier has been canceled. We thank for your previous support, and hope you still enjoy the network! `
|
||||
})
|
||||
|
||||
await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
|
||||
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
|
||||
});
|
||||
if (discord && discordId && product.metadata.discord_role_id) {
|
||||
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
|
||||
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
|
||||
});
|
||||
}
|
||||
|
||||
await sendToNotificationEmails(mailer, notificationEmails, {
|
||||
title: `Canceled ${product.name} subscription`,
|
||||
@@ -255,9 +269,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
|
||||
body: `Your subscription for the ${product.name} tier has been canceled due to non payment. We thank for your previous support, and hope you still enjoy the network! `
|
||||
})
|
||||
|
||||
await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
|
||||
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
|
||||
});
|
||||
if (discord && discordId && product.metadata.discord_role_id) {
|
||||
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
|
||||
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
|
||||
});
|
||||
}
|
||||
|
||||
await sendToNotificationEmails(mailer, notificationEmails, {
|
||||
title: `Removed ${product.name} subscription`,
|
||||
@@ -270,9 +286,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
|
||||
body: `Your subscription for the ${product.name} tier has changed status to ${subscription.status}. This is usually caused by payment failure. Your account has been reverted back to default until payment resumes. If you believe this to be an error, please reach out for support on our Discord server, and we thank you for your previous support!`
|
||||
})
|
||||
|
||||
await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
|
||||
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
|
||||
});
|
||||
if (discord && discordId && product.metadata.discord_role_id) {
|
||||
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
|
||||
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
|
||||
});
|
||||
}
|
||||
|
||||
await sendToNotificationEmails(mailer, notificationEmails, {
|
||||
title: `Removed ${product.name} subscription`,
|
||||
|
||||
Reference in New Issue
Block a user