Add show management and going-live controls to admin
- admin/shows.php: list shows - admin/show_edit.php: add, edit and delete shows, with validated background image uploads saved under generated names - admin/index.php: queue the next show, switch the live show, or clear the queue - includes/broadcasts.php: setBroadcastShow() and clearQueuedShow(), shared by the dashboard and cli/set_show.php - includes/admin_layout.php: flash messages and Shows nav link Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
babb71660c
commit
6e61fc6bda
7 files changed
+297
-12
No files matched your search
@@ -43,7 +43,7 @@ MariaDB on localhost. Credentials live in `config/db_credentials.php`, which is
|
|||||||
- `broadcasts` — one row per streaming session: pending (queued by `cli/set_show.php`), live, or ended.
|
- `broadcasts` — one row per streaming session: pending (queued by `cli/set_show.php`), live, or ended.
|
||||||
- `played_tracks` — tracks per broadcast. Cleared ten minutes after the broadcast ends; no long-term history is kept.
|
- `played_tracks` — tracks per broadcast. Cleared ten minutes after the broadcast ends; no long-term history is kept.
|
||||||
|
|
||||||
`cli/poll_icecast.php` runs from cron every minute and polls Icecast four times, 15 seconds apart. A broadcast starts when the stream comes online and ends after five minutes unseen (so brief drop-outs don't split a show). Before going live, run `php cli/set_show.php <slug>` to choose the show.
|
`cli/poll_icecast.php` runs from cron every minute and polls Icecast four times, 15 seconds apart. A broadcast starts when the stream comes online and ends after five minutes unseen (so brief drop-outs don't split a show). Before going live, choose the show on the admin dashboard (or run `php cli/set_show.php <slug>`). Shows and their background images are managed at `admin/shows.php`; uploads are saved to `images/backgrounds/` under generated names, so the web server needs write access to that directory.
|
||||||
|
|
||||||
## Admin Authentication
|
## Admin Authentication
|
||||||
|
|
||||||
|
|||||||
+59
-6
@@ -6,22 +6,75 @@ require __DIR__ . '/../includes/broadcasts.php';
|
|||||||
$admin = requireAdmin();
|
$admin = requireAdmin();
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
verifyCsrf();
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
if ($action === 'set_show') {
|
||||||
|
$stmt = $db->prepare('SELECT id, name FROM shows WHERE id = ?');
|
||||||
|
$stmt->execute([(int)($_POST['show_id'] ?? 0)]);
|
||||||
|
$show = $stmt->fetch();
|
||||||
|
if (!$show) {
|
||||||
|
flash('Please choose a show.', 'error');
|
||||||
|
} elseif (setBroadcastShow($db, (int)$show['id']) === 'live') {
|
||||||
|
flash("The live broadcast is now {$show['name']}.");
|
||||||
|
} else {
|
||||||
|
flash("{$show['name']} is queued. It will go live as soon as the stream starts.");
|
||||||
|
}
|
||||||
|
} elseif ($action === 'clear_queue') {
|
||||||
|
clearQueuedShow($db);
|
||||||
|
flash('The queued show has been cleared.');
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$live = liveBroadcast($db);
|
$live = liveBroadcast($db);
|
||||||
$show = currentShow($db);
|
$current = currentShow($db);
|
||||||
|
$shows = $db->query('SELECT id, name FROM shows ORDER BY name')->fetchAll();
|
||||||
|
|
||||||
adminHeader('Dashboard', $admin);
|
adminHeader('Dashboard', $admin);
|
||||||
?>
|
?>
|
||||||
<p>Logged in as <strong><?= e($admin['username']) ?></strong>.</p>
|
|
||||||
|
|
||||||
<h3>Broadcast status</h3>
|
<h3>Broadcast status</h3>
|
||||||
<p>
|
<p>
|
||||||
<?php if ($live): ?>
|
<?php if ($live): ?>
|
||||||
Live now<?= $show ? ': <strong>' . e($show['name']) . '</strong>' : ' (no show set)' ?>, since <?= e($live['started_at']) ?> UTC.
|
<strong>Live now</strong><?= $current ? ': ' . e($current['name']) : ' (no show set)' ?>, since <?= e($live['started_at']) ?> UTC.
|
||||||
<?php elseif ($show): ?>
|
<?php elseif ($current): ?>
|
||||||
Offline. <strong><?= e($show['name']) ?></strong> is queued for the next broadcast.
|
Offline. <strong><?= e($current['name']) ?></strong> is queued for the next broadcast.
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
Offline. No show is queued.
|
Offline. No show is queued.
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3><?= $live ? 'Change the live show' : 'Queue the next show' ?></h3>
|
||||||
|
<p><?= $live
|
||||||
|
? 'Switch which show the live page and recently played list belong to.'
|
||||||
|
: 'Choose the show before you start streaming. The broadcast goes live on the site automatically when the stream comes online.' ?></p>
|
||||||
|
<form method="post" action="index.php">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_show" />
|
||||||
|
<div class="row gtr-uniform gtr-50">
|
||||||
|
<div class="col-8 col-12-small">
|
||||||
|
<select name="show_id" aria-label="Show" required>
|
||||||
|
<option value="">Choose a show…</option>
|
||||||
|
<?php foreach ($shows as $show): ?>
|
||||||
|
<option value="<?= (int)$show['id'] ?>"<?= $current && $current['id'] == $show['id'] ? ' selected' : '' ?>><?= e($show['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-4 col-12-small">
|
||||||
|
<input type="submit" class="primary fit" value="<?= $live ? 'Switch show' : 'Queue show' ?>" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if (!$live && $current): ?>
|
||||||
|
<form method="post" action="index.php">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="clear_queue" />
|
||||||
|
<input type="submit" class="small" value="Clear queued show" />
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
<?php
|
<?php
|
||||||
adminFooter();
|
adminFooter();
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../includes/auth.php';
|
||||||
|
require __DIR__ . '/../includes/admin_layout.php';
|
||||||
|
|
||||||
|
$admin = requireAdmin();
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
const BACKGROUND_DIR = __DIR__ . '/../images/backgrounds/';
|
||||||
|
const MAX_BACKGROUND_BYTES = 5 * 1024 * 1024;
|
||||||
|
// Uploads are identified by their content, not their filename, and saved under a generated name
|
||||||
|
const BACKGROUND_TYPES = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
|
||||||
|
|
||||||
|
function slugify(string $name): string {
|
||||||
|
$slug = strtolower(str_replace(["'", '’'], '', $name));
|
||||||
|
return trim(preg_replace('/[^a-z0-9]+/', '-', $slug), '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBackground(?string $filename): void {
|
||||||
|
if ($filename && is_file(BACKGROUND_DIR . basename($filename))) {
|
||||||
|
unlink(BACKGROUND_DIR . basename($filename));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$show = ['id' => 0, 'name' => '', 'slug' => '', 'background_image' => null];
|
||||||
|
if ($id) {
|
||||||
|
$stmt = $db->prepare('SELECT id, name, slug, background_image FROM shows WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$show = $stmt->fetch();
|
||||||
|
if (!$show) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('Show not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
// PHP discards the whole form when a request exceeds post_max_size, so catch that before the CSRF check
|
||||||
|
if (empty($_POST) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
|
||||||
|
$errors[] = 'That upload is too large. Background images can be up to ' . (MAX_BACKGROUND_BYTES / 1024 / 1024) . ' MB.';
|
||||||
|
} else {
|
||||||
|
verifyCsrf();
|
||||||
|
|
||||||
|
if (($_POST['action'] ?? '') === 'delete' && $id) {
|
||||||
|
$db->prepare('DELETE FROM shows WHERE id = ?')->execute([$id]);
|
||||||
|
deleteBackground($show['background_image']);
|
||||||
|
flash("Deleted {$show['name']}.");
|
||||||
|
header('Location: shows.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$show['name'] = trim((string)($_POST['name'] ?? ''));
|
||||||
|
$show['slug'] = trim((string)($_POST['slug'] ?? '')) ?: slugify($show['name']);
|
||||||
|
|
||||||
|
if ($show['name'] === '' || mb_strlen($show['name']) > 255) {
|
||||||
|
$errors[] = 'Name is required (up to 255 characters).';
|
||||||
|
}
|
||||||
|
if (!preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $show['slug']) || strlen($show['slug']) > 64) {
|
||||||
|
$errors[] = 'Slug may only contain lowercase letters, numbers and single dashes (up to 64 characters).';
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare('SELECT COUNT(*) FROM shows WHERE slug = ? AND id != ?');
|
||||||
|
$stmt->execute([$show['slug'], $id]);
|
||||||
|
if ($stmt->fetchColumn() > 0) $errors[] = "Another show already uses the slug '{$show['slug']}'.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$upload = $_FILES['background'] ?? null;
|
||||||
|
$uploadExt = null;
|
||||||
|
if ($upload && $upload['error'] !== UPLOAD_ERR_NO_FILE) {
|
||||||
|
if (in_array($upload['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) || $upload['size'] > MAX_BACKGROUND_BYTES) {
|
||||||
|
$errors[] = 'Background images can be up to ' . (MAX_BACKGROUND_BYTES / 1024 / 1024) . ' MB.';
|
||||||
|
} elseif ($upload['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$errors[] = 'The background image failed to upload. Please try again.';
|
||||||
|
} else {
|
||||||
|
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($upload['tmp_name']);
|
||||||
|
$uploadExt = BACKGROUND_TYPES[$mime] ?? null;
|
||||||
|
if ($uploadExt === null || getimagesize($upload['tmp_name']) === false) {
|
||||||
|
$errors[] = 'Background images must be JPEG, PNG or WebP.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$errors) {
|
||||||
|
$oldBackground = $show['background_image'];
|
||||||
|
if ($uploadExt !== null) {
|
||||||
|
$filename = $show['slug'] . '-' . bin2hex(random_bytes(4)) . '.' . $uploadExt;
|
||||||
|
if (move_uploaded_file($upload['tmp_name'], BACKGROUND_DIR . $filename)) {
|
||||||
|
$show['background_image'] = $filename;
|
||||||
|
} else {
|
||||||
|
$errors[] = 'The background image could not be saved. Check that the web server can write to images/backgrounds/.';
|
||||||
|
}
|
||||||
|
} elseif (!empty($_POST['remove_background'])) {
|
||||||
|
$show['background_image'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$errors) {
|
||||||
|
if ($id) {
|
||||||
|
$db->prepare('UPDATE shows SET name = ?, slug = ?, background_image = ? WHERE id = ?')
|
||||||
|
->execute([$show['name'], $show['slug'], $show['background_image'], $id]);
|
||||||
|
} else {
|
||||||
|
$db->prepare('INSERT INTO shows (name, slug, background_image) VALUES (?, ?, ?)')
|
||||||
|
->execute([$show['name'], $show['slug'], $show['background_image']]);
|
||||||
|
}
|
||||||
|
if ($oldBackground !== $show['background_image']) deleteBackground($oldBackground);
|
||||||
|
|
||||||
|
flash("Saved {$show['name']}.");
|
||||||
|
header('Location: shows.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adminHeader($id ? 'Edit Show' : 'Add a Show', $admin);
|
||||||
|
?>
|
||||||
|
<?php foreach ($errors as $error): ?>
|
||||||
|
<p class="admin-message error"><?= e($error) ?></p>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<form method="post" enctype="multipart/form-data" action="show_edit.php<?= $id ? '?id=' . $id : '' ?>">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="MAX_FILE_SIZE" value="<?= MAX_BACKGROUND_BYTES ?>" />
|
||||||
|
<div class="row gtr-uniform gtr-50">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" name="name" id="name" value="<?= e($show['name']) ?>" maxlength="255" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="slug">Slug</label>
|
||||||
|
<input type="text" name="slug" id="slug" value="<?= e($show['slug']) ?>" maxlength="64" pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="Leave blank to generate from the name" />
|
||||||
|
<p><small>Short name used by <code>cli/set_show.php</code>. Lowercase letters, numbers and dashes.</small></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="background">Background image</label>
|
||||||
|
<?php if ($show['background_image']): ?>
|
||||||
|
<p><img src="../images/backgrounds/<?= e(rawurlencode($show['background_image'])) ?>" alt="Current background" style="max-width:320px;border-radius:4px;display:block;"></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="file" name="background" id="background" accept="image/jpeg,image/png,image/webp" />
|
||||||
|
<p><small>JPEG, PNG or WebP, up to <?= MAX_BACKGROUND_BYTES / 1024 / 1024 ?> MB. Shown behind the player on the live page during this show.</small></p>
|
||||||
|
<?php if ($show['background_image']): ?>
|
||||||
|
<input type="checkbox" name="remove_background" id="remove_background" value="1" />
|
||||||
|
<label for="remove_background">Remove background (use the default)</label>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="actions">
|
||||||
|
<li><input type="submit" class="primary" value="Save" /></li>
|
||||||
|
<li><a href="shows.php" class="button">Cancel</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($id): ?>
|
||||||
|
<hr />
|
||||||
|
<form method="post" action="show_edit.php?id=<?= $id ?>" onsubmit="return confirm('Delete <?= e(addslashes($show['name'])) ?>? This cannot be undone.');">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="delete" />
|
||||||
|
<input type="submit" class="small" value="Delete this show" />
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php
|
||||||
|
adminFooter();
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../includes/auth.php';
|
||||||
|
require __DIR__ . '/../includes/admin_layout.php';
|
||||||
|
|
||||||
|
$admin = requireAdmin();
|
||||||
|
$shows = getDB()->query('SELECT id, slug, name, background_image FROM shows ORDER BY name')->fetchAll();
|
||||||
|
|
||||||
|
adminHeader('Shows', $admin);
|
||||||
|
?>
|
||||||
|
<ul class="actions">
|
||||||
|
<li><a href="show_edit.php" class="button primary small">Add a show</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Background</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($shows as $show): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<?php if ($show['background_image']): ?>
|
||||||
|
<img src="../images/backgrounds/<?= e(rawurlencode($show['background_image'])) ?>" alt="" style="width:80px;height:50px;object-fit:cover;border-radius:4px;display:block;">
|
||||||
|
<?php else: ?>
|
||||||
|
<em>Default</em>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= e($show['name']) ?></td>
|
||||||
|
<td><code><?= e($show['slug']) ?></code></td>
|
||||||
|
<td><a href="show_edit.php?id=<?= (int)$show['id'] ?>">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (!$shows): ?>
|
||||||
|
<tr><td colspan="4">No shows yet.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
adminFooter();
|
||||||
+1
-5
@@ -33,12 +33,8 @@ if (!$show) {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$live = liveBroadcast($db);
|
if (setBroadcastShow($db, (int)$show['id']) === 'live') {
|
||||||
if ($live !== null) {
|
|
||||||
$db->prepare('UPDATE broadcasts SET show_id = ? WHERE id = ?')->execute([$show['id'], $live['id']]);
|
|
||||||
echo "Live broadcast is now {$show['name']}.\n";
|
echo "Live broadcast is now {$show['name']}.\n";
|
||||||
} else {
|
} else {
|
||||||
$db->exec('DELETE FROM broadcasts WHERE started_at IS NULL');
|
|
||||||
$db->prepare('INSERT INTO broadcasts (show_id) VALUES (?)')->execute([$show['id']]);
|
|
||||||
echo "{$show['name']} is queued for the next broadcast.\n";
|
echo "{$show['name']} is queued for the next broadcast.\n";
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,12 @@ function e(string $s): string {
|
|||||||
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-time status message shown at the top of the next admin page (for post-redirect-get)
|
||||||
|
function flash(string $message, string $type = 'success'): void {
|
||||||
|
startAdminSession();
|
||||||
|
$_SESSION['flash'] = ['message' => $message, 'type' => $type];
|
||||||
|
}
|
||||||
|
|
||||||
function adminHeader(string $title, ?array $admin = null): void {
|
function adminHeader(string $title, ?array $admin = null): void {
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
@@ -56,6 +62,7 @@ function adminHeader(string $title, ?array $admin = null): void {
|
|||||||
<?php if ($admin): ?>
|
<?php if ($admin): ?>
|
||||||
<ul class="admin-nav">
|
<ul class="admin-nav">
|
||||||
<li><a href="index.php">Dashboard</a></li>
|
<li><a href="index.php">Dashboard</a></li>
|
||||||
|
<li><a href="shows.php">Shows</a></li>
|
||||||
<li><a href="../live.php">View live page</a></li>
|
<li><a href="../live.php">View live page</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<form method="post" action="logout.php">
|
<form method="post" action="logout.php">
|
||||||
@@ -72,6 +79,10 @@ function adminHeader(string $title, ?array $admin = null): void {
|
|||||||
<h2><?= e($title) ?></h2>
|
<h2><?= e($title) ?></h2>
|
||||||
</header>
|
</header>
|
||||||
<?php
|
<?php
|
||||||
|
if (!empty($_SESSION['flash'])) {
|
||||||
|
echo '<p class="admin-message ' . e($_SESSION['flash']['type']) . '">' . e($_SESSION['flash']['message']) . "</p>\n";
|
||||||
|
unset($_SESSION['flash']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminFooter(): void {
|
function adminFooter(): void {
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ function currentShow(PDO $db): ?array {
|
|||||||
return $row ?: null;
|
return $row ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assigns a show to the live broadcast, or queues it for the next one if nothing is live.
|
||||||
|
// Returns 'live' or 'queued'.
|
||||||
|
function setBroadcastShow(PDO $db, int $showId): string {
|
||||||
|
$live = liveBroadcast($db);
|
||||||
|
if ($live !== null) {
|
||||||
|
$db->prepare('UPDATE broadcasts SET show_id = ? WHERE id = ?')->execute([$showId, $live['id']]);
|
||||||
|
return 'live';
|
||||||
|
}
|
||||||
|
clearQueuedShow($db);
|
||||||
|
$db->prepare('INSERT INTO broadcasts (show_id) VALUES (?)')->execute([$showId]);
|
||||||
|
return 'queued';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueuedShow(PDO $db): void {
|
||||||
|
$db->exec('DELETE FROM broadcasts WHERE started_at IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
// Tracks from the most recent broadcast that has started, newest first
|
// Tracks from the most recent broadcast that has started, newest first
|
||||||
function recentTracks(PDO $db, int $limit = 100): array {
|
function recentTracks(PDO $db, int $limit = 100): array {
|
||||||
$stmt = $db->prepare(
|
$stmt = $db->prepare(
|
||||||
|
|||||||
Reference in new issue
Block a user