SHA256
Compare commits
6
Commits
6bb6a38040
...
c82de902a8
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
c82de902a8 | ||
|
|
8d488f44ff | ||
|
|
168927453a | ||
|
|
2df603b32b | ||
|
|
535a9c9db0 | ||
|
|
863cd1fd3a |
@@ -8,6 +8,16 @@ Alle Datensätze werden **im Browser** verschlüsselt
|
|||||||
Der Server sieht ausschließlich Chiffretext – auch bei vollständiger
|
Der Server sieht ausschließlich Chiffretext – auch bei vollständiger
|
||||||
Serverkompromittierung bleiben die Inhalte ohne Passphrase vertraulich.
|
Serverkompromittierung bleiben die Inhalte ohne Passphrase vertraulich.
|
||||||
|
|
||||||
|
## Erster Start (UX)
|
||||||
|
|
||||||
|
- **Beim ersten Besuch** (Datenbank leer): Die App zeigt "Willkommen" und
|
||||||
|
fragt zweimal nach einer neuen Passphrase (mindestens 8 Zeichen).
|
||||||
|
Diese schützt ab sofort alle Datensätze – es gibt keine Zurücksetzung.
|
||||||
|
- **Bei weiteren Besuchen**: Die App bittet einmal um die bekannte
|
||||||
|
Passphrase ("Entsperren").
|
||||||
|
- Der Server speichert niemals die Passphrase selbst, nur verschlüsselte
|
||||||
|
Datensätze.
|
||||||
|
|
||||||
## Starten (HTTP; TLS über Reverse Proxy)
|
## Starten (HTTP; TLS über Reverse Proxy)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+45
-14
@@ -12,18 +12,47 @@
|
|||||||
|
|
||||||
<section id="flash" class="flash" aria-live="polite"></section>
|
<section id="flash" class="flash" aria-live="polite"></section>
|
||||||
|
|
||||||
<!-- unlock screen: passphrase stays in memory only -->
|
<!-- First visit: NO records exist yet → user chooses a new passphrase -->
|
||||||
|
<section id="setup" hidden>
|
||||||
|
<form id="setup-form" autocomplete="off">
|
||||||
|
<h2>Willkommen bei crushdb!</h2>
|
||||||
|
<p class="intro">
|
||||||
|
Du benutzt crushdb zum ersten Mal. Wähle jetzt eine Passphrase –
|
||||||
|
sie ist das Master-Passwort für alle deine Daten. Es gibt keine
|
||||||
|
Zurücksetzung, speichere sie sicher!
|
||||||
|
</p>
|
||||||
|
<label>Neue Passphrase
|
||||||
|
<input type="password" id="setup-pass1" autocomplete="new-password"
|
||||||
|
required minlength="8" placeholder="mindestens 8 Zeichen" />
|
||||||
|
</label>
|
||||||
|
<label>Passphrase wiederholen
|
||||||
|
<input type="password" id="setup-pass2" autocomplete="new-password"
|
||||||
|
required placeholder="gleiche Passphrase nochmal" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" id="setup-btn">Konto einrichten →</button>
|
||||||
|
<p id="setup-progress" class="hint" hidden>Schlüssel wird abgeleitet …</p>
|
||||||
|
<p class="hint">
|
||||||
|
Wichtig: Die Passphrase wird <strong>nicht am Server gespeichert</strong>.
|
||||||
|
Sie bleibt nur in deinem Browser-Arbeitsspeicher. Wer sie vergisst,
|
||||||
|
verliert Zugriff auf alle Daten (absichtlich so).
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Returning user: records exist but are undecryptable → enter passphrase -->
|
||||||
<section id="unlock">
|
<section id="unlock">
|
||||||
<form id="unlock-form" autocomplete="off">
|
<form id="unlock-form" autocomplete="off">
|
||||||
<label for="passphrase">Passphrase</label>
|
<h2>Entsperren</h2>
|
||||||
<input
|
<p class="intro">
|
||||||
type="password"
|
Du hast bereits verschlüsselte Datensätze hinterlegt.
|
||||||
id="passphrase"
|
Gib deine Passphrase ein, um sie zu lesen.
|
||||||
autocomplete="off"
|
</p>
|
||||||
required
|
<label>Passphrase
|
||||||
placeholder="Passphrase eingeben …"
|
<input type="password" id="passphrase" autocomplete="current-password"
|
||||||
/>
|
required placeholder="deine Passphrase …" />
|
||||||
<button type="submit">Entsperren</button>
|
</label>
|
||||||
|
<button type="submit" id="unlock-btn">Entsperren</button>
|
||||||
|
<p id="unlock-progress" class="hint" hidden>Schlüssel wird abgeleitet …</p>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Die Passphrase verlässt niemals deinen Browser. Sie wird per
|
Die Passphrase verlässt niemals deinen Browser. Sie wird per
|
||||||
Argon2id lokal abgeleitet und nur im Arbeitsspeicher gehalten.
|
Argon2id lokal abgeleitet und nur im Arbeitsspeicher gehalten.
|
||||||
@@ -63,11 +92,13 @@
|
|||||||
<label>Person B
|
<label>Person B
|
||||||
<input id="f-personb" type="text" required maxlength="120" />
|
<input id="f-personb" type="text" required maxlength="120" />
|
||||||
</label>
|
</label>
|
||||||
<label>Start (optional)
|
<label>Start (optional, beliebiger Text)
|
||||||
<input id="f-tsstart" type="datetime-local" />
|
<input id="f-tsstart" type="text" maxlength="120"
|
||||||
|
placeholder="z. B. 12.03.2026, Sommer 2025, unbekannt …" />
|
||||||
</label>
|
</label>
|
||||||
<label>Ende (optional)
|
<label>Ende (optional, beliebiger Text)
|
||||||
<input id="f-tsend" type="datetime-local" />
|
<input id="f-tsend" type="text" maxlength="120"
|
||||||
|
placeholder="z. B. laufend, unbekannt, März 2026 …" />
|
||||||
</label>
|
</label>
|
||||||
<label>Aktion
|
<label>Aktion
|
||||||
<input id="f-action" type="text" required maxlength="500" />
|
<input id="f-action" type="text" required maxlength="500" />
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
|
|||||||
}
|
}
|
||||||
#flash.error { color: var(--danger); }
|
#flash.error { color: var(--danger); }
|
||||||
|
|
||||||
#unlock {
|
#unlock, #setup {
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
margin: 3rem auto;
|
margin: 3rem auto;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -43,10 +43,13 @@ h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#unlock label { display: block; margin-bottom: 0.5rem; }
|
#unlock h2, #setup h2 { margin: 0 0 0.5rem; font-size: 1.15rem; }
|
||||||
#unlock input {
|
.intro { color: var(--muted); font-size: 0.9rem; margin: 0 0 1rem; }
|
||||||
|
|
||||||
|
#unlock label, #setup label { display: block; margin-bottom: 0.75rem; }
|
||||||
|
#unlock input, #setup input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 1rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
.hint { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; }
|
.hint { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; }
|
||||||
|
|
||||||
@@ -105,6 +108,10 @@ td:last-child, td button { white-space: nowrap; }
|
|||||||
td button + button { margin-left: 0.4rem; }
|
td button + button { margin-left: 0.4rem; }
|
||||||
|
|
||||||
/* modal */
|
/* modal */
|
||||||
|
/* NOTE: keep `hidden` winning over the flex display below, otherwise the
|
||||||
|
backdrop is permanently visible and blocks everything underneath. */
|
||||||
|
#editor-backdrop[hidden] { display: none; }
|
||||||
|
|
||||||
#editor-backdrop {
|
#editor-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
+128
-42
@@ -1,17 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* ui.js – browser UI logic for crushdb.
|
* ui.js – browser UI logic for crushdb.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Two distinct screens before the app opens:
|
||||||
* 1. User enters a passphrase → Argon2id derives the session key in
|
*
|
||||||
* memory (never sent anywhere, never persisted).
|
* 1. "setup" (first visit): No encrypted records exist on the server
|
||||||
* 2. Fetch ciphertext records from /api/records, decrypt each in
|
* yet. The user picks a NEW passphrase (typed twice to avoid
|
||||||
* memory; decrypt failures are surfaced as "wrong passphrase or
|
* typos). This passphrase then protects every future record.
|
||||||
* tampered record" without leaking details.
|
*
|
||||||
* 3. Create/edit records: form values are encrypted client-side
|
* 2. "unlock" (returning): encrypted records exist, but the user's
|
||||||
* before POST/PUT; the server only ever sees ciphertext.
|
* key isn't known to the browser (it never is – that's the whole
|
||||||
* 4. Delete requires the record's current version (optimistic lock).
|
* point of client-side encryption). The user types their
|
||||||
* 5. "Lock" wipes the passphrase field and releases decrypted rows so
|
* existing passphrase.
|
||||||
* nothing sensitive lingers in the DOM.
|
*
|
||||||
|
* Which screen to show is decided by a cheap server call:
|
||||||
|
* GET /api/records → empty array ⇒ setup, else unlock.
|
||||||
|
*
|
||||||
|
* The passphrase itself NEVER leaves the browser: Argon2id derives a
|
||||||
|
* 256-bit session key in memory, and that key exists only here.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +37,12 @@ function el(tag, attrs = {}, children = []) {
|
|||||||
for (const [k, v] of Object.entries(attrs)) {
|
for (const [k, v] of Object.entries(attrs)) {
|
||||||
if (k === "class") node.className = v;
|
if (k === "class") node.className = v;
|
||||||
else if (k === "text") node.textContent = v;
|
else if (k === "text") node.textContent = v;
|
||||||
|
else if (k.startsWith("on") && typeof v === "function") {
|
||||||
|
// Event handlers MUST be assigned as properties: setAttribute(k, fn)
|
||||||
|
// stringifies the function ("... => ...") and never binds a listener,
|
||||||
|
// which silently killed the Bearbeiten/Löschen buttons.
|
||||||
|
node[k] = v;
|
||||||
|
}
|
||||||
else node.setAttribute(k, v);
|
else node.setAttribute(k, v);
|
||||||
}
|
}
|
||||||
for (const child of children) node.appendChild(child);
|
for (const child of children) node.appendChild(child);
|
||||||
@@ -42,21 +53,24 @@ function flash(msg, isError = false) {
|
|||||||
const box = $("#flash");
|
const box = $("#flash");
|
||||||
box.textContent = msg;
|
box.textContent = msg;
|
||||||
box.className = isError ? "flash error" : "flash";
|
box.className = isError ? "flash error" : "flash";
|
||||||
setTimeout(() => { box.textContent = ""; }, 4000);
|
setTimeout(() => { box.textContent = ""; }, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- session state (memory only) ----------
|
// ---------- session state (memory only) ----------
|
||||||
|
|
||||||
let sessionKey = null; // Uint8Array(32) or null
|
let sessionKey = null; // Uint8Array(32) or null
|
||||||
|
let busy = false; // Argon2id takes a second; block double submits
|
||||||
|
|
||||||
|
// Salt for the session-key derivation. Fixed domain string, contains
|
||||||
|
// no secret; the per-record salt is separate (see crypto.js).
|
||||||
|
const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1");
|
||||||
|
|
||||||
function resetSession() {
|
function resetSession() {
|
||||||
wipeKey(sessionKey);
|
wipeKey(sessionKey);
|
||||||
sessionKey = null;
|
sessionKey = null;
|
||||||
$("#records").replaceChildren();
|
$("#records").replaceChildren();
|
||||||
$("#app").hidden = true;
|
$("#app").hidden = true;
|
||||||
$("#unlock").hidden = false;
|
location.reload(); // cheapest and safest way back to a clean state
|
||||||
$("#passphrase").value = "";
|
|
||||||
$("#passphrase").focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- API helpers ----------
|
// ---------- API helpers ----------
|
||||||
@@ -77,10 +91,10 @@ async function api(path, opts = {}) {
|
|||||||
|
|
||||||
// ---------- rendering ----------
|
// ---------- rendering ----------
|
||||||
|
|
||||||
function iso(ts) {
|
// Timestamps are free text now ("unknown", "Sommer 2025", …). Display
|
||||||
if (ts === null || ts === "") return "–";
|
// them verbatim; nothing to parse or format.
|
||||||
const d = new Date(ts);
|
function showTs(value) {
|
||||||
return isNaN(d) ? String(ts) : d.toLocaleString();
|
return (value === null || value === undefined || value === "") ? "–" : String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRow(record, payload) {
|
function renderRow(record, payload) {
|
||||||
@@ -88,8 +102,8 @@ function renderRow(record, payload) {
|
|||||||
const cells = [
|
const cells = [
|
||||||
payload.personA,
|
payload.personA,
|
||||||
payload.personB,
|
payload.personB,
|
||||||
iso(payload.tsStart),
|
showTs(payload.tsStart),
|
||||||
iso(payload.tsEnd),
|
showTs(payload.tsEnd),
|
||||||
payload.action,
|
payload.action,
|
||||||
];
|
];
|
||||||
for (const text of cells) {
|
for (const text of cells) {
|
||||||
@@ -136,6 +150,7 @@ async function reload() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$("#new-btn").disabled = false;
|
||||||
if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true);
|
if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +162,8 @@ function openEditor(record, payload) {
|
|||||||
$("#editor-title").textContent = "Datensatz bearbeiten";
|
$("#editor-title").textContent = "Datensatz bearbeiten";
|
||||||
$("#f-persona").value = payload.personA ?? "";
|
$("#f-persona").value = payload.personA ?? "";
|
||||||
$("#f-personb").value = payload.personB ?? "";
|
$("#f-personb").value = payload.personB ?? "";
|
||||||
$("#f-tsstart").value = payload.tsStart ? new Date(payload.tsStart).toISOString().slice(0, 16) : "";
|
$("#f-tsstart").value = payload.tsStart ?? "";
|
||||||
$("#f-tsend").value = payload.tsEnd ? new Date(payload.tsEnd).toISOString().slice(0, 16) : "";
|
$("#f-tsend").value = payload.tsEnd ?? "";
|
||||||
$("#f-action").value = payload.action ?? "";
|
$("#f-action").value = payload.action ?? "";
|
||||||
$("#editor-backdrop").hidden = false;
|
$("#editor-backdrop").hidden = false;
|
||||||
}
|
}
|
||||||
@@ -169,9 +184,9 @@ function closeEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tsOrNull(inputValue) {
|
function tsOrNull(inputValue) {
|
||||||
if (!inputValue) return null;
|
// Free-text field: trim to null or the plain string.
|
||||||
const d = new Date(inputValue);
|
const v = inputValue.trim();
|
||||||
return isNaN(d) ? null : d.toISOString();
|
return v === "" ? null : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- record operations ----------
|
// ---------- record operations ----------
|
||||||
@@ -206,7 +221,7 @@ async function saveRecord(ev) {
|
|||||||
}
|
}
|
||||||
closeEditor();
|
closeEditor();
|
||||||
await reload();
|
await reload();
|
||||||
flash(isEdit ? "Datensatz aktualisiert." : "Datensatz erstellt.");
|
flash(isEdit ? "Datensatz aktualisiert." : "Datensatz gespeichert.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
closeEditor();
|
closeEditor();
|
||||||
if (String(e).includes("409")) {
|
if (String(e).includes("409")) {
|
||||||
@@ -236,27 +251,45 @@ async function removeRecord(record) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- unlock / lock ----------
|
// ---------- first-time setup (create a NEW passphrase) ----------
|
||||||
|
|
||||||
async function unlock(ev) {
|
async function setup(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const passphrase = $("#passphrase").value;
|
if (busy) return;
|
||||||
if (!passphrase) { flash("Bitte Passphrase eingeben.", true); return; }
|
const p1 = $("#setup-pass1").value;
|
||||||
// Salt for key derivation: per-record salts are used for the AEAD;
|
const p2 = $("#setup-pass2").value;
|
||||||
// for the session key we use a fixed domain-salt so all records can
|
|
||||||
// be opened with one passphrase. It contains no secret.
|
if (p1 !== p2) { flash("Die beiden Passphrasen stimmen nicht überein.", true); return; }
|
||||||
const domainSalt = new TextEncoder().encode("crushdb://session-key-v1");
|
if (p1.length < 8) { flash("Bitte mindestens 8 Zeichen verwenden.", true); return; }
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
$("#setup-btn").disabled = true;
|
||||||
|
$("#setup-progress").hidden = false;
|
||||||
try {
|
try {
|
||||||
sessionKey = deriveKey(passphrase, domainSalt);
|
sessionKey = deriveKey(p1, DOMAIN_SALT);
|
||||||
$("#passphrase").value = "";
|
// Verify no records existed while we were deriving (TOCTOU-safe):
|
||||||
$("#unlock").hidden = true;
|
// if someone else created data in parallel, fall back to unlock.
|
||||||
|
const existing = await api("/api/records");
|
||||||
|
if (existing.length > 0) {
|
||||||
|
flash("Es existieren bereits Daten. Bitte entsperren statt neu einrichten.", true);
|
||||||
|
resetSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("#setup-pass1").value = "";
|
||||||
|
$("#setup-pass2").value = "";
|
||||||
|
$("#setup").hidden = true;
|
||||||
$("#app").hidden = false;
|
$("#app").hidden = false;
|
||||||
await reload();
|
await reload();
|
||||||
} catch (e) {
|
flash("Konto eingerichtet. Ab jetzt schützt deine Passphrase alle Daten.");
|
||||||
flash("Entsperren fehlgeschlagen: " + e.message, true);
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
$("#setup-btn").disabled = false;
|
||||||
|
$("#setup-progress").hidden = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- lock (wipe key, back to clean state) ----------
|
||||||
|
|
||||||
function lock() {
|
function lock() {
|
||||||
if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return;
|
if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return;
|
||||||
resetSession();
|
resetSession();
|
||||||
@@ -264,12 +297,65 @@ function lock() {
|
|||||||
|
|
||||||
// ---------- wire-up ----------
|
// ---------- wire-up ----------
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
$("#setup-form").addEventListener("submit", setup);
|
||||||
$("#unlock-form").addEventListener("submit", unlock);
|
$("#unlock-form").addEventListener("submit", unlock);
|
||||||
$("#lock-btn").addEventListener("click", lock);
|
$("#lock-btn").addEventListener("click", lock);
|
||||||
$("#new-btn").addEventListener("click", newRecord);
|
$("#new-btn").addEventListener("click", newRecord);
|
||||||
|
$("#new-btn").disabled = true; // enabled after reload() succeeds
|
||||||
$("#editor").addEventListener("submit", saveRecord);
|
$("#editor").addEventListener("submit", saveRecord);
|
||||||
$("#editor-cancel").addEventListener("click", closeEditor);
|
$("#editor-cancel").addEventListener("click", closeEditor);
|
||||||
window.addEventListener("beforeunload", () => wipeKey(sessionKey));
|
window.addEventListener("beforeunload", () => wipeKey(sessionKey));
|
||||||
$("#passphrase").focus();
|
|
||||||
|
// Decide which pre-screen to show: fresh install vs returning user.
|
||||||
|
try {
|
||||||
|
const raw = await api("/api/records");
|
||||||
|
const firstVisit = raw.length === 0;
|
||||||
|
$("#setup").hidden = !firstVisit;
|
||||||
|
$("#unlock").hidden = firstVisit;
|
||||||
|
if (firstVisit) $("#setup-pass1").focus();
|
||||||
|
else $("#passphrase").focus();
|
||||||
|
} catch (e) {
|
||||||
|
// Server unreachable → default to unlock screen; retry will tell more.
|
||||||
|
$("#setup").hidden = true;
|
||||||
|
$("#unlock").hidden = false;
|
||||||
|
flash("Server nicht erreichbar: " + e.message, true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- unlock (returning user) ----------
|
||||||
|
|
||||||
|
async function unlock(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
const passphrase = $("#passphrase").value;
|
||||||
|
if (!passphrase) { flash("Bitte Passphrase eingeben.", true); return; }
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
$("#unlock-btn").disabled = true;
|
||||||
|
$("#unlock-progress").hidden = false;
|
||||||
|
try {
|
||||||
|
sessionKey = deriveKey(passphrase, DOMAIN_SALT);
|
||||||
|
$("#passphrase").value = "";
|
||||||
|
$("#unlock").hidden = true;
|
||||||
|
$("#app").hidden = false;
|
||||||
|
await reload();
|
||||||
|
|
||||||
|
// If nothing decrypts, the passphrase is (almost certainly) wrong.
|
||||||
|
const tbody = $("#records tbody");
|
||||||
|
const failed = tbody.querySelectorAll("tr.row-error").length;
|
||||||
|
const total = tbody.children.length;
|
||||||
|
if (total > 0 && failed === total) {
|
||||||
|
// Everything undecryptable ⇒ wrong passphrase; go back cleanly.
|
||||||
|
flash("Entsperren fehlgeschlagen: falsche Passphrase?");
|
||||||
|
resetSession();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
flash("Entsperren fehlgeschlagen: " + e.message, true);
|
||||||
|
resetSession();
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
$("#unlock-btn").disabled = false;
|
||||||
|
$("#unlock-progress").hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user