Add admin login
- admins and login_attempts tables - cli/create_admin.php: create an admin or reset a password over SSH (no email involved) - includes/auth.php: hardened admin sessions, login throttling (5 failures per IP per 15 minutes) and CSRF helpers - admin/: login, logout and a dashboard showing broadcast status - includes/admin_layout.php: shared admin page layout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
74dfb3c146
commit
babb71660c
8 files changed
+389
No files matched your search
@@ -26,6 +26,8 @@ This repo covers the **frontend only** — the public-facing website. Icecast co
|
|||||||
- [includes/broadcasts.php](includes/broadcasts.php) — Broadcast lifecycle and track history logic.
|
- [includes/broadcasts.php](includes/broadcasts.php) — Broadcast lifecycle and track history logic.
|
||||||
- [cli/poll_icecast.php](cli/poll_icecast.php) — Cron-run poller that records broadcasts and tracks.
|
- [cli/poll_icecast.php](cli/poll_icecast.php) — Cron-run poller that records broadcasts and tracks.
|
||||||
- [cli/set_show.php](cli/set_show.php) — Sets which show is airing next (or now).
|
- [cli/set_show.php](cli/set_show.php) — Sets which show is airing next (or now).
|
||||||
|
- [cli/create_admin.php](cli/create_admin.php) — Creates an admin account or resets its password.
|
||||||
|
- [admin/](admin/) — Admin backend. Every page calls `requireAdmin()` from [includes/auth.php](includes/auth.php) and uses `adminHeader()`/`adminFooter()` from [includes/admin_layout.php](includes/admin_layout.php).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -43,6 +45,10 @@ MariaDB on localhost. Credentials live in `config/db_credentials.php`, which is
|
|||||||
|
|
||||||
`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, run `php cli/set_show.php <slug>` to choose the show.
|
||||||
|
|
||||||
|
## Admin Authentication
|
||||||
|
|
||||||
|
Username and password only; the site sends no email. Accounts are created and passwords reset over SSH with `php cli/create_admin.php <username>`. Passwords are hashed with `password_hash()` (PASSWORD_DEFAULT). Sessions use a `rfe_admin` cookie scoped to the admin directory (HttpOnly, SameSite=Strict, Secure over HTTPS) and expire after four hours idle. Five failed logins from one IP within 15 minutes locks that IP out for the rest of the window. Every POST form must include `csrfField()` and its handler must call `verifyCsrf()` first.
|
||||||
|
|
||||||
## Icecast Integration
|
## Icecast Integration
|
||||||
|
|
||||||
- **Stream URL:** `https://radio.cyberpunklibrarian.nohost.me/rfe`
|
- **Stream URL:** `https://radio.cyberpunklibrarian.nohost.me/rfe`
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../includes/auth.php';
|
||||||
|
require __DIR__ . '/../includes/admin_layout.php';
|
||||||
|
require __DIR__ . '/../includes/broadcasts.php';
|
||||||
|
|
||||||
|
$admin = requireAdmin();
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
$live = liveBroadcast($db);
|
||||||
|
$show = currentShow($db);
|
||||||
|
|
||||||
|
adminHeader('Dashboard', $admin);
|
||||||
|
?>
|
||||||
|
<p>Logged in as <strong><?= e($admin['username']) ?></strong>.</p>
|
||||||
|
|
||||||
|
<h3>Broadcast status</h3>
|
||||||
|
<p>
|
||||||
|
<?php if ($live): ?>
|
||||||
|
Live now<?= $show ? ': <strong>' . e($show['name']) . '</strong>' : ' (no show set)' ?>, since <?= e($live['started_at']) ?> UTC.
|
||||||
|
<?php elseif ($show): ?>
|
||||||
|
Offline. <strong><?= e($show['name']) ?></strong> is queued for the next broadcast.
|
||||||
|
<?php else: ?>
|
||||||
|
Offline. No show is queued.
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
adminFooter();
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../includes/auth.php';
|
||||||
|
require __DIR__ . '/../includes/admin_layout.php';
|
||||||
|
|
||||||
|
if (currentAdmin() !== null) {
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
$username = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
verifyCsrf();
|
||||||
|
$username = trim((string)($_POST['username'] ?? ''));
|
||||||
|
$password = (string)($_POST['password'] ?? '');
|
||||||
|
|
||||||
|
$error = attemptLogin($username, $password);
|
||||||
|
if ($error === null) {
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adminHeader('Log In');
|
||||||
|
?>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<p class="admin-message error"><?= e($error) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="post" action="login.php">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<div class="row gtr-uniform gtr-50">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" name="username" id="username" value="<?= e($username) ?>" autocomplete="username" required autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" name="password" id="password" autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="actions">
|
||||||
|
<li><input type="submit" class="primary" value="Log In" /></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php
|
||||||
|
adminFooter();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
// Logout is POST-only with a CSRF token, so other sites can't log you out with a link or image
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
verifyCsrf();
|
||||||
|
startAdminSession();
|
||||||
|
logoutAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: login.php');
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
// Creates an admin account, or resets the password if the username already exists:
|
||||||
|
// php cli/create_admin.php <username>
|
||||||
|
// The password is prompted for and never echoed or passed on the command line.
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__ . '/../includes/db.php';
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 12;
|
||||||
|
|
||||||
|
function promptHidden(string $prompt): string {
|
||||||
|
echo $prompt;
|
||||||
|
$interactive = function_exists('posix_isatty') && posix_isatty(STDIN);
|
||||||
|
if ($interactive) shell_exec('stty -echo');
|
||||||
|
$value = rtrim((string)fgets(STDIN), "\r\n");
|
||||||
|
if ($interactive) {
|
||||||
|
shell_exec('stty echo');
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = $argv[1] ?? '';
|
||||||
|
if (!preg_match('/^[A-Za-z0-9_.-]{1,64}$/', $username)) {
|
||||||
|
fwrite(STDERR, "Usage: php cli/create_admin.php <username>\n(letters, numbers, dot, dash and underscore only)\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = promptHidden('Password: ');
|
||||||
|
if (strlen($password) < MIN_PASSWORD_LENGTH) {
|
||||||
|
fwrite(STDERR, 'Password must be at least ' . MIN_PASSWORD_LENGTH . " characters.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (promptHidden('Confirm password: ') !== $password) {
|
||||||
|
fwrite(STDERR, "Passwords do not match.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
|
||||||
|
$stmt = $db->prepare('SELECT id FROM admins WHERE username = ?');
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
$db->prepare('UPDATE admins SET password_hash = ? WHERE username = ?')->execute([$hash, $username]);
|
||||||
|
echo "Password reset for {$username}.\n";
|
||||||
|
} else {
|
||||||
|
$db->prepare('INSERT INTO admins (username, password_hash) VALUES (?, ?)')->execute([$username, $hash]);
|
||||||
|
echo "Admin {$username} created.\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
// Shared page chrome for the admin pages: the site's Strata layout with an admin menu in the sidebar.
|
||||||
|
|
||||||
|
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function e(string $s): string {
|
||||||
|
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminHeader(string $title, ?array $admin = null): void {
|
||||||
|
?>
|
||||||
|
<!DOCTYPE HTML>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title><?= e($title) ?> | RFE Admin</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<link rel="stylesheet" href="../assets/css/main.css" />
|
||||||
|
<style>
|
||||||
|
#header ul.admin-nav {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
|
#header ul.admin-nav li {
|
||||||
|
padding: 0.25em 0;
|
||||||
|
}
|
||||||
|
#header ul.admin-nav a {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.admin-message {
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
.admin-message.error {
|
||||||
|
background-color: rgba(200, 60, 60, 0.15);
|
||||||
|
color: #a33;
|
||||||
|
}
|
||||||
|
.admin-message.success {
|
||||||
|
background-color: rgba(60, 160, 90, 0.15);
|
||||||
|
color: #2a7a45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header id="header">
|
||||||
|
<div class="inner">
|
||||||
|
<a href="../index.php" class="image avatar"><img src="../images/avatar-rfe.jpg" alt="Radio Free Elsewhere logo" /></a>
|
||||||
|
<h1><strong>Radio Free Elsewhere</strong><br>
|
||||||
|
Admin</h1>
|
||||||
|
<?php if ($admin): ?>
|
||||||
|
<ul class="admin-nav">
|
||||||
|
<li><a href="index.php">Dashboard</a></li>
|
||||||
|
<li><a href="../live.php">View live page</a></li>
|
||||||
|
</ul>
|
||||||
|
<form method="post" action="logout.php">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="submit" class="small" value="Log out <?= e($admin['username']) ?>" />
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="main">
|
||||||
|
<section>
|
||||||
|
<header class="major">
|
||||||
|
<h2><?= e($title) ?></h2>
|
||||||
|
</header>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminFooter(): void {
|
||||||
|
?>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
// Admin authentication: sessions, login throttling and CSRF protection.
|
||||||
|
|
||||||
|
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
// Log out after this long without activity
|
||||||
|
const ADMIN_IDLE_TIMEOUT = 4 * 60 * 60;
|
||||||
|
// Failed logins allowed from one IP address within the lockout window
|
||||||
|
const LOGIN_MAX_FAILURES = 5;
|
||||||
|
const LOGIN_LOCKOUT_MINUTES = 15;
|
||||||
|
|
||||||
|
function startAdminSession(): void {
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) return;
|
||||||
|
|
||||||
|
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||||
|
session_name('rfe_admin');
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
// Only sent to the admin pages, not the rest of the site or other apps on the domain
|
||||||
|
'path' => rtrim(dirname($_SERVER['SCRIPT_NAME']), '/') . '/',
|
||||||
|
'secure' => $https,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
ini_set('session.use_strict_mode', '1');
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// Admin pages should never be cached, framed or indexed
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('X-Frame-Options: DENY');
|
||||||
|
header('X-Robots-Tag: noindex, nofollow');
|
||||||
|
|
||||||
|
if (isset($_SESSION['admin_id'], $_SESSION['last_activity']) && time() - $_SESSION['last_activity'] > ADMIN_IDLE_TIMEOUT) {
|
||||||
|
logoutAdmin();
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
$_SESSION['last_activity'] = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentAdmin(): ?array {
|
||||||
|
startAdminSession();
|
||||||
|
if (!isset($_SESSION['admin_id'])) return null;
|
||||||
|
|
||||||
|
$stmt = getDB()->prepare('SELECT id, username FROM admins WHERE id = ?');
|
||||||
|
$stmt->execute([$_SESSION['admin_id']]);
|
||||||
|
return $stmt->fetch() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call at the top of every admin page; sends anyone not logged in to the login page
|
||||||
|
function requireAdmin(): array {
|
||||||
|
$admin = currentAdmin();
|
||||||
|
if ($admin === null) {
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return $admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginLockedOut(PDO $db, string $ip): bool {
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM login_attempts
|
||||||
|
WHERE ip_address = ? AND attempted_at > NOW() - INTERVAL ' . LOGIN_LOCKOUT_MINUTES . ' MINUTE'
|
||||||
|
);
|
||||||
|
$stmt->execute([$ip]);
|
||||||
|
return $stmt->fetchColumn() >= LOGIN_MAX_FAILURES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns null on success, or an error message to show on the login form
|
||||||
|
function attemptLogin(string $username, string $password): ?string {
|
||||||
|
startAdminSession();
|
||||||
|
$db = getDB();
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
|
||||||
|
$db->exec('DELETE FROM login_attempts WHERE attempted_at < NOW() - INTERVAL 1 DAY');
|
||||||
|
if (loginLockedOut($db, $ip)) {
|
||||||
|
return 'Too many failed logins. Please wait ' . LOGIN_LOCKOUT_MINUTES . ' minutes and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare('SELECT id, password_hash FROM admins WHERE username = ?');
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
$admin = $stmt->fetch();
|
||||||
|
|
||||||
|
// Verify against a dummy hash for unknown usernames so response time doesn't reveal which usernames exist
|
||||||
|
$hash = $admin['password_hash'] ?? '$2y$10$lH52eP6Hg.ZFcO9AwUw/lOzZ9REOgwKxOJrLCqF9eKJGV0a4OiYNG';
|
||||||
|
if (!password_verify($password, $hash) || !$admin) {
|
||||||
|
$db->prepare('INSERT INTO login_attempts (ip_address) VALUES (?)')->execute([$ip]);
|
||||||
|
return 'Incorrect username or password.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password_needs_rehash($admin['password_hash'], PASSWORD_DEFAULT)) {
|
||||||
|
$db->prepare('UPDATE admins SET password_hash = ? WHERE id = ?')
|
||||||
|
->execute([password_hash($password, PASSWORD_DEFAULT), $admin['id']]);
|
||||||
|
}
|
||||||
|
$db->prepare('UPDATE admins SET last_login_at = NOW() WHERE id = ?')->execute([$admin['id']]);
|
||||||
|
$db->prepare('DELETE FROM login_attempts WHERE ip_address = ?')->execute([$ip]);
|
||||||
|
|
||||||
|
// New session ID on login to prevent session fixation
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION = ['admin_id' => (int)$admin['id'], 'last_activity' => time()];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoutAdmin(): void {
|
||||||
|
$_SESSION = [];
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', [
|
||||||
|
'expires' => time() - 3600,
|
||||||
|
'path' => $params['path'],
|
||||||
|
'secure' => $params['secure'],
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfToken(): string {
|
||||||
|
startAdminSession();
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfField(): string {
|
||||||
|
return '<input type="hidden" name="csrf_token" value="' . csrfToken() . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call at the start of handling any POST; stops the request if the token is missing or wrong
|
||||||
|
function verifyCsrf(): void {
|
||||||
|
startAdminSession();
|
||||||
|
$token = $_POST['csrf_token'] ?? '';
|
||||||
|
if (!is_string($token) || empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $token)) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit('Your session has expired. Please go back, reload the page and try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,23 @@ CREATE TABLE IF NOT EXISTS played_tracks (
|
|||||||
FOREIGN KEY (broadcast_id) REFERENCES broadcasts (id) ON DELETE CASCADE
|
FOREIGN KEY (broadcast_id) REFERENCES broadcasts (id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Admin accounts are created and reset from the command line with cli/create_admin.php
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login_at DATETIME NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Failed admin logins, used to throttle password guessing
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ip_address VARCHAR(45) NOT NULL,
|
||||||
|
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX (ip_address, attempted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
INSERT IGNORE INTO shows (slug, name) VALUES
|
INSERT IGNORE INTO shows (slug, name) VALUES
|
||||||
('omorains-pub', 'O''Morain''s Pub'),
|
('omorains-pub', 'O''Morain''s Pub'),
|
||||||
('cosmoeuphoria', 'Cosmoeuphoria'),
|
('cosmoeuphoria', 'Cosmoeuphoria'),
|
||||||
|
|||||||
Reference in new issue
Block a user