Release URBM 2026.06.15.r005
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
87517e9e5e8d7b255576173f55c9092551c161e84ea2a95c0301ca06c5c5b519 urbm-2026.06.15.r003-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
8c6fc66fbe80ca1dc06e5bf445a794b749eaca212376e016160e24310deb5e5f urbm-2026.06.15.r005-x86_64-1.txz
|
||||||
Vendored
+10
-2
@@ -2,13 +2,21 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.15.r003">
|
<!ENTITY version "2026.06.15.r005">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
||||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "87517e9e5e8d7b255576173f55c9092551c161e84ea2a95c0301ca06c5c5b519">
|
<!ENTITY packageSHA256 "8c6fc66fbe80ca1dc06e5bf445a794b749eaca212376e016160e24310deb5e5f">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.06.15.r005
|
||||||
|
- Truncate long activity job names cleanly while keeping the complete name available as a tooltip.
|
||||||
|
- Automatically clear safe stale Restic locks and retry retention once after a successful backup.
|
||||||
|
|
||||||
|
### 2026.06.15.r004
|
||||||
|
- Show readable job and repository names in activities instead of full internal UUIDs.
|
||||||
|
- Replace verbose Restic lock output with concise German recovery guidance and prevent status badges from overlapping adjacent columns.
|
||||||
|
|
||||||
### 2026.06.15.r003
|
### 2026.06.15.r003
|
||||||
- Keep activity tables inside their cards by wrapping long messages, paths, and job IDs and removing oversized empty action columns.
|
- Keep activity tables inside their cards by wrapping long messages, paths, and job IDs and removing oversized empty action columns.
|
||||||
- Add controlled horizontal scrolling for activity tables on narrow displays.
|
- Add controlled horizontal scrolling for activity tables on narrow displays.
|
||||||
|
|||||||
@@ -139,6 +139,13 @@ func (r *Runner) Forget(ctx context.Context, repo model.Repository, job model.Jo
|
|||||||
if job.Retention.Monthly > 0 {
|
if job.Retention.Monthly > 0 {
|
||||||
args = append(args, "--keep-monthly", fmt.Sprint(job.Retention.Monthly))
|
args = append(args, "--keep-monthly", fmt.Sprint(job.Retention.Monthly))
|
||||||
}
|
}
|
||||||
|
err := r.run(ctx, repo, args, nil, nil)
|
||||||
|
if err == nil || !isRepositoryLocked(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if unlockErr := r.Unlock(ctx, repo); unlockErr != nil {
|
||||||
|
return fmt.Errorf("retention repository locked; remove stale lock: %w", unlockErr)
|
||||||
|
}
|
||||||
return r.run(ctx, repo, args, nil, nil)
|
return r.run(ctx, repo, args, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +335,15 @@ func resticError(message string, commandErr error) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isRepositoryLocked(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(lower, "repository is already locked") ||
|
||||||
|
strings.Contains(lower, "unable to create lock in backend")
|
||||||
|
}
|
||||||
|
|
||||||
func repositoryLocation(repo model.Repository) string {
|
func repositoryLocation(repo model.Repository) string {
|
||||||
if repo.Type == model.RepositorySFTP && !strings.HasPrefix(repo.Location, "sftp:") {
|
if repo.Type == model.RepositorySFTP && !strings.HasPrefix(repo.Location, "sftp:") {
|
||||||
return "sftp:" + repo.Location
|
return "sftp:" + repo.Location
|
||||||
|
|||||||
@@ -76,6 +76,45 @@ func TestForgetUsesAgeAndCalendarRetention(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestForgetUnlocksStaleRepositoryLockAndRetries(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
logPath := filepath.Join(dir, "commands")
|
||||||
|
countPath := filepath.Join(dir, "forget-count")
|
||||||
|
script := filepath.Join(dir, "restic")
|
||||||
|
body := fmt.Sprintf(`#!/bin/sh
|
||||||
|
printf '%%s\n' "$*" >> '%s'
|
||||||
|
case " $* " in
|
||||||
|
*" forget "*)
|
||||||
|
count=0
|
||||||
|
[ -f '%s' ] && count=$(cat '%s')
|
||||||
|
count=$((count + 1))
|
||||||
|
printf '%%s' "$count" > '%s'
|
||||||
|
if [ "$count" -eq 1 ]; then
|
||||||
|
printf 'Fatal: unable to create lock in backend: repository is already locked\n' >&2
|
||||||
|
exit 11
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
`, logPath, countPath, countPath, countPath)
|
||||||
|
if err := os.WriteFile(script, []byte(body), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
|
||||||
|
repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
|
||||||
|
job := model.Job{ID: "job", Retention: model.Retention{KeepWithinDays: 30}}
|
||||||
|
if err := runner.Forget(context.Background(), repo, job); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
commands, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(commands)), "\n")
|
||||||
|
if len(lines) != 3 || !strings.Contains(lines[0], "forget") || !strings.Contains(lines[1], "unlock") || !strings.Contains(lines[2], "forget") {
|
||||||
|
t.Fatalf("expected forget, unlock, forget; got %q", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnlockUsesSafeResticCommand(t *testing.T) {
|
func TestUnlockUsesSafeResticCommand(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
argsPath := filepath.Join(dir, "args")
|
argsPath := filepath.Join(dir, "args")
|
||||||
|
|||||||
+9
-1
@@ -2,13 +2,21 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.15.r003">
|
<!ENTITY version "2026.06.15.r005">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
||||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.06.15.r005
|
||||||
|
- Truncate long activity job names cleanly while keeping the complete name available as a tooltip.
|
||||||
|
- Automatically clear safe stale Restic locks and retry retention once after a successful backup.
|
||||||
|
|
||||||
|
### 2026.06.15.r004
|
||||||
|
- Show readable job and repository names in activities instead of full internal UUIDs.
|
||||||
|
- Replace verbose Restic lock output with concise German recovery guidance and prevent status badges from overlapping adjacent columns.
|
||||||
|
|
||||||
### 2026.06.15.r003
|
### 2026.06.15.r003
|
||||||
- Keep activity tables inside their cards by wrapping long messages, paths, and job IDs and removing oversized empty action columns.
|
- Keep activity tables inside their cards by wrapping long messages, paths, and job IDs and removing oversized empty action columns.
|
||||||
- Add controlled horizontal scrolling for activity tables on narrow displays.
|
- Add controlled horizontal scrolling for activity tables on narrow displays.
|
||||||
|
|||||||
+4
-4
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
|||||||
<?php
|
<?php
|
||||||
$pluginRoot = '/plugins/urbm';
|
$pluginRoot = '/plugins/urbm';
|
||||||
?>
|
?>
|
||||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r003">
|
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r005">
|
||||||
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
|
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||||
<header class="bu-header">
|
<header class="bu-header">
|
||||||
<div class="bu-brand">
|
<div class="bu-brand">
|
||||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r003" alt="URBM-Logo">
|
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r005" alt="URBM-Logo">
|
||||||
<div><h1>URBM <span class="bu-version">2026.06.15.r003</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
<div><h1>URBM <span class="bu-version">2026.06.15.r005</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
|
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
|
|||||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||||
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r003.js"></script>
|
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r005.js"></script>
|
||||||
|
|||||||
@@ -143,15 +143,19 @@
|
|||||||
.bu-table th { color: var(--bu-text); font-size: 13px; font-weight: 700; letter-spacing: .03em; opacity: .76; text-transform: uppercase; }
|
.bu-table th { color: var(--bu-text); font-size: 13px; font-weight: 700; letter-spacing: .03em; opacity: .76; text-transform: uppercase; }
|
||||||
.bu-table-scroll { max-width: 100%; overflow-x: auto; }
|
.bu-table-scroll { max-width: 100%; overflow-x: auto; }
|
||||||
.bu-runs-table { table-layout: fixed; }
|
.bu-runs-table { table-layout: fixed; }
|
||||||
.bu-runs-table .bu-run-task { width: 18%; }
|
.bu-runs-table .bu-run-task { width: 17%; }
|
||||||
.bu-runs-table .bu-run-status { width: 8%; }
|
.bu-runs-table .bu-run-status { width: 9%; }
|
||||||
.bu-runs-table .bu-run-created { width: 11%; }
|
.bu-runs-table .bu-run-created { width: 12%; }
|
||||||
.bu-runs-table .bu-run-progress { width: 27%; }
|
.bu-runs-table .bu-run-progress { width: 26%; }
|
||||||
.bu-runs-table .bu-run-message { width: 30%; }
|
.bu-runs-table .bu-run-message { width: 30%; }
|
||||||
.bu-runs-table .bu-run-actions { width: 6%; }
|
.bu-runs-table .bu-run-actions { width: 6%; }
|
||||||
.bu-runs-table th, .bu-runs-table td { min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
|
.bu-runs-table th, .bu-runs-table td { min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
|
||||||
.bu-runs-table .bu-run-message-cell { line-height: 1.45; white-space: normal; }
|
.bu-runs-table .bu-run-message-cell { line-height: 1.45; white-space: normal; }
|
||||||
.bu-runs-table .bu-table-actions { min-width: 0; }
|
.bu-runs-table .bu-table-actions { min-width: 0; }
|
||||||
|
.bu-runs-table .bu-run-task-cell { overflow: hidden; }
|
||||||
|
.bu-runs-table .bu-run-task-cell strong, .bu-runs-table .bu-run-task-cell span { display: block; }
|
||||||
|
.bu-runs-table .bu-run-task-cell span { color: var(--bu-muted); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.bu-runs-table .bu-status { white-space: nowrap; }
|
||||||
.bu-status { border: 1px solid currentColor; border-radius: 999px; display: inline-block; font-size: 12px; font-weight: 700; padding: 3px 8px; text-transform: uppercase; }
|
.bu-status { border: 1px solid currentColor; border-radius: 999px; display: inline-block; font-size: 12px; font-weight: 700; padding: 3px 8px; text-transform: uppercase; }
|
||||||
.bu-status.success { background: rgba(8,122,85,.10); color: var(--bu-good); }
|
.bu-status.success { background: rgba(8,122,85,.10); color: var(--bu-good); }
|
||||||
.bu-status.failed, .bu-status.cancelled { background: rgba(180,35,47,.10); color: var(--bu-bad); }
|
.bu-status.failed, .bu-status.cancelled { background: rgba(180,35,47,.10); color: var(--bu-bad); }
|
||||||
|
|||||||
+12
-2
@@ -150,7 +150,17 @@
|
|||||||
const typeLabels = {share:'Freigaben',appdata:'Appdata',docker:'Docker',vm:'Virtuelle Maschinen',flash:'USB-Flash',local:'Lokal',smb:'SMB',nfs:'NFS',sftp:'SFTP'};
|
const typeLabels = {share:'Freigaben',appdata:'Appdata',docker:'Docker',vm:'Virtuelle Maschinen',flash:'USB-Flash',local:'Lokal',smb:'SMB',nfs:'NFS',sftp:'SFTP'};
|
||||||
const messageLabels = {'backup completed':'Backup abgeschlossen','restore completed':'Wiederherstellung abgeschlossen','check completed':'Prüfung abgeschlossen','prune completed':'Bereinigung abgeschlossen','daemon restarted while task was active':'Daemon wurde während eines aktiven Vorgangs neu gestartet'};
|
const messageLabels = {'backup completed':'Backup abgeschlossen','restore completed':'Wiederherstellung abgeschlossen','check completed':'Prüfung abgeschlossen','prune completed':'Bereinigung abgeschlossen','daemon restarted while task was active':'Daemon wurde während eines aktiven Vorgangs neu gestartet'};
|
||||||
const status = (value) => `<span class="bu-status ${esc(value)}" tabindex="0"${tipAttr('Aktueller oder endgültiger Status dieses Vorgangs.')}>${esc(statusLabels[value]||value)}</span>`;
|
const status = (value) => `<span class="bu-status ${esc(value)}" tabindex="0"${tipAttr('Aktueller oder endgültiger Status dieses Vorgangs.')}>${esc(statusLabels[value]||value)}</span>`;
|
||||||
const displayMessage = value => messageLabels[value] || translateUserMessage(value);
|
const displayMessage = value => {
|
||||||
|
const text=String(value||'');
|
||||||
|
if(text.includes('backup succeeded but retention failed')&&text.includes('repository is already locked')) return 'Backup erfolgreich. Die Aufbewahrung konnte wegen einer Repository-Sperre nicht ausgeführt werden. Unter Repositorys „Entsperren“ und danach „Bereinigen“ wählen.';
|
||||||
|
if(text.includes('repository is already locked')) return 'Repository ist gesperrt. Falls kein anderer Restic-Vorgang läuft, unter Repositorys „Entsperren“ wählen.';
|
||||||
|
return messageLabels[text] || translateUserMessage(text);
|
||||||
|
};
|
||||||
|
const runTarget = run => {
|
||||||
|
if(run.taskType==='backup') return state.config.jobs.find(job=>job.id===run.jobId)?.name || 'Unbekannter Job';
|
||||||
|
if(['check','prune'].includes(run.taskType)) return state.config.repositories.find(repo=>repo.id===run.jobId)?.name || 'Unbekanntes Repository';
|
||||||
|
return run.jobId || '';
|
||||||
|
};
|
||||||
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" type="button" data-action="${esc(action)}"${tipAttr(actionTooltip(action, label))}>${esc(label)}</button>`;
|
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" type="button" data-action="${esc(action)}"${tipAttr(actionTooltip(action, label))}>${esc(label)}</button>`;
|
||||||
const disabledButton = (label, help) => `<button class="bu-button" type="button" disabled${tipAttr(help)}>${esc(label)}</button>`;
|
const disabledButton = (label, help) => `<button class="bu-button" type="button" disabled${tipAttr(help)}>${esc(label)}</button>`;
|
||||||
const actionGroup = (buttons) => `<div class="bu-actions bu-table-actions">${buttons}</div>`;
|
const actionGroup = (buttons) => `<div class="bu-actions bu-table-actions">${buttons}</div>`;
|
||||||
@@ -513,7 +523,7 @@
|
|||||||
function runsTable(runs) {
|
function runsTable(runs) {
|
||||||
if(!runs.length) return '<div class="bu-empty">Keine Aktivitäten vorhanden.</div>';
|
if(!runs.length) return '<div class="bu-empty">Keine Aktivitäten vorhanden.</div>';
|
||||||
const showLiveLog=state.config?.settings?.showLiveLog===true;
|
const showLiveLog=state.config?.settings?.showLiveLog===true;
|
||||||
return `<div class="bu-table-scroll"><table class="bu-table bu-runs-table"><colgroup><col class="bu-run-task"><col class="bu-run-status"><col class="bu-run-created"><col class="bu-run-progress"><col class="bu-run-message"><col class="bu-run-actions"></colgroup><thead><tr><th>Aufgabe</th><th>Status</th><th>Erstellt</th><th>Fortschritt</th><th>Meldung</th><th></th></tr></thead><tbody>${runs.map(r=>{const controls=r.status==='running'?button('Pause',`pause-run:${r.id}`)+button('Abbrechen',`cancel-run:${r.id}`,'danger'):r.status==='paused'?button('Fortsetzen',`resume-run:${r.id}`,'primary')+button('Abbrechen',`cancel-run:${r.id}`,'danger'):r.status==='queued'?button('Abbrechen',`cancel-run:${r.id}`,'danger'):'';return `<tr><td class="bu-run-task-cell">${esc(taskLabels[r.taskType]||r.taskType)} ${esc(r.jobId||'')}</td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${progressView(r)}</td><td class="bu-run-message-cell">${esc(displayMessage(r.message))}</td><td>${controls?actionGroup(controls):''}</td></tr>${showLiveLog&&(r.liveLog||[]).length?`<tr class="bu-log-row"><td colspan="6">${liveLogView(r)}</td></tr>`:''}`;}).join('')}</tbody></table></div>`;
|
return `<div class="bu-table-scroll"><table class="bu-table bu-runs-table"><colgroup><col class="bu-run-task"><col class="bu-run-status"><col class="bu-run-created"><col class="bu-run-progress"><col class="bu-run-message"><col class="bu-run-actions"></colgroup><thead><tr><th>Aufgabe</th><th>Status</th><th>Erstellt</th><th>Fortschritt</th><th>Meldung</th><th></th></tr></thead><tbody>${runs.map(r=>{const controls=r.status==='running'?button('Pause',`pause-run:${r.id}`)+button('Abbrechen',`cancel-run:${r.id}`,'danger'):r.status==='paused'?button('Fortsetzen',`resume-run:${r.id}`,'primary')+button('Abbrechen',`cancel-run:${r.id}`,'danger'):r.status==='queued'?button('Abbrechen',`cancel-run:${r.id}`,'danger'):'';const target=runTarget(r);return `<tr><td class="bu-run-task-cell" title="${esc(target)}"><strong>${esc(taskLabels[r.taskType]||r.taskType)}</strong><span>${esc(target)}</span></td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${progressView(r)}</td><td class="bu-run-message-cell">${esc(displayMessage(r.message))}</td><td>${controls?actionGroup(controls):''}</td></tr>${showLiveLog&&(r.liveLog||[]).length?`<tr class="bu-log-row"><td colspan="6">${liveLogView(r)}</td></tr>`:''}`;}).join('')}</tbody></table></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
|
|||||||
Reference in New Issue
Block a user