local-instance: simulate a teacher, and run the files-storage consumer
`scripts/simulate-teacher.mjs` creates, edits and deletes what teachers create — course, room, topic, task, board, columns, cards, rich text, link, Etherpad pad, folder, files — so the MCP server can be exercised against content the real account has never held. It is the only thing here that writes to a Schulcloud and refuses any non-localhost address. `scripts/mcp-env.sh` points the server and CLI at the instance. Two gaps it exposed in the stack itself: The files-storage AMQP consumer is a separate entrypoint, and we were running only the HTTP one. Nothing was bound to the `files-storage` exchange, so `TaskService.delete` — which awaits deleteFilesOfParent over AMQP before touching the task — hung until the request timeout. Deleting any task or topic answered 408 with the entity still there. The demo data is dated 2017-2018 and the v3 endpoints filter on those dates, so a student saw no tasks at all. seed.sh now brings courses and homework into the present, which is the difference between a fixture that exercises the student-facing surface and one that looks empty. Teams turn out to be uncreatable through the API (the legacy service registers no `create`, v3 has no route), and a new board is unpublished and 403s for students, so the simulation adopts a seeded team and leaves one board a draft on purpose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
30
local-instance/scripts/mcp-env.sh
Executable file
30
local-instance/scripts/mcp-env.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mint a session on the local instance and print the environment the MCP server
|
||||
# and CLI expect, so they can be pointed at it instead of the live Schulcloud.
|
||||
#
|
||||
# eval "$(./scripts/mcp-env.sh)" # as the demo student
|
||||
# eval "$(./scripts/mcp-env.sh klara.fall@schul-cloud.org Schulcloud1\!)"
|
||||
#
|
||||
# Nothing is written to the repo: the output contains a live session token, and
|
||||
# a throwaway instance is still no reason to start committing those.
|
||||
set -euo pipefail
|
||||
|
||||
# `localhost`, not `127.0.0.1`: it must match SC_DOMAIN, because urls the server
|
||||
# hands back (Etherpad pads, for one) are built from it, and the client refuses
|
||||
# to follow a url onto a different host rather than leak a session cookie there.
|
||||
URL=${LOCAL_SC_URL:-http://localhost:4400}
|
||||
USER=${1:-demo-schueler@schul-cloud.org}
|
||||
PASS=${2:-schulcloud}
|
||||
|
||||
token=$(curl -fsS -X POST "$URL/api/v3/authentication/local" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(printf '{"username":%s,"password":%s}' \
|
||||
"$(printf '%s' "$USER" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
|
||||
"$(printf '%s' "$PASS" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["accessToken"])')
|
||||
|
||||
cat <<ENV
|
||||
export TSC_URL=$URL
|
||||
export TSC_JWT_COOKIE=$token
|
||||
export MCP_AUTH_TOKEN=local-instance-token
|
||||
ENV
|
||||
@@ -49,6 +49,43 @@ SECRET=$(curl -fsS -X POST "$MGMT/encrypt-plain-text" \
|
||||
print(' schools linked to the provider: ' + r.modifiedCount);
|
||||
"
|
||||
|
||||
# The demo data is dated 2017-2018, and the v3 endpoints filter on those dates:
|
||||
# a course that has ended shows a student no tasks, and a task whose due date
|
||||
# passed years ago is in neither the open nor the finished list and has dropped
|
||||
# off the course page. The result is a student account that looks empty for
|
||||
# reasons that have nothing to do with whatever is being tested against it.
|
||||
#
|
||||
# So move the stale data into the present. Courses get a term around today.
|
||||
# Homework is shifted by one offset per date cluster, which keeps the relative
|
||||
# order — and so which tasks are past due, the ones carrying the graded
|
||||
# submissions — while landing the newest of them two weeks ago.
|
||||
echo "==> dating the demo data to the present"
|
||||
"${COMPOSE[@]}" exec -T mongo mongosh schulcloud --quiet --eval "
|
||||
const now = new Date();
|
||||
const courses = db.courses.updateMany({ untilDate: { \$lt: now } }, { \$set: {
|
||||
startDate: new Date(now.getTime() - 180 * 86400000),
|
||||
untilDate: new Date(now.getTime() + 185 * 86400000),
|
||||
} });
|
||||
print(' courses given a current term: ' + courses.modifiedCount);
|
||||
|
||||
let moved = 0;
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
const cutoff = new Date(Date.now() - 365 * 86400000);
|
||||
const newest = db.homeworks.find({ dueDate: { \$lt: cutoff } }).sort({ dueDate: -1 }).limit(1).toArray()[0];
|
||||
if (!newest) break;
|
||||
const offset = (Date.now() - 14 * 86400000) - newest.dueDate.getTime();
|
||||
db.homeworks.find({ dueDate: { \$lt: cutoff } }).forEach((h) => {
|
||||
const set = {};
|
||||
for (const field of ['dueDate', 'availableDate', 'createdAt', 'updatedAt']) {
|
||||
if (h[field] instanceof Date) set[field] = new Date(h[field].getTime() + offset);
|
||||
}
|
||||
db.homeworks.updateOne({ _id: h._id }, { \$set: set });
|
||||
moved++;
|
||||
});
|
||||
}
|
||||
print(' homework brought forward: ' + moved);
|
||||
"
|
||||
|
||||
cat <<'ACCOUNTS'
|
||||
|
||||
==> ready — http://localhost:4400
|
||||
|
||||
356
local-instance/scripts/simulate-teacher.mjs
Executable file
356
local-instance/scripts/simulate-teacher.mjs
Executable file
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Simulate a teacher's work against the LOCAL instance: create, change and
|
||||
* delete the things teachers actually create, so the MCP server can be exercised
|
||||
* against content the real account has never contained.
|
||||
*
|
||||
* This is the only thing in this repository that writes to a Schulcloud, and the
|
||||
* guard below is what keeps it that way. The MCP server itself stays read-only;
|
||||
* nothing here runs through it.
|
||||
*
|
||||
* node scripts/simulate-teacher.mjs create # build the fixture, print ids
|
||||
* node scripts/simulate-teacher.mjs update # rename/edit everything it made
|
||||
* node scripts/simulate-teacher.mjs delete # remove it again
|
||||
*
|
||||
* State lives in .simulate-teacher.json so the phases can be run one at a time
|
||||
* with MCP checks in between.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const STATE = join(ROOT, '.simulate-teacher.json');
|
||||
|
||||
// The server's own port, not the nginx origin: `/api/v1` is deliberately not
|
||||
// routed through the ingress (the live deployment does the same), and the
|
||||
// legacy services are the only way to create tasks and topics.
|
||||
const API = process.env.LOCAL_SC_API ?? 'http://127.0.0.1:3030';
|
||||
const FILES = process.env.LOCAL_SC_FILES ?? 'http://127.0.0.1:4444';
|
||||
|
||||
for (const [name, url] of [['LOCAL_SC_API', API], ['LOCAL_SC_FILES', FILES]]) {
|
||||
const { hostname } = new URL(url);
|
||||
if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1') {
|
||||
console.error(`refusing to run: ${name}=${url} is not a localhost address.`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const TEACHER = process.env.SIM_TEACHER ?? 'klara.fall@schul-cloud.org';
|
||||
const PASSWORD = process.env.SIM_PASSWORD ?? 'Schulcloud1!';
|
||||
/** Everything is created here, because the demo student is a member. */
|
||||
const COURSE = process.env.SIM_COURSE ?? '59a3c657a2049554a93fec3a'; // Biologie 9b
|
||||
const STUDENT_EMAIL = 'demo-schueler@schul-cloud.org';
|
||||
|
||||
let jwt = '';
|
||||
let me;
|
||||
|
||||
async function req(base, method, path, body, { raw = false } = {}) {
|
||||
const headers = { Authorization: `Bearer ${jwt}` };
|
||||
let payload;
|
||||
if (body instanceof FormData) {
|
||||
payload = body;
|
||||
} else if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
payload = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(`${base}${path}`, { method, headers, body: payload });
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 400)}`);
|
||||
}
|
||||
if (raw) return text;
|
||||
return text ? JSON.parse(text) : undefined;
|
||||
}
|
||||
|
||||
const v3 = (method, path, body, opts) => req(API, method, `/api/v3${path}`, body, opts);
|
||||
const v1 = (method, path, body, opts) => req(API, method, `/api/v1${path}`, body, opts);
|
||||
const files = (method, path, body, opts) => req(FILES, method, `/api/v3/file${path}`, body, opts);
|
||||
|
||||
const log = (...a) => console.log(' ', ...a);
|
||||
const step = (s) => console.log(`\n== ${s} ==`);
|
||||
|
||||
function loadState() {
|
||||
return existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {};
|
||||
}
|
||||
function saveState(s) {
|
||||
writeFileSync(STATE, JSON.stringify(s, null, '\t') + '\n');
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const res = await fetch(`${API}/api/v3/authentication/local`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: TEACHER, password: PASSWORD }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`login failed: ${res.status} ${await res.text()}`);
|
||||
jwt = (await res.json()).accessToken;
|
||||
me = await v3('GET', '/me');
|
||||
log(`signed in as ${me.user.firstName} ${me.user.lastName} (${me.roles.map((r) => r.name).join(', ')})`);
|
||||
}
|
||||
|
||||
/** The demo student, so created content is visible to the account under test. */
|
||||
async function studentId() {
|
||||
const found = await v1('GET', `/users?email=${encodeURIComponent(STUDENT_EMAIL)}`);
|
||||
const user = found.data?.[0] ?? found[0];
|
||||
if (!user) throw new Error(`could not find ${STUDENT_EMAIL}`);
|
||||
return user._id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- create ---
|
||||
|
||||
async function create() {
|
||||
const s = loadState();
|
||||
// Saved after every step, not at the end: a failure halfway through used to
|
||||
// leave created objects with no record of their ids, and so no way to delete
|
||||
// them again.
|
||||
const keep = (k, v) => { s[k] = v; saveState(s); return v; };
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||||
const student = await studentId();
|
||||
|
||||
step('course');
|
||||
const course = await v3('POST', '/courses', { name: `MCP-Test Kurs (${stamp})` });
|
||||
keep('courseId', course.courseId ?? course.id ?? course._id);
|
||||
// v3 creates the course but takes no members; the legacy service does.
|
||||
await v1('PATCH', `/courses/${s.courseId}`, { userIds: [student], teacherIds: [me.user.id] });
|
||||
log(`course ${s.courseId} with the demo student enrolled`);
|
||||
|
||||
step('room');
|
||||
const room = await v3('POST', '/rooms', {
|
||||
name: `MCP-Test Raum (${stamp})`,
|
||||
color: 'blue',
|
||||
features: [],
|
||||
});
|
||||
keep('roomId', room.id);
|
||||
log(`room ${s.roomId}`);
|
||||
|
||||
step('team');
|
||||
// Teams cannot be created through the API: the legacy service registers
|
||||
// ['find','get','update','patch','remove'] and no 'create'
|
||||
// (schulcloud-server src/services/teams/index.js), and v3 has no team route
|
||||
// beyond news and create-room. POST /teams answers 405. So the simulation
|
||||
// adopts a seeded team and edits that instead — which is all the MCP server
|
||||
// needs, since it reads teams only through api_get.
|
||||
const teams = await v1('GET', '/teams');
|
||||
const existing = (teams.data ?? [])[0];
|
||||
if (existing) {
|
||||
keep('teamId', existing._id);
|
||||
log(`adopted seeded team ${existing._id} (${existing.name}) — creation is not exposed`);
|
||||
} else {
|
||||
log('no seeded team to adopt; skipping');
|
||||
}
|
||||
|
||||
step('topic (legacy lesson) in Biologie 9b');
|
||||
const lesson = await v1('POST', '/lessons', {
|
||||
name: `MCP-Test Thema (${stamp})`,
|
||||
courseId: COURSE,
|
||||
hidden: false,
|
||||
contents: [
|
||||
{
|
||||
title: 'Einführung',
|
||||
hidden: false,
|
||||
component: 'text',
|
||||
content: { text: '<p>Dieses Thema prüft, was der MCP-Server aus einem Thema liest.</p>' },
|
||||
},
|
||||
],
|
||||
});
|
||||
keep('lessonId', lesson._id);
|
||||
log(`lesson ${s.lessonId}`);
|
||||
|
||||
step('task (homework) in Biologie 9b');
|
||||
const task = await v1('POST', '/homework', {
|
||||
name: `MCP-Test Aufgabe (${stamp})`,
|
||||
description: '<p>Beschreibe in drei Sätzen, was ein Neuron tut.</p>',
|
||||
courseId: COURSE,
|
||||
availableDate: new Date(Date.now() - 86400_000).toISOString(),
|
||||
dueDate: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
||||
private: false,
|
||||
teacherId: me.user.id,
|
||||
schoolId: me.school.id,
|
||||
});
|
||||
keep('taskId', task._id);
|
||||
log(`task ${s.taskId}`);
|
||||
|
||||
step('column board in Biologie 9b');
|
||||
const board = await v3('POST', '/boards', {
|
||||
title: `MCP-Test Board (${stamp})`,
|
||||
parentId: COURSE,
|
||||
parentType: 'course',
|
||||
layout: 'columns',
|
||||
});
|
||||
keep('boardId', board.id);
|
||||
const column = await v3('POST', `/boards/${s.boardId}/columns`);
|
||||
keep('columnId', column.id);
|
||||
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material' });
|
||||
const card = await v3('POST', `/columns/${s.columnId}/cards`);
|
||||
keep('cardId', card.id);
|
||||
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem' });
|
||||
log(`board ${s.boardId} / column ${s.columnId} / card ${s.cardId}`);
|
||||
|
||||
step('card elements');
|
||||
const rich = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'richText' });
|
||||
keep('richTextId', rich.id);
|
||||
await v3('PATCH', `/elements/${s.richTextId}/content`, {
|
||||
data: {
|
||||
content: {
|
||||
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Suchbegriff: Synapsenspalt.</p>',
|
||||
inputFormat: 'richTextCk5',
|
||||
},
|
||||
type: 'richText',
|
||||
},
|
||||
});
|
||||
log(`richText ${s.richTextId}`);
|
||||
|
||||
const link = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'link' });
|
||||
keep('linkId', link.id);
|
||||
await v3('PATCH', `/elements/${s.linkId}/content`, {
|
||||
data: {
|
||||
content: { url: 'https://www.dbildungscloud.de/', title: 'dBildungscloud', description: '', imageUrl: '', originalImageUrl: '' },
|
||||
type: 'link',
|
||||
},
|
||||
});
|
||||
log(`link ${s.linkId}`);
|
||||
|
||||
const pad = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'collaborativeTextEditor' });
|
||||
keep('padElementId', pad.id);
|
||||
log(`collaborativeTextEditor (Etherpad) ${s.padElementId}`);
|
||||
|
||||
const folder = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'fileFolder' });
|
||||
keep('folderId', folder.id);
|
||||
await v3('PATCH', `/elements/${s.folderId}/content`, {
|
||||
data: { content: { title: 'Arbeitsblätter' }, type: 'fileFolder' },
|
||||
});
|
||||
log(`fileFolder (directory) ${s.folderId}`);
|
||||
|
||||
step('files');
|
||||
const fileEl = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'file' });
|
||||
keep('fileElementId', fileEl.id);
|
||||
keep('fileId', await upload(s.fileElementId, 'nervensystem-notiz.txt', 'text/plain',
|
||||
'Das Nervensystem\n\nReizleitung erfolgt ueber Synapsen. Stichwort: Synapsenspalt.\n'));
|
||||
log(`file element ${s.fileElementId} holding file ${s.fileId}`);
|
||||
|
||||
keep('folderFileId', await upload(s.folderId, 'arbeitsblatt-1.txt', 'text/plain',
|
||||
'Arbeitsblatt 1\n\nAufgabe: Beschrifte die Teile eines Neurons.\n'));
|
||||
log(`file ${s.folderFileId} inside the directory`);
|
||||
|
||||
step('publishing');
|
||||
// A board is created as a draft. Students get 403 on it while the course page
|
||||
// still lists its title, so the fixture needs both states to be useful.
|
||||
await v3('PATCH', `/boards/${s.boardId}/visibility`, { isVisible: true });
|
||||
log('main board published');
|
||||
|
||||
const draft = await v3('POST', '/boards', {
|
||||
title: `MCP-Test Entwurf, unveröffentlicht (${stamp})`,
|
||||
parentId: COURSE,
|
||||
parentType: 'course',
|
||||
layout: 'columns',
|
||||
});
|
||||
keep('draftBoardId', draft.id);
|
||||
log(`second board left unpublished on purpose: ${draft.id}`);
|
||||
|
||||
saveState(s);
|
||||
console.log(`\nstate written to ${STATE}`);
|
||||
summary(s);
|
||||
}
|
||||
|
||||
/** files-storage attaches bytes to a board node (element) id, not to the card. */
|
||||
async function upload(parentId, name, type, body) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([body], { type }), name);
|
||||
const record = await files('POST', `/upload/school/${me.school.id}/boardnodes/${parentId}`, form);
|
||||
return record.id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- update ---
|
||||
|
||||
async function update() {
|
||||
const s = loadState();
|
||||
if (!s.boardId) throw new Error('nothing to update — run `create` first');
|
||||
const stamp = new Date().toISOString().slice(11, 16);
|
||||
|
||||
step('renames');
|
||||
await v1('PATCH', `/courses/${s.courseId}`, { name: `MCP-Test Kurs [umbenannt ${stamp}]` });
|
||||
log('course renamed');
|
||||
await v3('PATCH', `/boards/${s.boardId}/title`, { title: `MCP-Test Board [umbenannt ${stamp}]` });
|
||||
log('board renamed');
|
||||
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material (überarbeitet)' });
|
||||
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem — überarbeitet' });
|
||||
log('column and card renamed');
|
||||
await v1('PATCH', `/homework/${s.taskId}`, { name: `MCP-Test Aufgabe [umbenannt ${stamp}]` });
|
||||
log('task renamed');
|
||||
await v1('PATCH', `/lessons/${s.lessonId}`, { name: `MCP-Test Thema [umbenannt ${stamp}]` });
|
||||
log('topic renamed');
|
||||
if (s.teamId) {
|
||||
const team = await v1('GET', `/teams/${s.teamId}`);
|
||||
await v1('PATCH', `/teams/${s.teamId}`, { name: `${team.name.replace(/ \[MCP .*$/, '')} [MCP ${stamp}]` });
|
||||
log('team renamed (the one write teams do allow)');
|
||||
}
|
||||
await v3('PUT', `/rooms/${s.roomId}`, { name: `MCP-Test Raum [umbenannt ${stamp}]`, color: 'green', features: [] });
|
||||
log('room renamed');
|
||||
|
||||
step('content edits');
|
||||
await v3('PATCH', `/elements/${s.richTextId}/content`, {
|
||||
data: {
|
||||
content: {
|
||||
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Neuer Suchbegriff: Ranvierscher Schnürring.</p>',
|
||||
inputFormat: 'richTextCk5',
|
||||
},
|
||||
type: 'richText',
|
||||
},
|
||||
});
|
||||
log('rich text rewritten (new search term: Ranvierscher Schnürring)');
|
||||
await files('PATCH', `/rename/${s.fileId}`, { fileName: 'nervensystem-notiz-v2.txt' });
|
||||
log('file renamed');
|
||||
|
||||
saveState({ ...s, updated: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- delete ---
|
||||
|
||||
async function remove() {
|
||||
const s = loadState();
|
||||
if (!s.boardId) throw new Error('nothing to delete — run `create` first');
|
||||
|
||||
step('deleting what was created');
|
||||
const tries = [
|
||||
['file', () => files('DELETE', `/delete/${s.folderFileId}`)],
|
||||
['file element', () => v3('DELETE', `/elements/${s.fileElementId}`)],
|
||||
['fileFolder element', () => v3('DELETE', `/elements/${s.folderId}`)],
|
||||
['etherpad element', () => v3('DELETE', `/elements/${s.padElementId}`)],
|
||||
['link element', () => v3('DELETE', `/elements/${s.linkId}`)],
|
||||
['card', () => v3('DELETE', `/cards/${s.cardId}`)],
|
||||
['column', () => v3('DELETE', `/columns/${s.columnId}`)],
|
||||
['board', () => v3('DELETE', `/boards/${s.boardId}`)],
|
||||
['draft board', () => v3('DELETE', `/boards/${s.draftBoardId}`)],
|
||||
['task', () => v3('DELETE', `/tasks/${s.taskId}`)],
|
||||
['topic', () => v3('DELETE', `/lessons/${s.lessonId}`)],
|
||||
['room', () => v3('DELETE', `/rooms/${s.roomId}`)],
|
||||
['course', () => v1('DELETE', `/courses/${s.courseId}`)],
|
||||
];
|
||||
for (const [what, fn] of tries) {
|
||||
try {
|
||||
await fn();
|
||||
log(`deleted ${what}`);
|
||||
} catch (err) {
|
||||
log(`could NOT delete ${what}: ${String(err.message).slice(0, 160)}`);
|
||||
}
|
||||
}
|
||||
unlinkSync(STATE);
|
||||
console.log(`\nstate file removed`);
|
||||
}
|
||||
|
||||
function summary(s) {
|
||||
console.log('\nids for the MCP side:');
|
||||
for (const [k, v] of Object.entries(s)) console.log(` ${k.padEnd(16)} ${v}`);
|
||||
}
|
||||
|
||||
const phase = process.argv[2] ?? 'create';
|
||||
await login();
|
||||
if (phase === 'create') await create();
|
||||
else if (phase === 'update') await update();
|
||||
else if (phase === 'delete') await remove();
|
||||
else if (phase === 'show') summary(loadState());
|
||||
else {
|
||||
console.error(`unknown phase ${phase}; expected create | update | delete | show`);
|
||||
process.exit(2);
|
||||
}
|
||||
Reference in New Issue
Block a user