mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
diag: invalid_grant is SSO auth-method mismatch, not bad creds
- feat: creds-test.sh gum probe for SMTP vs PeerTube auth + PeerTubeService::probeAuth() - feat: app-token.sh gum probe for long-lived PeerTube app token (client_credentials) - docs: add copy-paste proof commands to demonstrate the SSO break to admins
This commit is contained in:
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# app-token.sh — can we obtain a long-lived PeerTube app token?
|
||||
#
|
||||
# A long-lived ("app") token comes from PeerTube's `client_credentials` OAuth2
|
||||
# grant, which is only allowed for APPLICATION OAuth clients (not the built-in
|
||||
# `local` client). To create one you first need an admin to run PeerTube's
|
||||
# create-client script ON the instance, then paste the client_id/client_secret
|
||||
# here.
|
||||
#
|
||||
# This script:
|
||||
# 1. Probes API reachability of the instance.
|
||||
# 2. Tries `client_credentials` with the given (or optional) app client.
|
||||
# 3. Detects whether the instance is behind an SSO/OIDC provider.
|
||||
# 4. Prints the exact admin command to create an app client, and how to use it
|
||||
# here afterward.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/app-token.sh [--instance https://videos.erg.be] [--client <id> --secret <secret>]
|
||||
#
|
||||
# --instance PeerTube base URL (default: read from xamxam DB, else https://videos.erg.be)
|
||||
# --client OAuth client_id of an application client (if you already have one)
|
||||
# --secret matching client_secret
|
||||
#
|
||||
# Exit 0 => a token was minted (printed to stdout); 1 => could not.
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
DB_PATH="$REPO_ROOT/app/storage/xamxam.db"
|
||||
INSTANCE=""
|
||||
CLIENT_ID=""
|
||||
CLIENT_SECRET=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--instance) INSTANCE="$2"; shift 2 ;;
|
||||
--client) CLIENT_ID="$2"; shift 2 ;;
|
||||
--secret) CLIENT_SECRET="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
gum style "Usage:" --bold
|
||||
gum style " scripts/app-token.sh [--instance <url>] [--client <id>] [--secret <secret>]"
|
||||
exit 0 ;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
command -v gum >/dev/null || { echo "gum is required"; exit 2; }
|
||||
command -v curl >/dev/null || { echo "curl is required"; exit 2; }
|
||||
command -v jq >/dev/null || { echo "jq is required (brew install jq)"; exit 2; }
|
||||
|
||||
# Default instance from the DB if not given.
|
||||
if [[ -z "$INSTANCE" ]]; then
|
||||
if [[ -f "$DB_PATH" ]] && command -v sqlite3 >/dev/null; then
|
||||
INSTANCE="$(sqlite3 "$DB_PATH" "SELECT instance_url FROM peertube_settings WHERE id=1;" 2>/dev/null)"
|
||||
INSTANCE="${INSTANCE:-https://videos.erg.be}"
|
||||
else
|
||||
INSTANCE="https://videos.erg.be"
|
||||
fi
|
||||
fi
|
||||
INSTANCE="${INSTANCE%/}"
|
||||
API="$INSTANCE/api/v1"
|
||||
|
||||
gum style "── XAMXAM · PeerTube app-token probe ──────────────────────" \
|
||||
--border double --padding "1 2" --foreground 213
|
||||
|
||||
gum style "Instance : $INSTANCE" --foreground 214
|
||||
|
||||
# ── 1. API reachability ────────────────────────────────────────────────────────
|
||||
resp="$(curl -sS -m 20 -w $'\n%{http_code}' "$API/config" 2>/dev/null)"
|
||||
code="${resp##*$'\n'}"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
gum style "✗ API unreachable (HTTP $code)" --foreground 196
|
||||
echo "$resp" | head -1 >&2
|
||||
exit 1
|
||||
fi
|
||||
gum style "✓ API reachable" --foreground 42
|
||||
|
||||
# ── 2. Detect SSO / OIDC ───────────────────────────────────────────────────────
|
||||
# Plugin listing often requires auth. Only report OIDC presence if we actually got
|
||||
# a data list; otherwise state that it can't be determined anonymously.
|
||||
oidc_flag="unknown"
|
||||
oidc_list="$(curl -sS -m 20 "$API/plugins?pluginType=2&count=100" 2>/dev/null)"
|
||||
if echo "$oidc_list" | grep -q '"total"'; then
|
||||
if echo "$oidc_list" | jq -e '.data[]? | select(.name | contains("oidc"))' >/dev/null 2>&1; then
|
||||
oidc_flag="enabled"; gum style "⚠ OIDC/SSO plugin detected on the instance" --foreground 220
|
||||
else
|
||||
oidc_flag="none"; gum style "✓ No OIDC plugin in the auth-plugin list" --foreground 42
|
||||
fi
|
||||
else
|
||||
gum style "? OIDC/SSO presence not determinable anonymously (plugin list needs admin auth)" --foreground 240
|
||||
fi
|
||||
|
||||
# ── 3. Try client_credentials ──────────────────────────────────────────────────
|
||||
# PeerTube only allows client_credentials on APPLICATION clients. The built-in
|
||||
# `local` client rejects it; if that's the only one we have, say so.
|
||||
if [[ -z "$CLIENT_ID" || -z "$CLIENT_SECRET" ]]; then
|
||||
gum style "── app client ──" --padding "0 1" --foreground 240 --bold
|
||||
gum style "No application client_id/client_secret supplied." --foreground 214
|
||||
gum style "Fetching the built-in 'local' client to demonstrate it is NOT enough:" --foreground 240
|
||||
local_client="$(curl -sS -m 20 "$API/oauth-clients/local" 2>/dev/null)"
|
||||
if command -v jq >/dev/null; then
|
||||
CLIENT_ID="$(echo "$local_client" | jq -r '.client_id // empty')"
|
||||
CLIENT_SECRET="$(echo "$local_client" | jq -r '.client_secret // empty')"
|
||||
else
|
||||
CLIENT_ID="$(echo "$local_client" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p')"
|
||||
CLIENT_SECRET="$(echo "$local_client" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p')"
|
||||
fi
|
||||
fi
|
||||
|
||||
grant="$(curl -sS -m 20 -X POST "$API/users/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET" 2>/dev/null)"
|
||||
|
||||
if echo "$grant" | grep -q '"access_token"'; then
|
||||
token="$(echo "$grant" | jq -r '.access_token')"
|
||||
expires="$(echo "$grant" | jq -r '.expires_in // "n/a"')"
|
||||
gum style "✓ client_credentials OK — minted access token" --foreground 42
|
||||
gum style " expires_in: $expires s" --foreground 240
|
||||
gum style "Access token:" --foreground 214 --bold
|
||||
echo "$token"
|
||||
exit 0
|
||||
else
|
||||
errcode="$(echo "$grant" | jq -r '.code // empty' 2>/dev/null)"
|
||||
errmsg="$(echo "$grant" | jq -r '.detail // .error // "unknown"' 2>/dev/null)"
|
||||
if [[ "$errcode" == "unsupported_grant_type" ]]; then
|
||||
gum style "✗ client_credentials REJECTED (unsupported_grant_type)" --foreground 196
|
||||
gum style " The '$CLIENT_ID' client is not an application client — it can only do" --foreground 214
|
||||
gum style " password grant. You need an ADMIN-CREATED app client (see below)." --foreground 214
|
||||
else
|
||||
gum style "✗ client_credentials failed: $errcode $errmsg" --foreground 196
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Guide: how an admin creates an app client ───────────────────────────────
|
||||
gum style "── How to get a long-lived app token ──" --padding "0 1" --foreground 240 --bold
|
||||
gum style "Run this ON the PeerTube server (as an admin):" --foreground 214
|
||||
gum style "" --foreground 240
|
||||
gum style " cd /var/www/peertube/prod" --foreground 240
|
||||
gum style " sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/prod/config \\" --foreground 240
|
||||
gum style " NODE_ENV=production node -r dotenv/config tools/create-client.js" --foreground 240
|
||||
gum style "" --foreground 240
|
||||
gum style "It prints a client_id and client_secret." --foreground 240
|
||||
gum style "Then mint a long-lived token right here:" --foreground 214
|
||||
gum style "" --foreground 240
|
||||
gum style " bash $SCRIPT_DIR/app-token.sh --client <client_id> --secret <client_secret>" --foreground 240
|
||||
gum style "" --foreground 240
|
||||
gum style "Note: the app client may still need the app authorized to act on behalf of" --foreground 240
|
||||
gum style "a user; PeerTube issues the token with the client's own permission scope." --foreground 240
|
||||
|
||||
exit 1
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* creds-probe.php
|
||||
*
|
||||
* CLI helper used by scripts/creds-test.sh. Performs two live probes using the
|
||||
* credentials stored in the database (SMTP username/password), WITHOUT the SSO
|
||||
* portal in the middle:
|
||||
*
|
||||
* 1. SMTP — real TCP connect + SMTP AUTH, then disconnect (no mail sent)
|
||||
* 2. PeerTube — OAuth2 "password" grant against <instance>/api/v1/users/token
|
||||
*
|
||||
* Output is a single JSON document on stdout:
|
||||
* {
|
||||
* "username": "...", // SMTP username (what PeerTube reuses)
|
||||
* "password": "...", // DECRYPTED password (only when --with-pwd)
|
||||
* "smtp": {... probe result },
|
||||
* "peertube": {... probe result }
|
||||
* }
|
||||
*
|
||||
* Usage:
|
||||
* php creds-probe.php [--db <path>] [--with-pwd] [--instance <url>] [--channel <handle>]
|
||||
*
|
||||
* Exit code 0 when BOTH probes succeed, 1 otherwise. Never prints the password to
|
||||
* stderr/log; only prints it to stdout when --with-pwd is given.
|
||||
*/
|
||||
|
||||
$opts = [
|
||||
'db' => null,
|
||||
'with-pwd' => false,
|
||||
'instance' => null, // override the stored instance URL (optional)
|
||||
'channel' => null, // override the stored channel handle (optional)
|
||||
];
|
||||
$args = $argv ?? ($GLOBALS['argv'] ?? $_SERVER['argv'] ?? []);
|
||||
$args = is_array($args) ? $args : [];
|
||||
array_shift($args);
|
||||
for ($i = 0; $i < count($args); $i++) {
|
||||
$a = $args[$i];
|
||||
if ($a === '--with-pwd') { $opts['with-pwd'] = true; continue; }
|
||||
if (in_array($a, ['--db', '--instance', '--channel'], true)) {
|
||||
$opts[ltrim($a, '-')] = $args[$i + 1] ?? null;
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Locate app root (script lives in scripts/ next to app/) ──────────────────
|
||||
$candidates = [__DIR__ . '/../app', __DIR__ . '/..'];
|
||||
$appRoot = null;
|
||||
foreach ($candidates as $c) {
|
||||
if (file_exists(realpath($c) . '/src/Crypto.php')) { $appRoot = realpath($c); break; }
|
||||
}
|
||||
if ($appRoot === null) { throw new RuntimeException('Could not locate app root (src/Crypto.php).'); }
|
||||
|
||||
define('APP_ROOT', $appRoot);
|
||||
|
||||
// Autoload dependencies (PHPMailer, GuzzleHttp) from composer.
|
||||
$autoloads = [
|
||||
$appRoot . '/../vendor/autoload.php', // repo root / vendor
|
||||
$appRoot . '/vendor/autoload.php', // app / vendor
|
||||
];
|
||||
foreach ($autoloads as $a) {
|
||||
if (is_file($a)) { require_once $a; break; }
|
||||
}
|
||||
|
||||
// Read-only probe: we must NOT run schema migrations against the live DB.
|
||||
// If DatabaseMigrations isn't loaded yet, provide a no-op runner so that
|
||||
// Database's constructor just opens a PDO connection.
|
||||
if (!class_exists('DatabaseMigrations', false)) {
|
||||
class DatabaseMigrations
|
||||
{
|
||||
public function run(): void {}
|
||||
}
|
||||
}
|
||||
|
||||
require_once $appRoot . '/src/Crypto.php';
|
||||
require_once $appRoot . '/src/Database.php';
|
||||
require_once $appRoot . '/src/SmtpRelay.php';
|
||||
require_once $appRoot . '/src/PeerTubeService.php';
|
||||
|
||||
$dbPath = $opts['db'] ?: $appRoot . '/storage/xamxam.db';
|
||||
if (!is_file($dbPath)) { throw new RuntimeException("Database not found: $dbPath"); }
|
||||
|
||||
$out = ['username' => '', 'password' => '', 'smtp' => null, 'peertube' => null];
|
||||
|
||||
$db = new Database($dbPath);
|
||||
$smtp = SmtpRelay::getSettings($db);
|
||||
$out['username'] = $smtp['username'];
|
||||
$out['password'] = $opts['with-pwd'] ? $smtp['password'] : '';
|
||||
|
||||
// ── Probe 1: SMTP (connect + AUTH + close, no message sent) ──────────────────
|
||||
if ($smtp['host'] !== '') {
|
||||
$t = SmtpRelay::test($db);
|
||||
$out['smtp'] = ['ok' => $t['ok'], 'error' => $t['error'], 'field' => $t['field']];
|
||||
} else {
|
||||
$out['smtp'] = ['ok' => false, 'error' => 'SMTP not configured.', 'field' => null];
|
||||
}
|
||||
|
||||
// ── Probe 2: PeerTube OAuth password grant ────────────────────────────────────
|
||||
$peertube = PeerTubeService::getSettings($db);
|
||||
if ($opts['instance']) { $peertube['instance_url'] = rtrim($opts['instance'], '/'); }
|
||||
if ($opts['channel']) { $peertube['channel_name'] = $opts['channel']; }
|
||||
|
||||
if ($peertube['instance_url'] === '') {
|
||||
$out['peertube'] = ['ok' => false, 'error' => 'PeerTube instance not configured.', 'token' => false];
|
||||
} else {
|
||||
try {
|
||||
$a = PeerTubeService::probeAuth($peertube);
|
||||
$out['peertube'] = ['ok' => $a['ok'], 'error' => $a['error'], 'token' => $a['ok']];
|
||||
} catch (\Throwable $e) {
|
||||
$out['peertube'] = ['ok' => false, 'error' => $e->getMessage(), 'token' => false];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode($out, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), "\n";
|
||||
// Exit 0 only if BOTH probes succeeded.
|
||||
exit(($out['smtp']['ok'] === true) && ($out['peertube']['ok'] === true) ? 0 : 1);
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# creds-test.sh — gum-powered interactive probe of the credentials shared by
|
||||
# SMTP and PeerTube in the xamxam database.
|
||||
#
|
||||
# Probes two endpoints with the SAME stored username/password, going directly
|
||||
# (bypassing any SSO portal):
|
||||
# • SMTP — TCP connect + SMTP AUTH + disconnect (no mail sent)
|
||||
# • PeerTube — OAuth2 "password" grant against <instance>/api/v1/users/token
|
||||
#
|
||||
# Every run is appended to a log without the plaintext password.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/creds-test.sh [--db <path>] [--instance <url>] [--channel <handle>]
|
||||
# --instance / --channel override the stored values for the PeerTube probe
|
||||
# --show-pwd reveal the decrypted password (interactive confirm)
|
||||
#
|
||||
# Exit code 0 => both probes succeeded; 1 => at least one failed.
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PROBE="$SCRIPT_DIR/creds-probe.php"
|
||||
LOG="$SCRIPT_DIR/../creds-test.log"
|
||||
|
||||
DB_PATH=""
|
||||
INSTANCE=""
|
||||
CHANNEL=""
|
||||
SHOW_PWD=0
|
||||
|
||||
# ---- argument parsing --------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--db) DB_PATH="$2"; shift 2 ;;
|
||||
--instance) INSTANCE="$2"; shift 2 ;;
|
||||
--channel) CHANNEL="$2"; shift 2 ;;
|
||||
--show-pwd) SHOW_PWD=1; shift ;;
|
||||
-h|--help)
|
||||
gum style "Usage:" --bold
|
||||
gum style " scripts/creds-test.sh [--db <path>] [--instance <url>] [--channel <handle>] [--show-pwd]"
|
||||
exit 0 ;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- preflight ---------------------------------------------------------------
|
||||
command -v gum >/dev/null || { echo "gum is required (install via 'brew install gum' or the repo justfile)"; exit 2; }
|
||||
command -v php >/dev/null || { echo "php is required"; exit 2; }
|
||||
[[ -f "$PROBE" ]] || { echo "Missing helper: $PROBE"; exit 2; }
|
||||
|
||||
gum style "── XAMXAM · probe SMTP vs PeerTube ────────────────────────" \
|
||||
--border double --padding "1 2" --foreground 212
|
||||
|
||||
# Default DB path: same layout as the PHP helpers.
|
||||
DB_PATH="${DB_PATH:-$REPO_ROOT/app/storage/xamxam.db}"
|
||||
if [[ ! -f "$DB_PATH" ]]; then
|
||||
gum style "✗ Database not found: $DB_PATH" --foreground 196
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PROBE_ARGS=(--db "$DB_PATH")
|
||||
[[ -n "$INSTANCE" ]] && PROBE_ARGS+=(--instance "$INSTANCE")
|
||||
[[ -n "$CHANNEL" ]] && PROBE_ARGS+=(--channel "$CHANNEL")
|
||||
if [[ "$SHOW_PWD" -eq 1 ]]; then
|
||||
# Interactive confirmation BEFORE we ever print a password.
|
||||
if gum confirm "Show the decrypted password on screen? (it will NOT be logged)"; then
|
||||
PROBE_ARGS+=(--with-pwd)
|
||||
else
|
||||
exit 130
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- run the probe ---------------------------------------------------------------
|
||||
OUT="$(gum spin --spinner minidot --title "Probing SMTP + PeerTube …" \
|
||||
-- php "$PROBE" "${PROBE_ARGS[@]}" 2>/tmp/creds-probe.err)"
|
||||
RC=$?
|
||||
if [[ -n "${OUT:-}" ]]; then
|
||||
JSON="$OUT"
|
||||
else
|
||||
JSON='{}'
|
||||
fi
|
||||
|
||||
# ---- decode ------------------------------------------------------------------
|
||||
username=$(php -r 'echo json_decode($argv[1],true)["username"]??"";' "$JSON")
|
||||
smtp_ok=$(php -r 'echo (json_decode($argv[1],true)["smtp"]["ok"]??false)?"1":"0";' "$JSON")
|
||||
smtp_err=$(php -r 'echo json_decode($argv[1],true)["smtp"]["error"]??"";' "$JSON")
|
||||
ptv_ok=$(php -r 'echo (json_decode($argv[1],true)["peertube"]["ok"]??false)?"1":"0";' "$JSON")
|
||||
ptv_err=$(php -r 'echo json_decode($argv[1],true)["peertube"]["error"]??"";' "$JSON")
|
||||
|
||||
# ---- render with gum ---------------------------------------------------------
|
||||
gum style "Username : $username" --foreground 214
|
||||
[[ "$SHOW_PWD" -eq 1 ]] && gum style "Password : $(php -r 'echo json_decode($argv[1],true)["password"]??"";' "$JSON")" --foreground 214
|
||||
|
||||
gum style "SMTP" --padding "0 1" --foreground 240 --bold
|
||||
if [[ "$smtp_ok" == "1" ]]; then
|
||||
gum style " ✓ SMTP AUTH succeeded (connect + auth + close, no mail sent)" --foreground 42
|
||||
else
|
||||
gum style " ✗ SMTP failed: ${smtp_err}" --foreground 196
|
||||
fi
|
||||
|
||||
gum style "PeerTube (direct OAuth password grant, no portal)" --padding "0 1" --foreground 240 --bold
|
||||
if [[ "$ptv_ok" == "1" ]]; then
|
||||
gum style " ✓ PeerTube issued an access token with these creds" --foreground 42
|
||||
else
|
||||
gum style " ✗ PeerTube failed: ${ptv_err}" --foreground 196
|
||||
fi
|
||||
|
||||
# ---- verdict + log -------------------------------------------------------------
|
||||
verdict="UNKNOWN"
|
||||
if [[ "$ptv_ok" == "1" ]]; then verdict="PEERTUBE_TOKENS_OK"; fi
|
||||
status_line="$verdict"
|
||||
if [[ "$ptv_ok" == "0" && "$smtp_ok" == "1" ]]; then
|
||||
status_line="SMTP_OK__PEERTUBE_BAD"
|
||||
gum style "" --foreground 214
|
||||
gum style " Root-cause hint:" --foreground 214 --bold
|
||||
gum style " Credentials are valid (SMTP auth works), but PeerTube's password" --foreground 214
|
||||
gum style " grant rejects them → the SSO/portal almost certainly broke PeerTube's" --foreground 214
|
||||
gum style " direct API password-grant path, even though the same login works" --foreground 214
|
||||
gum style " through portail.erg.be. See the curl repro in the PHP helper/todo." --foreground 214
|
||||
elif [[ "$ptv_ok" == "0" && "$smtp_ok" == "0" ]]; then
|
||||
status_line="BOTH_BAD"
|
||||
gum style " Both probes failed → the stored credentials themselves are wrong/stale." --foreground 214
|
||||
elif [[ "$ptv_ok" == "1" && "$smtp_ok" == "1" ]]; then
|
||||
status_line="BOTH_OK"
|
||||
gum style " Both SMTP and PeerTube accept these credentials." --foreground 42
|
||||
fi
|
||||
|
||||
# ---- append to log (NEVER the password) -----------------------------------------
|
||||
{
|
||||
echo "===== $(date '+%Y-%m-%d %H:%M:%S') | $status_line | user=$username | db=$DB_PATH"
|
||||
echo " SMTP : ok=$smtp_ok err=$(printf '%q' "$smtp_err")"
|
||||
echo " PeerTube : ok=$ptv_ok err=$(printf '%q' "$ptv_err")"
|
||||
} >> "$LOG"
|
||||
gum style "" --foreground 240
|
||||
gum style "Logged (no password stored) → $LOG" --foreground 240
|
||||
|
||||
# Exit 0 only when BOTH probes succeeded.
|
||||
[[ "$smtp_ok" == "1" && "$ptv_ok" == "1" ]] && exit 0 || exit 1
|
||||
Reference in New Issue
Block a user