Развёртывание на Debian 12/13: Docker, nginx, работа за обратным прокси
- приложение перенесено в public/, инструкции в docs/ - Dockerfile (nginx без root-прав), docker-compose.yml с ограничениями - deploy/split-trainer.conf: CSP и заголовки безопасности, no-cache по ETag, /healthz, реальный IP из X-Forwarded-For только от частных сетей, редиректы без внутреннего хоста - deploy/install-debian.sh: установка и обновление без Docker - docs/DEPLOY.md: требования, установка, примеры прокси (nginx, Caddy, Traefik), подпуть Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
42b90a056c
commit
e897a6c5eb
+341
@@ -0,0 +1,341 @@
|
||||
/* Разбор раскладки Vial (.vil) и планировщик маршрутов по слоям.
|
||||
Модуль не знает про DOM: язык/символы ему передаёт приложение через charOf(lang, code, shift). */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
/* ---------- Коды клавиш QMK -> KeyboardEvent.code ---------- */
|
||||
const NAMES = {
|
||||
ENTER: 'Enter', ENT: 'Enter', ESCAPE: 'Escape', ESC: 'Escape', BSPACE: 'Backspace', BSPC: 'Backspace', TAB: 'Tab',
|
||||
SPACE: 'Space', SPC: 'Space', MINUS: 'Minus', MINS: 'Minus', EQUAL: 'Equal', EQL: 'Equal',
|
||||
LBRACKET: 'BracketLeft', LBRC: 'BracketLeft', RBRACKET: 'BracketRight', RBRC: 'BracketRight',
|
||||
BSLASH: 'Backslash', BSLS: 'Backslash', NONUS_HASH: 'Backslash', NUHS: 'Backslash',
|
||||
SCOLON: 'Semicolon', SCLN: 'Semicolon', QUOTE: 'Quote', QUOT: 'Quote', GRAVE: 'Backquote', GRV: 'Backquote',
|
||||
COMMA: 'Comma', COMM: 'Comma', DOT: 'Period', SLASH: 'Slash', SLSH: 'Slash', CAPSLOCK: 'CapsLock', CAPS: 'CapsLock',
|
||||
PSCREEN: 'PrintScreen', PSCR: 'PrintScreen', SCROLLLOCK: 'ScrollLock', SLCK: 'ScrollLock', PAUSE: 'Pause', PAUS: 'Pause', BRK: 'Pause',
|
||||
INSERT: 'Insert', INS: 'Insert', HOME: 'Home', PGUP: 'PageUp', DELETE: 'Delete', DEL: 'Delete', END: 'End',
|
||||
PGDOWN: 'PageDown', PGDN: 'PageDown', RIGHT: 'ArrowRight', RGHT: 'ArrowRight', LEFT: 'ArrowLeft', DOWN: 'ArrowDown', UP: 'ArrowUp',
|
||||
APPLICATION: 'ContextMenu', APP: 'ContextMenu', NUMLOCK: 'NumLock', NLCK: 'NumLock',
|
||||
LCTRL: 'ControlLeft', LCTL: 'ControlLeft', LSHIFT: 'ShiftLeft', LSFT: 'ShiftLeft', LALT: 'AltLeft', LGUI: 'MetaLeft', LWIN: 'MetaLeft', LCMD: 'MetaLeft',
|
||||
RCTRL: 'ControlRight', RCTL: 'ControlRight', RSHIFT: 'ShiftRight', RSFT: 'ShiftRight', RALT: 'AltRight', RGUI: 'MetaRight', RWIN: 'MetaRight', RCMD: 'MetaRight',
|
||||
KP_DOT: 'NumpadDecimal', PDOT: 'NumpadDecimal', KP_PLUS: 'NumpadAdd', PPLS: 'NumpadAdd', KP_MINUS: 'NumpadSubtract', PMNS: 'NumpadSubtract',
|
||||
KP_ASTERISK: 'NumpadMultiply', PAST: 'NumpadMultiply', KP_SLASH: 'NumpadDivide', PSLS: 'NumpadDivide',
|
||||
KP_ENTER: 'NumpadEnter', PENT: 'NumpadEnter', KP_EQUAL: 'NumpadEqual', PEQL: 'NumpadEqual'
|
||||
};
|
||||
const MEDIA = { MPRV: '⏮', MNXT: '⏭', MPLY: '⏯', MSTP: '⏹', MUTE: '🔇', VOLU: 'Vol+', VOLD: 'Vol-', BRIU: '☀+', BRID: '☀-',
|
||||
MEDIA_PREV_TRACK: '⏮', MEDIA_NEXT_TRACK: '⏭', MEDIA_PLAY_PAUSE: '⏯', AUDIO_MUTE: '🔇', AUDIO_VOL_UP: 'Vol+', AUDIO_VOL_DOWN: 'Vol-',
|
||||
BRIGHTNESS_UP: '☀+', BRIGHTNESS_DOWN: '☀-' };
|
||||
|
||||
function codeOfName(n) {
|
||||
if (/^[A-Z]$/.test(n)) return 'Key' + n;
|
||||
if (/^[0-9]$/.test(n)) return 'Digit' + n;
|
||||
if (/^F([1-9]|1\d|2[0-4])$/.test(n)) return n;
|
||||
if (NAMES[n]) return NAMES[n];
|
||||
const kp = /^(?:KP_|P)(\d)$/.exec(n);
|
||||
return kp ? 'Numpad' + kp[1] : null;
|
||||
}
|
||||
|
||||
const SHIFT = 1, CTRL = 2, ALT = 4, GUI = 8;
|
||||
const WRAP = { LSFT: SHIFT, RSFT: SHIFT, S: SHIFT, LCTL: CTRL, RCTL: CTRL, C: CTRL, LALT: ALT, RALT: ALT, A: ALT, ALGR: ALT,
|
||||
LGUI: GUI, RGUI: GUI, G: GUI, LCMD: GUI, LWIN: GUI };
|
||||
|
||||
function keyP(code, raw) {
|
||||
return { t: 'key', code, raw, shift: false, other: false, holdShift: false, holdLayer: null,
|
||||
isMod: /^(Shift|Control|Alt|Meta)/.test(code), shiftSrc: /^Shift/.test(code) };
|
||||
}
|
||||
|
||||
/* Разбирает одну клавишу в удобную структуру. null = клавиши в этой позиции нет (-1). */
|
||||
function parseKC(raw) {
|
||||
if (raw === -1 || raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
if (s === 'KC_TRNS' || s === 'KC_TRANSPARENT' || s === '_______') return { t: 'trns', raw: s };
|
||||
if (s === 'KC_NO' || s === 'XXXXXXX' || s === '') return { t: 'no', raw: s };
|
||||
let m;
|
||||
if ((m = /^KC_(\w+)$/.exec(s))) {
|
||||
const code = codeOfName(m[1]);
|
||||
if (code) return keyP(code, s);
|
||||
return { t: 'other', raw: s, label: MEDIA[m[1]] || null };
|
||||
}
|
||||
if ((m = /^(MO|TG|TO|OSL|TT|DF|PDF)\((\d+)\)$/.exec(s))) return { t: m[1] === 'PDF' ? 'df' : m[1].toLowerCase(), n: +m[2], raw: s };
|
||||
if ((m = /^LT(\d+)\((.+)\)$/.exec(s)) || (m = /^LT\((\d+),\s*(.+)\)$/.exec(s))) {
|
||||
const inner = parseKC(m[2]);
|
||||
if (inner && inner.t === 'key') { inner.holdLayer = +m[1]; inner.raw = s; return inner; }
|
||||
return { t: 'other', raw: s };
|
||||
}
|
||||
if ((m = /^OSM\((.+)\)$/.exec(s))) return { t: 'osm', shift: /SFT|SHIFT/.test(m[1]), raw: s };
|
||||
if ((m = /^MT\((.+?),\s*(.+)\)$/.exec(s)) || (m = /^(?:[LR]?(?:SFT|CTL|ALT|GUI|WIN|CMD|OPT)|LCAG|ALL|MEH|HYPR|SGUI|LSG|LAG|RSG|RAG)_T\((.+)\)$/.exec(s))) {
|
||||
const isMT = s.startsWith('MT(');
|
||||
const inner = parseKC(isMT ? m[2] : m[1]);
|
||||
if (inner && inner.t === 'key') {
|
||||
inner.holdShift = isMT ? /SFT|SHIFT/.test(m[1]) : /^[LR]?SFT_T|^SGUI|^ALL|^MEH|^HYPR|^LSG|^RSG/.test(s);
|
||||
inner.raw = s; return inner;
|
||||
}
|
||||
return { t: 'other', raw: s };
|
||||
}
|
||||
if ((m = /^(\w+)\((.+)\)$/.exec(s)) && WRAP[m[1]] !== undefined) {
|
||||
const inner = parseKC(m[2]);
|
||||
if (inner && inner.t === 'key') {
|
||||
const mods = WRAP[m[1]];
|
||||
if (mods & SHIFT) inner.shift = true;
|
||||
if (mods & (CTRL | ALT | GUI)) inner.other = true;
|
||||
inner.raw = s; return inner;
|
||||
}
|
||||
}
|
||||
return { t: 'other', raw: s };
|
||||
}
|
||||
|
||||
/* ---------- Файл .vil ---------- */
|
||||
function parseFile(text) {
|
||||
let d;
|
||||
try { d = JSON.parse(text); } catch (e) { return { error: 'Файл не похож на JSON. Нужен файл .vil из Vial (меню File → Save current layout).' }; }
|
||||
if (!d || !Array.isArray(d.layout) || !d.layout.length || !Array.isArray(d.layout[0]))
|
||||
return { error: 'В файле нет раскладки (поле "layout"). Это точно файл .vil из Vial?' };
|
||||
const rows = d.layout[0].length;
|
||||
const cols = Math.max(...d.layout[0].map(r => (Array.isArray(r) ? r.length : 0)));
|
||||
if (!rows || !cols || rows > 20 || cols > 20) return { error: 'Не удалось определить размер матрицы клавиатуры.' };
|
||||
const layers = d.layout.map(layer => {
|
||||
const out = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const k = layer[r] ? layer[r][c] : -1;
|
||||
row.push(k === -1 || k == null ? -1 : String(k));
|
||||
}
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return { layout: { layers, rows, cols }, meta: { vialProtocol: d.vial_protocol || null } };
|
||||
}
|
||||
|
||||
/* ---------- Модель клавиатуры ---------- */
|
||||
const bit = n => 1 << n;
|
||||
|
||||
function createModel(layout, opts = {}) {
|
||||
const { layers, rows: R, cols: C } = layout, L = layers.length;
|
||||
const parsed = layers.map(layer => { const a = []; for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) a.push(parseKC(layer[r][c])); return a; });
|
||||
const present = Array.from({ length: R * C }, (_, pos) => parsed.some(l => l[pos] !== null));
|
||||
const split = opts.split !== false && R % 2 === 0 && R >= 2;
|
||||
const cells = new Array(R * C).fill(null), grid = {};
|
||||
const rowHoles = r => { let n = 0; for (let c = 0; c < C; c++) if (!present[r * C + c]) n++; return n; };
|
||||
const letterOf = d => (d <= 1 ? 'i' : d === 2 ? 'm' : d === 3 ? 'r' : 'p');
|
||||
|
||||
if (split) {
|
||||
const H = R / 2;
|
||||
for (const hand of ['L', 'R']) grid[hand] = Array.from({ length: H }, () => Array(C).fill(-1));
|
||||
// Где у половинки внутренний край (у зазора): большие пальцы лежат под внутренними колонками.
|
||||
// Если по ним не понять - считаем, что у обеих половинок внутренний край у старших столбцов матрицы (как у Silakka54).
|
||||
const innerHigh = {};
|
||||
for (const hand of ['L', 'R']) {
|
||||
const r = (hand === 'L' ? 0 : H) + H - 1, cols = [];
|
||||
for (let c = 0; c < C; c++) if (present[r * C + c]) cols.push(c);
|
||||
innerHigh[hand] = cols.length && cols.length < C ? cols.reduce((s, c) => s + c, 0) / cols.length > (C - 1) / 2 : true;
|
||||
}
|
||||
for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) {
|
||||
const pos = r * C + c; if (!present[pos]) continue;
|
||||
const hand = r < H ? 'L' : 'R', rr = r % H, ih = innerHigh[hand];
|
||||
const d = ih ? C - 1 - c : c; // расстояние до внутреннего края
|
||||
const x = hand === 'L' ? C - 1 - d : d; // положение слева направо на экране
|
||||
const thumb = rr === H - 1 && rowHoles(r) > 0;
|
||||
grid[hand][rr][x] = pos;
|
||||
cells[pos] = { hand, rr, x, thumb, finger: thumb ? 'th' : hand.toLowerCase() + letterOf(d) };
|
||||
}
|
||||
} else {
|
||||
grid.A = Array.from({ length: R }, () => Array(C).fill(-1));
|
||||
for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) {
|
||||
const pos = r * C + c; if (!present[pos]) continue;
|
||||
const hand = c < C / 2 ? 'L' : 'R', d = hand === 'L' ? Math.floor(C / 2) - 1 - c : c - Math.floor(C / 2);
|
||||
const thumb = r === R - 1 && rowHoles(r) > 0;
|
||||
grid.A[r][c] = pos;
|
||||
cells[pos] = { hand, rr: r, x: c, thumb, finger: thumb ? 'th' : hand.toLowerCase() + letterOf(d) };
|
||||
}
|
||||
}
|
||||
const positions = []; present.forEach((p, i) => { if (p) positions.push(i); });
|
||||
|
||||
const model = { layout, L, R, C, parsed, present, positions, split, cells, grid };
|
||||
model.used = usedLayers(model);
|
||||
return model;
|
||||
}
|
||||
|
||||
function usedLayers(model) {
|
||||
const out = [];
|
||||
for (let l = 0; l < model.L; l++) {
|
||||
if (l === 0 || model.parsed[l].some(p => p && p.t !== 'trns' && p.t !== 'no')) out.push(l);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const isDigitCode = c => /^(Digit\d|Numpad\d)$/.test(c);
|
||||
|
||||
function guessNumLayer(model) {
|
||||
let best = null, bestN = 3;
|
||||
for (const l of model.used) {
|
||||
if (l === 0) continue;
|
||||
const n = model.parsed[l].filter(p => p && p.t === 'key' && isDigitCode(p.code)).length;
|
||||
if (n > bestN) { best = l; bestN = n; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function guessName(model, l) {
|
||||
if (l === 0) return 'Базовый';
|
||||
const ps = model.parsed[l].filter(Boolean);
|
||||
const digits = ps.filter(p => p.t === 'key' && isDigitCode(p.code)).length;
|
||||
const fn = ps.filter(p => p.t === 'key' && /^(F\d+|Arrow.*|Home|End|Page.*)$/.test(p.code)).length;
|
||||
if (digits >= 6) return 'Цифры';
|
||||
if (fn >= 6) return 'Fn / навигация';
|
||||
return 'Слой ' + l;
|
||||
}
|
||||
|
||||
/* ---------- Разрешение клавиши с учётом активных слоёв ---------- */
|
||||
function resolve(model, A, pos) {
|
||||
for (let l = model.L - 1; l >= 0; l--) {
|
||||
if (!(A & bit(l)) && l !== 0) continue;
|
||||
const p = model.parsed[l][pos];
|
||||
if (p === null) return null;
|
||||
if (p.t !== 'trns') return { p, layer: l };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- Планировщик ---------- */
|
||||
function actionLabel(p) {
|
||||
return p.t === 'key' ? 'LT' + p.holdLayer : p.t.toUpperCase();
|
||||
}
|
||||
|
||||
function ctxFor(model, o) {
|
||||
return { model, lang: o.lang, charOf: o.charOf, prefer: o.prefer == null ? null : o.prefer, maxDepth: o.maxDepth == null ? 2 : o.maxDepth,
|
||||
cache: new Map(), actCache: new Map(), shiftCache: new Map() };
|
||||
}
|
||||
|
||||
function actionsUnder(ctx, A) {
|
||||
if (ctx.actCache.has(A)) return ctx.actCache.get(A);
|
||||
const seen = new Set(), out = [];
|
||||
for (const pos of ctx.model.positions) {
|
||||
const r = resolve(ctx.model, A, pos); if (!r) continue;
|
||||
const p = r.p; let kind = null, n = null;
|
||||
if (p.t === 'mo' || p.t === 'tt') { kind = 'hold'; n = p.n; }
|
||||
else if (p.t === 'tg') { kind = 'toggle'; n = p.n; }
|
||||
else if (p.t === 'to') { kind = 'to'; n = p.n; }
|
||||
else if (p.t === 'osl') { kind = 'osl'; n = p.n; }
|
||||
else if (p.t === 'key' && p.holdLayer != null) { kind = 'hold'; n = p.holdLayer; }
|
||||
if (!kind || n >= ctx.model.L) continue;
|
||||
if (kind === 'toggle' && n === 0) continue;
|
||||
const k = kind + n; if (seen.has(k)) continue; seen.add(k);
|
||||
out.push({ kind, n, pos, label: actionLabel(p) + (p.t === 'key' ? '' : '(' + n + ')') });
|
||||
}
|
||||
ctx.actCache.set(A, out); return out;
|
||||
}
|
||||
|
||||
function shiftSources(ctx, A) {
|
||||
if (ctx.shiftCache.has(A)) return ctx.shiftCache.get(A);
|
||||
const out = [];
|
||||
for (const pos of ctx.model.positions) {
|
||||
const r = resolve(ctx.model, A, pos); if (!r) continue;
|
||||
const p = r.p;
|
||||
if ((p.t === 'key' && (p.shiftSrc || p.holdShift)) || (p.t === 'osm' && p.shift)) out.push(pos);
|
||||
}
|
||||
ctx.shiftCache.set(A, out); return out;
|
||||
}
|
||||
|
||||
function typeOptions(ctx, A, ch) {
|
||||
const m = ctx.model, opts = [];
|
||||
for (const pos of m.positions) {
|
||||
const r = resolve(m, A, pos);
|
||||
if (!r || r.p.t !== 'key' || r.p.other || r.p.isMod) continue;
|
||||
const p = r.p;
|
||||
if (ctx.charOf(ctx.lang, p.code, p.shift) === ch) { opts.push({ pos, layer: r.layer, extra: 0, shiftPos: null }); continue; }
|
||||
if (!p.shift && ctx.charOf(ctx.lang, p.code, true) === ch) {
|
||||
let best = null;
|
||||
for (const sp of shiftSources(ctx, A)) {
|
||||
if (sp === pos) continue;
|
||||
const cost = m.cells[sp].hand === m.cells[pos].hand ? 1.5 : 1;
|
||||
if (!best || cost < best.cost) best = { pos: sp, cost };
|
||||
}
|
||||
if (best) opts.push({ pos, layer: r.layer, extra: best.cost, shiftPos: best.pos });
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/* Все способы напечатать ch из состояния постоянных слоёв P0: по одному лучшему на каждое итоговое состояние P'. */
|
||||
function search(ctx, P0, ch) {
|
||||
const ck = P0 + ':' + ch;
|
||||
if (ctx.cache.has(ck)) return ctx.cache.get(ck);
|
||||
let frontier = [{ P: P0, H: 0, actions: [] }];
|
||||
const seen = new Set([P0 + ':0']), cands = [];
|
||||
for (let depth = 0; depth <= ctx.maxDepth && frontier.length; depth++) {
|
||||
const next = [];
|
||||
for (const st of frontier) {
|
||||
const A = 1 | st.P | st.H;
|
||||
for (const o of typeOptions(ctx, A, ch)) cands.push({ P: st.P, A, actions: st.actions, opt: o, cost: st.actions.length + o.extra });
|
||||
if (depth === ctx.maxDepth) continue;
|
||||
for (const act of actionsUnder(ctx, A)) {
|
||||
let P = st.P, H = st.H;
|
||||
if (act.kind === 'hold' || act.kind === 'osl') { if (A & bit(act.n)) continue; H |= bit(act.n); }
|
||||
else if (act.kind === 'toggle') P ^= bit(act.n);
|
||||
else P = act.n ? bit(act.n) : 0;
|
||||
const k = P + ':' + H; if (seen.has(k)) continue; seen.add(k);
|
||||
next.push({ P, H, actions: st.actions.concat(act) });
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
let use = cands;
|
||||
if (ctx.prefer != null) { const pf = cands.filter(c => c.opt.layer === ctx.prefer); if (pf.length) use = pf; }
|
||||
const best = new Map();
|
||||
for (const c of use) {
|
||||
const b = best.get(c.P);
|
||||
if (!b || c.cost < b.cost || (c.cost === b.cost && c.actions.length < b.actions.length)) best.set(c.P, c);
|
||||
}
|
||||
const res = [...best.values()];
|
||||
ctx.cache.set(ck, res); return res;
|
||||
}
|
||||
|
||||
/* Планирует весь текст: динамическое программирование по состоянию постоянных слоёв (минимум действий). */
|
||||
function plan(model, text, o) {
|
||||
const ctx = ctxFor(model, o);
|
||||
const chars = [...text], P0 = o.startP || 0;
|
||||
let layer = new Map([[P0, { cost: 0 }]]);
|
||||
const steps = [];
|
||||
for (const ch of chars) {
|
||||
const nxt = new Map();
|
||||
for (const [P, st] of layer) {
|
||||
const res = search(ctx, P, ch);
|
||||
if (!res.length) {
|
||||
const total = st.cost + 0.001;
|
||||
if (!nxt.has(P) || total < nxt.get(P).cost) nxt.set(P, { cost: total, from: P, cand: null });
|
||||
continue;
|
||||
}
|
||||
for (const c of res) {
|
||||
const total = st.cost + c.cost;
|
||||
if (!nxt.has(c.P) || total < nxt.get(c.P).cost) nxt.set(c.P, { cost: total, from: P, cand: c });
|
||||
}
|
||||
}
|
||||
steps.push(nxt); layer = nxt;
|
||||
}
|
||||
let P = null, bestCost = Infinity;
|
||||
for (const [k, v] of layer) if (v.cost < bestCost) { bestCost = v.cost; P = k; }
|
||||
const entries = new Array(chars.length);
|
||||
for (let i = chars.length - 1; i >= 0; i--) {
|
||||
const e = steps[i].get(P), c = e.cand;
|
||||
entries[i] = c
|
||||
? { ch: chars[i], pos: c.opt.pos, shiftPos: c.opt.shiftPos, layer: c.opt.layer, actions: c.actions, A: c.A, Pbefore: e.from, Pafter: P, sw: c.actions.length > 0 }
|
||||
: { ch: chars[i], pos: null, shiftPos: null, layer: 0, actions: [], A: 1 | e.from, Pbefore: e.from, Pafter: P, sw: false, unreachable: true };
|
||||
P = e.from;
|
||||
}
|
||||
const missing = new Set(entries.filter(e => e.unreachable).map(e => e.ch));
|
||||
return {
|
||||
entries, missing,
|
||||
switches: entries.filter(e => e.actions.some(a => a.kind === 'toggle' || a.kind === 'to')).length,
|
||||
holds: entries.filter(e => e.actions.some(a => a.kind === 'hold' || a.kind === 'osl')).length
|
||||
};
|
||||
}
|
||||
|
||||
/* Какие из символов вообще нельзя напечатать на этой клавиатуре (из состояния «базовый слой»). */
|
||||
function unreachable(model, o, chars) {
|
||||
const ctx = ctxFor(model, { ...o, prefer: null, maxDepth: 2 });
|
||||
return [...chars].filter(ch => !search(ctx, 0, ch).length);
|
||||
}
|
||||
|
||||
window.Vial = { parseFile, parseKC, createModel, resolve, plan, unreachable, guessNumLayer, guessName, usedLayers, bit, isDigitCode };
|
||||
})();
|
||||
Reference in New Issue
Block a user