scripts: Standard-Deploy ueber git-pull (release/2.0) + major-release-cut.sh
Nach dem 1.x → 2.0-Cut auf prod (siehe v2.0.0-Tag) laeuft prod als sauberer git-checkout. Tar-Upload-Pfad ist obsolet. - scripts/deploy.sh: Branch-Guard release/2.0, Pre-flight-Checks (clean + pushed), Pre-Deploy-DB-Backup, Uptime-Kuma-Wartungsmodus, /health-Check mit Version-Anzeige nach Deploy - scripts/major-release-cut.sh: dokumentierter Workflow fuer den naechsten Major-Cut (z.B. 2.0 → 3.0). Inklusive Bundle-Fallback bei Gitea-Korruption (war beim 2.0-Cut gebraucht), DB-Wipe-Liste mit Erhalt der Vote-Daten, Pfad-Switchover und Smoke-Tests
This commit is contained in:
parent
770d890917
commit
d7e3c8a944
@ -1,12 +1,22 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Deploy-Script mit Uptime-Kuma-Wartungsmodus
|
# Standard-Deploy auf gwoe.toppyr.de (Prod).
|
||||||
# Usage: ./scripts/deploy.sh [files...]
|
|
||||||
# Ohne Argumente: alles deployen
|
|
||||||
#
|
#
|
||||||
# Setzt den GWÖ-Monitor in Uptime Kuma auf Wartung,
|
# Workflow ab v2.0.0: prod laeuft als sauberer git-checkout, der Server
|
||||||
# deployed, und aktiviert den Monitor wieder.
|
# zieht den release/2.0-Branch direkt aus dem Gitea-Repo. Tar-Upload-
|
||||||
|
# Pfad ist obsolet (siehe scripts/major-release-cut.sh fuer den
|
||||||
|
# Spezialfall eines neuen Major-Cuts).
|
||||||
#
|
#
|
||||||
# Benötigt: UPTIME_KUMA_USER + UPTIME_KUMA_PASS in ~/.env oder als ENV
|
# Setzt den Uptime-Kuma-Monitor auf Wartung, deployed, reaktiviert.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/deploy.sh # Deploy aktuellen release/2.0-Stand
|
||||||
|
# ./scripts/deploy.sh --force # Branch-Guard ueberspringen (Notfall)
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - Lokaler Branch ist release/2.0 (oder --force)
|
||||||
|
# - Remote release/2.0 ist gepusht
|
||||||
|
# - SSH-Zugang zu vserver
|
||||||
|
# - UPTIME_KUMA_USER + UPTIME_KUMA_PASS in ~/.env (optional)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@ -14,20 +24,19 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
SERVER="vserver"
|
SERVER="vserver"
|
||||||
REMOTE_DIR="/opt/gwoe-antragspruefer"
|
REMOTE_DIR="/opt/gwoe-antragspruefer"
|
||||||
|
PUBLIC_URL="https://gwoe.toppyr.de"
|
||||||
UPTIME_KUMA_URL="https://status.toppyr.de"
|
UPTIME_KUMA_URL="https://status.toppyr.de"
|
||||||
MONITOR_ID=9 # GWÖ-Antragsprüfer
|
MONITOR_ID=9
|
||||||
|
EXPECTED_BRANCH="release/2.0"
|
||||||
|
|
||||||
# Credentials laden
|
|
||||||
if [ -f ~/.env ]; then
|
if [ -f ~/.env ]; then
|
||||||
source ~/.env
|
source ~/.env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
# Branch-Guard: Prod (gwoe.toppyr.de) ist auf release/1.0 festgelegt.
|
# Branch-Guard
|
||||||
# 1.x-Entwicklung laeuft auf gwoe-dev.toppyr.de via Cron-Auto-Deploy aus main.
|
|
||||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||||||
EXPECTED_BRANCH="release/1.0"
|
|
||||||
if [ "${1:-}" = "--force" ]; then
|
if [ "${1:-}" = "--force" ]; then
|
||||||
shift
|
shift
|
||||||
echo "⚠ --force aktiv: Branch-Guard übersprungen ($CURRENT_BRANCH)"
|
echo "⚠ --force aktiv: Branch-Guard übersprungen ($CURRENT_BRANCH)"
|
||||||
@ -35,20 +44,43 @@ elif [ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]; then
|
|||||||
cat <<EOF
|
cat <<EOF
|
||||||
✗ Prod-Deploy abgebrochen: lokal aktiv ist '$CURRENT_BRANCH', erwartet '$EXPECTED_BRANCH'.
|
✗ Prod-Deploy abgebrochen: lokal aktiv ist '$CURRENT_BRANCH', erwartet '$EXPECTED_BRANCH'.
|
||||||
|
|
||||||
Prod (gwoe.toppyr.de) ist auf release/1.0 festgelegt. Vor einem Deploy:
|
Prod (gwoe.toppyr.de) ist ab v2.0.0 auf release/2.0 festgelegt.
|
||||||
git checkout release/1.0
|
|
||||||
|
|
||||||
Fuer Dev (gwoe-dev.toppyr.de) braucht es kein deploy.sh — der Server zieht
|
Fuer ein normales Update:
|
||||||
main per Cron alle 5 Minuten.
|
git checkout release/2.0
|
||||||
|
git merge --ff-only main # falls main neuer ist
|
||||||
|
git push
|
||||||
|
./scripts/deploy.sh
|
||||||
|
|
||||||
Mit --force kann der Guard ueberbruckt werden (nur in Notfaellen).
|
Fuer Dev (gwoe-dev.toppyr.de) braucht es kein deploy.sh — der Server
|
||||||
|
zieht main per Cron alle 5 Minuten.
|
||||||
|
|
||||||
|
Mit --force kann der Guard ueberbrueckt werden (nur in Notfaellen).
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== GWÖ-Antragsprüfer Deploy ==="
|
# Pre-flight: lokal commited?
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "⚠ Working tree ist nicht clean — uncommitted changes:"
|
||||||
|
git status --short
|
||||||
|
read -p "Trotzdem fortfahren? [y/N] " ans
|
||||||
|
[ "$ans" = "y" ] || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# 1. Uptime Kuma auf Wartung setzen
|
# Pre-flight: lokal gepusht?
|
||||||
|
LOCAL_HEAD=$(git rev-parse HEAD)
|
||||||
|
REMOTE_HEAD=$(git rev-parse "origin/$EXPECTED_BRANCH" 2>/dev/null || echo "")
|
||||||
|
if [ "$LOCAL_HEAD" != "$REMOTE_HEAD" ]; then
|
||||||
|
echo "⚠ Lokal ($LOCAL_HEAD) != origin/$EXPECTED_BRANCH ($REMOTE_HEAD)"
|
||||||
|
echo " Erst pushen, dann deployen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Deploy GWÖ-Antragsprüfer auf $PUBLIC_URL ==="
|
||||||
|
echo "Branch: $EXPECTED_BRANCH @ ${LOCAL_HEAD:0:8}"
|
||||||
|
|
||||||
|
# 1. Uptime Kuma auf Wartung
|
||||||
if [ -n "${UPTIME_KUMA_USER:-}" ] && [ -n "${UPTIME_KUMA_PASS:-}" ]; then
|
if [ -n "${UPTIME_KUMA_USER:-}" ] && [ -n "${UPTIME_KUMA_PASS:-}" ]; then
|
||||||
echo "⏸ Setze Monitor auf Wartung..."
|
echo "⏸ Setze Monitor auf Wartung..."
|
||||||
python3 -c "
|
python3 -c "
|
||||||
@ -59,39 +91,34 @@ api.pause_monitor($MONITOR_ID)
|
|||||||
api.disconnect()
|
api.disconnect()
|
||||||
print(' Monitor pausiert')
|
print(' Monitor pausiert')
|
||||||
" 2>/dev/null || echo " (Uptime Kuma nicht erreichbar, überspringe)"
|
" 2>/dev/null || echo " (Uptime Kuma nicht erreichbar, überspringe)"
|
||||||
else
|
|
||||||
echo "⚠ UPTIME_KUMA_USER/PASS nicht gesetzt, überspringe Wartungsmodus"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Build + Deploy
|
# 2. Pre-Deploy DB-Snapshot fuer Schnell-Rollback
|
||||||
if [ $# -gt 0 ]; then
|
echo "💾 Pre-Deploy DB-Backup..."
|
||||||
# Spezifische Files
|
ssh "$SERVER" "$REMOTE_DIR/scripts/backup-db.sh" 2>&1 | tail -1
|
||||||
echo "📦 Packe: $@"
|
|
||||||
tar czf /tmp/gwoe-deploy.tar.gz "$@"
|
|
||||||
else
|
|
||||||
# Alles
|
|
||||||
echo "📦 Packe gesamtes Projekt (ohne venv/data/reports)..."
|
|
||||||
tar czf /tmp/gwoe-deploy.tar.gz \
|
|
||||||
--exclude='venv' --exclude='__pycache__' \
|
|
||||||
--exclude='data' --exclude='reports' --exclude='.env' .
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🚀 Upload + Build..."
|
# 3. Pull + Build
|
||||||
scp /tmp/gwoe-deploy.tar.gz "$SERVER:/tmp/"
|
echo "🚀 git pull + docker compose up -d --build..."
|
||||||
ssh "$SERVER" "cd $REMOTE_DIR && tar xzf /tmp/gwoe-deploy.tar.gz && docker compose up -d --build" 2>&1 | tail -5
|
ssh "$SERVER" "cd $REMOTE_DIR && git fetch --quiet && git reset --hard origin/$EXPECTED_BRANCH && docker compose up -d --build" 2>&1 | tail -8
|
||||||
|
|
||||||
# 3. Warte auf Health
|
# 4. Warte auf Health
|
||||||
echo "⏳ Warte auf Health-Check..."
|
echo "⏳ Warte auf Health-Check..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 60); do
|
||||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 3 "https://gwoe.toppyr.de/health" 2>/dev/null || echo "000")
|
response=$(curl -sS --max-time 3 "$PUBLIC_URL/health" 2>/dev/null || echo "")
|
||||||
if [ "$code" = "200" ]; then
|
if echo "$response" | grep -q '"status":"ok"'; then
|
||||||
echo "✅ Health OK nach ${i}s"
|
version=$(echo "$response" | python3 -c "import json,sys; print(json.load(sys.stdin).get('version','?'))" 2>/dev/null || echo "?")
|
||||||
|
echo "✅ Health OK nach ${i}s — version $version"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
|
if [ "$i" = "60" ]; then
|
||||||
|
echo "✗ Health-Check nach 60s nicht OK — pruefe Logs:"
|
||||||
|
echo " ssh $SERVER 'docker logs gwoe-antragspruefer --tail 50'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# 4. Uptime Kuma wieder aktivieren
|
# 5. Uptime Kuma reaktivieren
|
||||||
if [ -n "${UPTIME_KUMA_USER:-}" ] && [ -n "${UPTIME_KUMA_PASS:-}" ]; then
|
if [ -n "${UPTIME_KUMA_USER:-}" ] && [ -n "${UPTIME_KUMA_PASS:-}" ]; then
|
||||||
echo "▶ Reaktiviere Monitor..."
|
echo "▶ Reaktiviere Monitor..."
|
||||||
python3 -c "
|
python3 -c "
|
||||||
|
|||||||
160
scripts/major-release-cut.sh
Executable file
160
scripts/major-release-cut.sh
Executable file
@ -0,0 +1,160 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Major-Release-Cut auf Prod (z.B. 1.x → 2.0).
|
||||||
|
#
|
||||||
|
# Sonderfall — laeuft EINMAL pro Major. Fuer normale Updates auf demselben
|
||||||
|
# Major-Branch: scripts/deploy.sh.
|
||||||
|
#
|
||||||
|
# Was hier passiert:
|
||||||
|
# 1. Pre-flight: Tag + release/X.0-Branch existieren? Lokales Repo clean?
|
||||||
|
# 2. DB-Dumps prod + dev frisch ziehen
|
||||||
|
# 3. Manuelles Restic-Backup
|
||||||
|
# 4. Frischer git-clone auf vserver in Parallel-Pfad
|
||||||
|
# 5. Praeparierte data/-Dir vorbereiten:
|
||||||
|
# - dev's data als Basis (enthaelt aktuelles Schema, embeddings, votes)
|
||||||
|
# - assessments/jobs/news/PM/monitoring leeren (Frischstart)
|
||||||
|
# - Behalten: plenum_vote_results, abgeordnetenwatch_votes/polls
|
||||||
|
# 6. backups/, reports/, .env aus altem Pfad kopieren
|
||||||
|
# 7. Container down → Pfad-Switch → Container up
|
||||||
|
# 8. Smoke-Tests
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/major-release-cut.sh v2.0.0 release/2.0
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - Tag + Branch sind bereits gepusht
|
||||||
|
# - Lokal $RELEASE_BRANCH ist ausgecheckt und aktuell
|
||||||
|
# - SSH-Zugang zu vserver
|
||||||
|
#
|
||||||
|
# Falls Gitea-seitig "early EOF / repository corruption":
|
||||||
|
# git bundle create /tmp/release.bundle <release-branch>
|
||||||
|
# scp /tmp/release.bundle vserver:/tmp/
|
||||||
|
# ssh vserver 'sudo git clone -b <branch> /tmp/release.bundle <neuer-pfad>'
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 <tag> <branch>
|
||||||
|
Beispiel: $0 v2.0.0 release/2.0
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAG="$1"
|
||||||
|
BRANCH="$2"
|
||||||
|
SERVER="vserver"
|
||||||
|
PROD_DIR="/opt/gwoe-antragspruefer"
|
||||||
|
DEV_DIR="/opt/gwoe-antragspruefer-dev"
|
||||||
|
NEW_DIR="/opt/gwoe-antragspruefer-${TAG#v}" # z.B. /opt/gwoe-antragspruefer-2.0.0
|
||||||
|
ARCHIVE_DIR="/opt/gwoe-antragspruefer-$(date +%Y%m%d-%H%M%S)-archive"
|
||||||
|
PUBLIC_URL="https://gwoe.toppyr.de"
|
||||||
|
REPO_URL="https://repo.toppyr.de/tobias/gwoe-antragspruefer.git"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
echo "=== Major-Release-Cut auf $PUBLIC_URL ==="
|
||||||
|
echo "Tag: $TAG"
|
||||||
|
echo "Branch: $BRANCH"
|
||||||
|
echo "Neu: $NEW_DIR"
|
||||||
|
echo "Alt: $ARCHIVE_DIR (Archiv)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Pre-flight
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "✗ Lokales Repo nicht clean. Erst commiten/stashen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||||
|
echo "✗ Tag $TAG existiert lokal nicht."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "⚠ Major-Cut auf prod jetzt durchfuehren? [yes/N] " ans
|
||||||
|
[ "$ans" = "yes" ] || exit 1
|
||||||
|
|
||||||
|
# 1. Frische DB-Dumps prod + dev
|
||||||
|
echo
|
||||||
|
echo "1️⃣ Frische DB-Dumps..."
|
||||||
|
ssh "$SERVER" "$PROD_DIR/scripts/backup-db.sh && $DEV_DIR/scripts/backup-db.sh" 2>&1 | tail -2
|
||||||
|
|
||||||
|
# 2. Manuelles Restic-Backup
|
||||||
|
echo
|
||||||
|
echo "2️⃣ Restic-Backup..."
|
||||||
|
ssh "$SERVER" "sudo /home/dotty/backup-scripts/vserver-backup.sh" 2>&1 | tail -3
|
||||||
|
|
||||||
|
# 3. Frischer Clone auf vserver (mit Bundle-Fallback bei Gitea-Korruption)
|
||||||
|
echo
|
||||||
|
echo "3️⃣ Klone $BRANCH nach $NEW_DIR..."
|
||||||
|
if ! ssh "$SERVER" "sudo git clone -b '$BRANCH' --single-branch '$REPO_URL' '$NEW_DIR' 2>&1" | tail -3 | grep -q "done"; then
|
||||||
|
echo " Gitea-Clone fehlgeschlagen — versuche Bundle-Fallback..."
|
||||||
|
git bundle create "/tmp/${TAG}.bundle" "$BRANCH" 2>&1 | tail -1
|
||||||
|
scp "/tmp/${TAG}.bundle" "$SERVER:/tmp/"
|
||||||
|
ssh "$SERVER" "sudo rm -rf '$NEW_DIR' 2>/dev/null; sudo git clone -b '$BRANCH' '/tmp/${TAG}.bundle' '$NEW_DIR' 2>&1 | tail -3"
|
||||||
|
ssh "$SERVER" "cd '$NEW_DIR' && sudo git remote set-url origin '$REPO_URL'"
|
||||||
|
fi
|
||||||
|
ssh "$SERVER" "sudo chown -R dotty:dotty '$NEW_DIR' && cd '$NEW_DIR' && git log --oneline -1"
|
||||||
|
|
||||||
|
# 4. Praeparierte data/-Dir: dev's Stand als Basis, ausgewaehlte Tabellen leeren
|
||||||
|
echo
|
||||||
|
echo "4️⃣ Bereite leere data/-Dir vor (basiert auf dev, assessments leer, votes behalten)..."
|
||||||
|
ssh "$SERVER" "cp -a '$DEV_DIR/data/.' '$NEW_DIR/data/'"
|
||||||
|
ssh "$SERVER" "python3 - <<'PY'
|
||||||
|
import sqlite3
|
||||||
|
db = sqlite3.connect('$NEW_DIR/data/gwoe-antraege.db')
|
||||||
|
TO_CLEAR = [
|
||||||
|
'assessments', 'assessment_versions',
|
||||||
|
'presse_drafts', 'news_articles',
|
||||||
|
'auto_rate_runs', 'jobs',
|
||||||
|
'monitoring_scans', 'monitoring_daily_summary',
|
||||||
|
'auth_bypass_uses', 'comments', 'merkliste',
|
||||||
|
'bookmarks', 'email_subscriptions', 'votes',
|
||||||
|
]
|
||||||
|
# Behalten: plenum_vote_results, abgeordnetenwatch_votes, abgeordnetenwatch_polls
|
||||||
|
for t in TO_CLEAR:
|
||||||
|
try: db.execute(f'DELETE FROM {t}')
|
||||||
|
except Exception: pass
|
||||||
|
try: db.execute('DELETE FROM sqlite_sequence')
|
||||||
|
except Exception: pass
|
||||||
|
db.commit()
|
||||||
|
db.execute('VACUUM')
|
||||||
|
print(' votes:', db.execute('SELECT COUNT(*) FROM plenum_vote_results').fetchone()[0])
|
||||||
|
print(' assessments:', db.execute('SELECT COUNT(*) FROM assessments').fetchone()[0])
|
||||||
|
db.close()
|
||||||
|
PY"
|
||||||
|
|
||||||
|
# 5. .env, backups/, reports/ aus altem Pfad
|
||||||
|
echo
|
||||||
|
echo "5️⃣ Kopiere .env, backups/, reports/ aus altem Pfad..."
|
||||||
|
ssh "$SERVER" "cp '$PROD_DIR/.env' '$NEW_DIR/.env'"
|
||||||
|
ssh "$SERVER" "cp -a '$PROD_DIR/backups' '$NEW_DIR/backups'"
|
||||||
|
ssh "$SERVER" "cp -a '$PROD_DIR/reports' '$NEW_DIR/reports'"
|
||||||
|
|
||||||
|
# 6. Switchover
|
||||||
|
echo
|
||||||
|
echo "6️⃣ Container down + Pfad-Switch..."
|
||||||
|
ssh "$SERVER" "cd '$PROD_DIR' && docker compose down" 2>&1 | tail -3
|
||||||
|
ssh "$SERVER" "sudo mv '$PROD_DIR' '$ARCHIVE_DIR' && sudo mv '$NEW_DIR' '$PROD_DIR' && sudo chown -R dotty:dotty '$PROD_DIR'"
|
||||||
|
ssh "$SERVER" "cd '$PROD_DIR' && docker compose up -d --build" 2>&1 | tail -5
|
||||||
|
|
||||||
|
# 7. Smoke-Tests
|
||||||
|
echo
|
||||||
|
echo "7️⃣ Smoke-Tests..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
response=$(curl -sS --max-time 3 "$PUBLIC_URL/health" 2>/dev/null || echo "")
|
||||||
|
if echo "$response" | grep -q '"status":"ok"'; then
|
||||||
|
version=$(echo "$response" | python3 -c "import json,sys; print(json.load(sys.stdin).get('version','?'))" 2>/dev/null)
|
||||||
|
echo "✅ Health OK nach ${i}s — version $version"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " Score-Histogram: $(curl -sk "$PUBLIC_URL/api/auswertungen/score-histogram" | python3 -c 'import json,sys; print(json.load(sys.stdin)["total"])')"
|
||||||
|
echo " Bundeslaender: $(curl -sk "$PUBLIC_URL/api/bundeslaender" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Cut abgeschlossen ==="
|
||||||
|
echo "Alt-Stand: $ARCHIVE_DIR (kann nach paar Tagen geloescht werden)"
|
||||||
|
echo "Neuer Workflow: scripts/deploy.sh (auf Branch $BRANCH)"
|
||||||
Loading…
Reference in New Issue
Block a user