Server-Tab (DiscordGSM-Stil), Team-Rechte und Bot-Profil-Beschreibung
- Game-Server: eigene Tabelle statt CSV-Setting (automatische Migration), eigener Setup-Tab mit Server-Builder (Name, Typ fivem/http, Query-URL, Anzeige-Adresse); pro Server ein Live-Embed (gruen/rot, Spieler-Balken, Map, Adresse als Code-Block), edit in place; Embed wird beim Server-Loeschen mit entfernt - Team-System: web_admins mit Bereichs-Scopes (content/community/rollen/bewerbungen/ server/settings/devlogs); Owner behaelt Brand/System/API-Keys/Team exklusiv; 32 Routen-Guards auf Scopes umgestellt, sensible PUT-Felder fuer Team gestrippt; Team-Tab (Owner) + Tab-Filterung im Frontend, /api/me liefert Scopes - Bot-Karte: Profil-Beschreibung (Ueber mich) via application.edit - Fix: settings-Tabelle wird vor der Gameserver-Migration angelegt (frische DBs crashten sonst beim Start) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+173
-33
@@ -10,6 +10,8 @@ import {
|
||||
addAlphaKeys, freeAlphaKeyCount, assignedAlphaKeys, alphaKeyOf, reserveAlphaKey, unreserveAlphaKey,
|
||||
createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm,
|
||||
saveTrigger, listTriggers, deleteTrigger, activityRange,
|
||||
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
|
||||
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
||||
} from '../db.js';
|
||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
|
||||
@@ -28,7 +30,7 @@ function paging(request) {
|
||||
return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page };
|
||||
}
|
||||
|
||||
/** Admin-Guard: null = okay, sonst wurde bereits eine Fehler-Antwort gesendet */
|
||||
/** Owner-Guard (ADMIN_DISCORD_ID): null = okay, sonst wurde bereits geantwortet */
|
||||
function requireAdmin(request, reply) {
|
||||
const user = getSessionUser(request);
|
||||
if (!user) return reply.code(401).send({ error: 'login required' });
|
||||
@@ -36,11 +38,36 @@ function requireAdmin(request, reply) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Web-Scopes des eingeloggten Users: Owner = ['*'], Team = aus web_admins */
|
||||
function scopesOf(user) {
|
||||
if (!user) return [];
|
||||
if (isAdmin(user)) return ['*'];
|
||||
return webAdminScopes(user.id);
|
||||
}
|
||||
|
||||
/** Bereichs-Guard: Owner oder Team-Mitglied mit passendem Scope */
|
||||
function requireScope(request, reply, scope) {
|
||||
const user = getSessionUser(request);
|
||||
if (!user) return reply.code(401).send({ error: 'login required' });
|
||||
const scopes = scopesOf(user);
|
||||
if (scopes.includes('*') || scopes.includes(scope)) return null;
|
||||
return reply.code(403).send({ error: `scope '${scope}' required` });
|
||||
}
|
||||
|
||||
/** Mindestens irgendein Team-Zugang (für gemeinsame Daten wie Kanal-Listen) */
|
||||
function requireAnyScope(request, reply) {
|
||||
const user = getSessionUser(request);
|
||||
if (!user) return reply.code(401).send({ error: 'login required' });
|
||||
if (scopesOf(user).length === 0) return reply.code(403).send({ error: 'no access' });
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerApiRoutes(app, client) {
|
||||
// Wer bin ich? (fürs Frontend: Login-Status + Admin-Flag)
|
||||
// Wer bin ich? (fürs Frontend: Login-Status, Owner-Flag + Team-Scopes)
|
||||
app.get('/api/me', async (request) => {
|
||||
const user = getSessionUser(request);
|
||||
return user ? { user, admin: isAdmin(user) } : { user: null, admin: false };
|
||||
if (!user) return { user: null, admin: false, scopes: [] };
|
||||
return { user, admin: isAdmin(user), scopes: scopesOf(user) };
|
||||
});
|
||||
|
||||
// Devlog-Archiv — öffentlich (wie der Discord-Kanal); ?q= für Volltextsuche
|
||||
@@ -76,7 +103,7 @@ export function registerApiRoutes(app, client) {
|
||||
|
||||
// Playtester-Liste — Admin
|
||||
app.get('/api/playtesters', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'community')) return;
|
||||
return { playtesters: listPlaytesters() };
|
||||
});
|
||||
|
||||
@@ -181,7 +208,7 @@ ${rssItems}
|
||||
|
||||
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
|
||||
app.delete('/api/devlogs/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'devlogs')) return;
|
||||
|
||||
const deleted = await removeDevlog(request.params.id);
|
||||
request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`);
|
||||
@@ -253,7 +280,6 @@ ${rssItems}
|
||||
status_channel_id: getSetting('status_channel_id') ?? '',
|
||||
voting_channel_id: getSetting('voting_channel_id') ?? '',
|
||||
ticket_channel_id: getSetting('ticket_channel_id') ?? '',
|
||||
gameservers: getSetting('gameservers') ?? '',
|
||||
autorole_id: getSetting('autorole_id') ?? '',
|
||||
brand_name: getSetting('brand_name') ?? 'D4RKST3R',
|
||||
brand_color: getSetting('brand_color') ?? '#f5c518',
|
||||
@@ -277,8 +303,10 @@ ${rssItems}
|
||||
}
|
||||
|
||||
app.get('/api/settings', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireAnyScope(request, reply)) return;
|
||||
|
||||
// Application-Daten (Beschreibung) nachladen — best effort
|
||||
await client.application?.fetch?.().catch(() => {});
|
||||
const stats = archiveStats();
|
||||
return {
|
||||
channels: listChannels(),
|
||||
@@ -288,6 +316,7 @@ ${rssItems}
|
||||
botTag: client.user?.tag ?? null,
|
||||
botName: client.user?.username ?? null,
|
||||
botAvatar: client.user?.displayAvatarURL?.({ size: 128 }) ?? null,
|
||||
botDescription: client.application?.description ?? '',
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
guilds: client.guilds.cache.size,
|
||||
giteaTokenConfigured: Boolean(giteaApiToken()),
|
||||
@@ -298,10 +327,18 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.put('/api/settings', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'settings')) return;
|
||||
|
||||
const body = request.body ?? {};
|
||||
|
||||
// Sensible Felder sind Owner-only — für Team-Mitglieder still ignorieren
|
||||
if (!isAdmin(getSessionUser(request))) {
|
||||
for (const key of [
|
||||
'gitea_api_token', 'twitch_client_id', 'twitch_client_secret',
|
||||
'discord_guild_id', 'bot_name', 'bot_description', 'public_url', 'gitea_url',
|
||||
]) delete body[key];
|
||||
}
|
||||
|
||||
// Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus)
|
||||
const OPTIONAL_CHANNELS = [
|
||||
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
|
||||
@@ -409,6 +446,15 @@ ${rssItems}
|
||||
return reply.code(502).send({ error: `Bot-Name: Discord lehnt ab (${error.message?.slice(0, 80)})` });
|
||||
}
|
||||
}
|
||||
// Bot-Profil-Beschreibung („Über mich" der App)
|
||||
if (body.bot_description !== undefined &&
|
||||
String(body.bot_description) !== (client.application?.description ?? '')) {
|
||||
try {
|
||||
await client.application.edit({ description: String(body.bot_description).slice(0, 400) });
|
||||
} catch (error) {
|
||||
return reply.code(502).send({ error: `Beschreibung: Discord lehnt ab (${error.message?.slice(0, 80)})` });
|
||||
}
|
||||
}
|
||||
if (statusChanged) {
|
||||
const { applyBotStatus } = await import('../bot/presence.js');
|
||||
applyBotStatus(client);
|
||||
@@ -429,7 +475,7 @@ ${rssItems}
|
||||
setSetting(key, String(body[key]).trim());
|
||||
}
|
||||
}
|
||||
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', 'gameservers']) {
|
||||
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo']) {
|
||||
if (body[key] !== undefined) {
|
||||
setSetting(key, String(body[key]).trim());
|
||||
}
|
||||
@@ -501,12 +547,12 @@ ${rssItems}
|
||||
const menuToJson = (m) => ({ ...m, exclusive: Boolean(m.exclusive), entries: JSON.parse(m.entries || '[]') });
|
||||
|
||||
app.get('/api/rolemenus', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'rollen')) return;
|
||||
return { menus: listRoleMenus().map(menuToJson) };
|
||||
});
|
||||
|
||||
app.post('/api/rolemenus', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'rollen')) return;
|
||||
const menu = parseMenuBody(request, reply);
|
||||
if (!menu) return;
|
||||
const id = createRoleMenu(menu);
|
||||
@@ -515,7 +561,7 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.put('/api/rolemenus/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'rollen')) return;
|
||||
const id = Number(request.params.id);
|
||||
if (!getRoleMenu(id)) return reply.code(404).send({ error: 'Menü nicht gefunden' });
|
||||
const menu = parseMenuBody(request, reply);
|
||||
@@ -535,7 +581,7 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.post('/api/rolemenus/:id/publish', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'rollen')) return;
|
||||
try {
|
||||
const messageId = await publishRoleMenu(client, Number(request.params.id));
|
||||
request.log.info(`Rollen-Menü ${request.params.id} publiziert (${messageId})`);
|
||||
@@ -546,7 +592,7 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.delete('/api/rolemenus/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'rollen')) return;
|
||||
const menu = getRoleMenu(Number(request.params.id));
|
||||
if (!menu) return { deleted: false };
|
||||
await unpublishRoleMenu(client, menu);
|
||||
@@ -564,12 +610,12 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.get('/api/alphakeys', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'community')) return;
|
||||
return keyStatus();
|
||||
});
|
||||
|
||||
app.post('/api/alphakeys', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'community')) return;
|
||||
const keys = String(request.body?.keys ?? '')
|
||||
.split(/\r?\n/)
|
||||
.map((k) => k.trim())
|
||||
@@ -582,7 +628,7 @@ ${rssItems}
|
||||
|
||||
// Verteilen: jeder Playtester ohne Key bekommt einen per DM (Key bleibt frei bei DM-Fehler)
|
||||
app.post('/api/alphakeys/distribute', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'community')) return;
|
||||
let sent = 0;
|
||||
const failed = [];
|
||||
for (const p of keyStatus().playtestersWithout) {
|
||||
@@ -641,12 +687,12 @@ ${rssItems}
|
||||
}
|
||||
|
||||
app.get('/api/appforms', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'bewerbungen')) return;
|
||||
return { forms: listAppForms().map(formToJson) };
|
||||
});
|
||||
|
||||
app.post('/api/appforms', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'bewerbungen')) return;
|
||||
const form = parseFormBody(request, reply);
|
||||
if (!form) return;
|
||||
const id = createAppForm(form);
|
||||
@@ -654,7 +700,7 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.put('/api/appforms/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'bewerbungen')) return;
|
||||
const id = Number(request.params.id);
|
||||
if (!getAppForm(id)) return reply.code(404).send({ error: 'Formular nicht gefunden' });
|
||||
const form = parseFormBody(request, reply);
|
||||
@@ -669,7 +715,7 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.post('/api/appforms/:id/publish', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'bewerbungen')) return;
|
||||
try {
|
||||
const messageId = await publishAppForm(client, Number(request.params.id));
|
||||
return { ok: true, message_id: messageId };
|
||||
@@ -679,7 +725,7 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.delete('/api/appforms/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'bewerbungen')) return;
|
||||
const form = getAppForm(Number(request.params.id));
|
||||
if (!form) return { deleted: false };
|
||||
await unpublishAppForm(client, form);
|
||||
@@ -687,15 +733,72 @@ ${rssItems}
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
// --- Game-Server (Admin) — DiscordGSM-Stil ---
|
||||
|
||||
function parseServerBody(request, reply) {
|
||||
const body = request.body ?? {};
|
||||
const name = String(body.name ?? '').trim();
|
||||
const queryUrl = String(body.query_url ?? '').trim().replace(/\/$/, '');
|
||||
const type = ['fivem', 'http'].includes(body.type) ? body.type : 'fivem';
|
||||
if (!name || !/^https?:\/\/.+/.test(queryUrl)) {
|
||||
reply.code(400).send({ error: 'name und query_url (http/https) nötig' });
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: name.slice(0, 60),
|
||||
type,
|
||||
query_url: queryUrl,
|
||||
address: String(body.address ?? '').trim().slice(0, 120),
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/gameservers', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'server')) return;
|
||||
return { servers: listGameservers() };
|
||||
});
|
||||
|
||||
app.post('/api/gameservers', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'server')) return;
|
||||
const server = parseServerBody(request, reply);
|
||||
if (!server) return;
|
||||
const id = createGameserver(server);
|
||||
return { ok: true, server: getGameserver(id) };
|
||||
});
|
||||
|
||||
app.put('/api/gameservers/:id', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'server')) return;
|
||||
const id = Number(request.params.id);
|
||||
if (!getGameserver(id)) return reply.code(404).send({ error: 'Server nicht gefunden' });
|
||||
const server = parseServerBody(request, reply);
|
||||
if (!server) return;
|
||||
updateGameserver({ ...server, id });
|
||||
return { ok: true, server: getGameserver(id) };
|
||||
});
|
||||
|
||||
app.delete('/api/gameservers/:id', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'server')) return;
|
||||
const server = getGameserver(Number(request.params.id));
|
||||
if (!server) return { deleted: false };
|
||||
// Status-Embed mit aufräumen
|
||||
if (server.message_id) {
|
||||
const channelId = getSetting('status_channel_id');
|
||||
const channel = channelId ? await client.channels.fetch(channelId).catch(() => null) : null;
|
||||
const message = await channel?.messages?.fetch(server.message_id).catch(() => null);
|
||||
await message?.delete().catch(() => {});
|
||||
}
|
||||
deleteGameserver(server.id);
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
// --- Triggers (Admin) ---
|
||||
|
||||
app.get('/api/triggers', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
return { triggers: listTriggers() };
|
||||
});
|
||||
|
||||
app.put('/api/triggers', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
const keyword = String(request.body?.keyword ?? '').trim().toLowerCase();
|
||||
const replyText = String(request.body?.reply ?? '').trim();
|
||||
if (keyword.length < 3 || !replyText) {
|
||||
@@ -706,19 +809,19 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.delete('/api/triggers/:keyword', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
return { deleted: deleteTrigger(String(request.params.keyword).toLowerCase()) };
|
||||
});
|
||||
|
||||
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
|
||||
|
||||
app.get('/api/tags', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
return { tags: listTags() };
|
||||
});
|
||||
|
||||
app.put('/api/tags/:name', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
const name = String(request.params.name).trim().toLowerCase();
|
||||
const content = String(request.body?.content ?? '').trim();
|
||||
if (!/^[a-z0-9äöüß_-]{1,40}$/.test(name)) {
|
||||
@@ -730,19 +833,19 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.delete('/api/tags/:name', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
return { deleted: deleteTag(String(request.params.name).trim().toLowerCase()) };
|
||||
});
|
||||
|
||||
// --- Geplante Posts (Admin) ---
|
||||
|
||||
app.get('/api/scheduled', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
return { posts: listScheduledPosts() };
|
||||
});
|
||||
|
||||
app.post('/api/scheduled', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
const body = request.body ?? {};
|
||||
const channelId = String(body.channel_id ?? '');
|
||||
const content = String(body.content ?? '').trim();
|
||||
@@ -774,13 +877,13 @@ ${rssItems}
|
||||
});
|
||||
|
||||
app.delete('/api/scheduled/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
return { deleted: deleteScheduledPost(Number(request.params.id)) };
|
||||
});
|
||||
|
||||
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
|
||||
app.post('/api/compose', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
|
||||
const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle } = request.body ?? {};
|
||||
const text = String(content ?? '').trim();
|
||||
@@ -851,6 +954,43 @@ ${rssItems}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Team-Verwaltung (nur Owner) ---
|
||||
|
||||
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs'];
|
||||
|
||||
app.get('/api/webadmins', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
return { admins: listWebAdmins(), scopes: TEAM_SCOPES };
|
||||
});
|
||||
|
||||
app.put('/api/webadmins', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const userId = String(request.body?.user_id ?? '').trim();
|
||||
const scopes = (Array.isArray(request.body?.scopes) ? request.body.scopes : [])
|
||||
.filter((s) => TEAM_SCOPES.includes(s));
|
||||
if (!/^\d{15,21}$/.test(userId)) {
|
||||
return reply.code(400).send({ error: 'user_id: Discord-User-ID erwartet' });
|
||||
}
|
||||
if (userId === config.adminDiscordId) {
|
||||
return reply.code(400).send({ error: 'Der Owner hat immer alle Rechte' });
|
||||
}
|
||||
if (scopes.length === 0) return reply.code(400).send({ error: 'mindestens ein Bereich' });
|
||||
// Username für die Anzeige holen (best effort)
|
||||
let username = String(request.body?.username ?? '').trim();
|
||||
if (!username) {
|
||||
const user = await client.users.fetch(userId).catch(() => null);
|
||||
username = user?.username ?? userId;
|
||||
}
|
||||
saveWebAdmin(userId, username, scopes);
|
||||
request.log.info(`Team-Mitglied ${username} (${userId}): ${scopes.join(',')}`);
|
||||
return { ok: true, admins: listWebAdmins() };
|
||||
});
|
||||
|
||||
app.delete('/api/webadmins/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
return { deleted: deleteWebAdmin(String(request.params.id)), admins: listWebAdmins() };
|
||||
});
|
||||
|
||||
// --- API-Keys (Admin) — für /api/v1/* ---
|
||||
|
||||
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
|
||||
@@ -883,7 +1023,7 @@ ${rssItems}
|
||||
|
||||
// Test-Embed in den konfigurierten Kanal senden (prüft die Kanal-Wahl ohne Push/Devlog)
|
||||
app.post('/api/settings/test/:target', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
if (requireScope(request, reply, 'settings')) return;
|
||||
|
||||
const target = request.params.target;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user