Добавить статистику и очистку аудита; доработать UI расшифровки.

- Страница /admin/stats: MinIO, pending/consumed, загрузки, audit-метрики
- Кнопка «Очистить» в аудите с подтверждением
- Красивые callout’ы и ошибки пароля; пустой пароль не тратит попытку
- Убрать автоопределение языка; «Расшифруй» в RU UI
This commit is contained in:
2026-07-18 23:50:44 +03:00
parent 8c8dbd2348
commit a881570938
17 changed files with 1007 additions and 258 deletions
+325 -11
View File
@@ -243,17 +243,69 @@ html[data-theme="dark"] .icon-btn:hover {
.lede { color: var(--muted); margin: 0 0 1.5rem; max-width: 42rem; }
.composer, .success-panel, .result-panel { display: grid; gap: 0.9rem; }
.result-panel > .success-callout {
justify-self: stretch;
}
.success-panel {
justify-items: stretch;
text-align: left;
}
.success-panel > h2,
.success-panel > .warn {
.success-panel > h2 {
text-align: center;
margin: 0;
}
.success-panel > .btn.ghost {
justify-self: center;
}
.success-callout {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin: 0;
padding: 0.85rem 1.1rem;
text-align: left;
border-radius: 14px;
border: 1px solid rgba(232, 168, 56, 0.35);
background: linear-gradient(160deg, rgba(232, 168, 56, 0.14), rgba(13, 20, 32, 0.28));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
html[data-theme="light"] .success-callout {
background: linear-gradient(160deg, rgba(232, 168, 56, 0.14), rgba(255, 255, 255, 0.9));
border-color: rgba(196, 130, 20, 0.28);
}
.success-callout-icon {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
display: grid;
place-items: center;
border-radius: 11px;
background: rgba(232, 168, 56, 0.18);
color: var(--warn);
font-size: 1rem;
}
html[data-theme="light"] .success-callout-icon {
background: rgba(232, 168, 56, 0.16);
}
.success-callout-body {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.success-callout-body strong {
font-size: 0.95rem;
font-weight: 650;
color: var(--text);
letter-spacing: 0.01em;
}
.success-callout-body span {
font-size: 0.82rem;
font-weight: 500;
color: var(--muted);
line-height: 1.35;
}
label { display: block; font-size: 0.86rem; color: var(--muted); margin-bottom: 0.35rem; }
@@ -334,16 +386,19 @@ input, select, textarea, .code-input {
.lang-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
align-items: center;
gap: 0.55rem 0.75rem;
}
.lang-current {
display: grid;
gap: 0.35rem;
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem 0.7rem;
}
.lang-label {
font-size: 0.86rem;
color: var(--muted);
line-height: 1.2;
}
.lang-chip {
appearance: none;
@@ -368,11 +423,6 @@ input, select, textarea, .code-input {
font-size: 0.7rem;
color: var(--muted);
}
.lang-hint {
margin: -0.35rem 0 0;
font-size: 0.8rem;
color: var(--warn);
}
.lang-modal-panel {
width: min(480px, 100%);
}
@@ -637,6 +687,74 @@ html[data-theme="light"] .brand-mark {
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.form-alert {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
padding: 0.85rem 1.1rem;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.form-alert.danger {
border-color: rgba(214, 69, 85, 0.4);
background: linear-gradient(160deg, rgba(214, 69, 85, 0.14), rgba(13, 20, 32, 0.28));
}
html[data-theme="light"] .form-alert.danger {
background: linear-gradient(160deg, rgba(214, 69, 85, 0.1), rgba(255, 255, 255, 0.92));
border-color: rgba(214, 69, 85, 0.28);
}
.form-alert-icon {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
display: grid;
place-items: center;
border-radius: 11px;
font-size: 1rem;
}
.form-alert.danger .form-alert-icon {
background: rgba(214, 69, 85, 0.16);
color: var(--danger);
}
.form-alert-body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
min-width: 0;
}
.form-alert-body strong {
font-size: 0.95rem;
font-weight: 650;
color: var(--text);
}
.form-alert-body > span:not(.form-alert-attempts) {
font-size: 0.82rem;
font-weight: 500;
color: var(--muted);
line-height: 1.35;
}
.form-alert-attempts {
margin-top: 0.2rem;
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 650;
letter-spacing: 0.01em;
color: var(--warn);
background: rgba(232, 168, 56, 0.16);
border: 1px solid rgba(232, 168, 56, 0.3);
}
html[data-theme="light"] .form-alert-attempts {
background: rgba(232, 168, 56, 0.14);
border-color: rgba(196, 130, 20, 0.28);
}
.copy-row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; }
.site-footer {
@@ -698,6 +816,49 @@ html[data-theme="light"] .footer-pillars li {
font-weight: 500;
line-height: 1.25;
}
@media (max-width: 720px) {
.site-footer {
max-width: 100%;
padding: 0.35rem 0.6rem 1.5rem;
}
.footer-pillars {
flex-wrap: nowrap;
gap: 0.3rem;
width: 100%;
}
.footer-pillars li {
flex: 1 1 0;
max-width: none;
min-width: 0;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.2rem;
padding: 0.4rem 0.3rem;
border-radius: 9px;
text-align: center;
}
.footer-pillars i {
font-size: 0.72rem;
width: auto;
}
.footer-pillars span {
align-items: center;
gap: 0.05rem;
}
.footer-pillars strong {
font-size: 0.62rem;
line-height: 1.15;
}
.footer-pillars small {
font-size: 0.55rem;
line-height: 1.2;
}
.footer-copy {
margin-top: 0.65rem;
font-size: 0.72rem;
}
}
.footer-copy {
margin: 0.85rem 0 0;
font-size: 0.8rem;
@@ -1061,6 +1222,142 @@ html[data-theme="light"] .admin-nav {
padding: 0 0.25rem;
margin: 0 0 0.85rem;
}
.stats-page {
display: grid;
gap: 1.1rem;
}
.stats-updated {
margin: 0;
padding: 0 0.15rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
@media (max-width: 1100px) {
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 620px) {
.stats-grid { grid-template-columns: 1fr; }
}
.stat-card {
display: grid;
gap: 0.35rem;
padding: 1rem 1.05rem;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow);
min-width: 0;
}
.stat-card.accent {
border-color: rgba(61, 214, 198, 0.35);
background: linear-gradient(160deg, rgba(61, 214, 198, 0.12), rgba(13, 20, 32, 0.35));
}
html[data-theme="light"] .stat-card.accent {
background: linear-gradient(160deg, rgba(47, 111, 237, 0.1), rgba(255, 255, 255, 0.92));
border-color: rgba(47, 111, 237, 0.22);
}
.stat-card-head {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--muted);
}
.stat-card-head i {
color: var(--accent-2);
width: 1rem;
text-align: center;
}
.stat-card-head h2 {
margin: 0;
font-size: 0.82rem;
font-weight: 600;
line-height: 1.25;
}
.stat-value {
margin: 0.15rem 0 0;
font-size: 1.65rem;
font-weight: 700;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
color: var(--text);
}
.stat-sub {
margin: 0;
font-size: 0.78rem;
color: var(--muted);
line-height: 1.35;
}
.stat-num {
font-family: var(--font-mono);
font-weight: 650;
color: var(--accent-2);
}
.stat-error {
margin: 0.25rem 0 0;
font-size: 0.75rem;
}
.stats-sections {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 0.85rem;
}
@media (max-width: 900px) {
.stats-sections { grid-template-columns: 1fr; }
}
.stats-section {
padding: 1rem 1.05rem;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--panel);
}
.stats-section h3 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
font-weight: 650;
}
.stats-table-wrap { overflow: auto; }
.stats-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.stats-table th,
.stats-table td {
text-align: left;
padding: 0.5rem 0.55rem;
border-bottom: 1px solid var(--line);
}
.stats-table th {
color: var(--muted);
font-weight: 600;
font-size: 0.78rem;
}
.stats-total-row td {
font-weight: 650;
border-bottom: 0;
}
.stats-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.stats-list li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
font-size: 0.9rem;
color: var(--muted);
}
.stats-list strong {
color: var(--text);
}
.audit-page {
display: grid;
gap: 0.85rem;
@@ -1098,13 +1395,30 @@ html[data-theme="light"] .admin-nav {
height: 42px;
align-self: end;
}
.audit-meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.6rem 1rem;
padding: 0 0.2rem;
}
.audit-meta {
display: flex;
gap: 0.4rem;
align-items: baseline;
color: var(--muted);
font-size: 0.85rem;
padding: 0 0.2rem;
}
.audit-clear-form {
margin: 0;
}
.audit-clear-form .btn.tiny {
min-width: 6.5rem;
}
.audit-clear-form .btn[disabled] {
opacity: 0.45;
pointer-events: none;
}
.audit-count {
color: var(--accent-2);
+17
View File
@@ -2,6 +2,7 @@
const form = document.getElementById("audit-filter-form");
const typeSelect = document.getElementById("audit-event-type");
const perPage = document.getElementById("audit-per-page");
const clearForm = document.getElementById("audit-clear-form");
function resetToFirstPageAndSubmit() {
if (!form) return;
@@ -13,6 +14,22 @@
typeSelect?.addEventListener("change", resetToFirstPageAndSubmit);
perPage?.addEventListener("change", resetToFirstPageAndSubmit);
clearForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const t = (k, fallback) => window.WrappedI18n?.t(k) || fallback;
const ok = await window.WrappedModal.confirm({
title: t("admin.audit.clearConfirmTitle", "Clear audit"),
message: t(
"admin.audit.clearConfirm",
"Delete all audit events? This cannot be undone."
),
confirmLabel: t("admin.audit.clearConfirmOk", "Clear all"),
cancelLabel: t("modal.confirm.cancel", "Cancel"),
danger: true,
});
if (ok) clearForm.submit();
});
document.querySelectorAll(".audit-time[data-ts]").forEach((el) => {
const raw = el.getAttribute("data-ts");
if (!raw) return;
+2 -57
View File
@@ -3,16 +3,12 @@
const files = [];
let settings = null;
let captchaWidgetId = null;
let langManual = false;
let detectTimer = null;
const els = {
text: document.getElementById("payload-text"),
lang: document.getElementById("text-language"),
langChip: document.getElementById("lang-chip"),
langChipText: document.getElementById("lang-chip-text"),
langChange: document.getElementById("lang-change"),
langHint: document.getElementById("lang-hint"),
langModal: document.getElementById("lang-modal"),
langGrid: document.getElementById("lang-grid"),
highlight: document.getElementById("code-highlight"),
@@ -57,11 +53,10 @@
return t(`lang.${id}`) || id;
}
function setLanguage(id, { manual = false, silent = false } = {}) {
function setLanguage(id, { silent = false } = {}) {
const lang = window.WrappedHighlight.languages().includes(id) ? id : "plaintext";
els.lang.value = lang;
if (els.langChipText) els.langChipText.textContent = langLabel(lang);
if (manual) langManual = true;
if (!silent) refreshHighlight();
}
@@ -77,49 +72,10 @@
function syncEditorHeight() {
if (!els.text || !els.highlight) return;
els.highlight.parentElement.style.height = "auto";
// Keep highlight pre matching textarea scroll height
const pre = els.highlight.parentElement;
pre.style.minHeight = `${Math.max(220, els.text.scrollHeight)}px`;
}
function updateLangHint(detection) {
if (!els.langHint) return;
if (langManual || !els.text.value.trim()) {
els.langHint.classList.add("hidden");
els.langHint.textContent = "";
return;
}
if (detection.confidence >= 0.6) {
els.langHint.classList.add("hidden");
els.langHint.textContent = "";
return;
}
const suggestions = (detection.suggestions || [])
.filter((l) => l !== detection.lang)
.slice(0, 3)
.map(langLabel);
els.langHint.textContent = suggestions.length
? t("create.languageUnsure", { suggestions: suggestions.join(", ") })
: t("create.languageUnsurePlain");
els.langHint.classList.remove("hidden");
}
function autoDetectLanguage() {
if (langManual) {
refreshHighlight();
return;
}
const detection = window.WrappedHighlight.detectLanguage(els.text.value);
setLanguage(detection.lang, { silent: true });
refreshHighlight();
updateLangHint(detection);
}
function scheduleDetect() {
clearTimeout(detectTimer);
detectTimer = setTimeout(autoDetectLanguage, 220);
}
function openLangModal() {
if (!els.langModal || !els.langGrid) return;
els.langGrid.innerHTML = "";
@@ -129,8 +85,7 @@
btn.className = "lang-option" + (id === els.lang.value ? " active" : "");
btn.textContent = langLabel(id);
btn.addEventListener("click", () => {
setLanguage(id, { manual: true });
updateLangHint({ confidence: 1, suggestions: [] });
setLanguage(id);
closeLangModal();
});
els.langGrid.appendChild(btn);
@@ -283,20 +238,11 @@
if (pasted.length) {
e.preventDefault();
addFiles(pasted);
return;
}
// Text paste into textarea triggers input; detect after paste event
if (document.activeElement === els.text) {
setTimeout(() => {
langManual = false;
autoDetectLanguage();
}, 0);
}
});
els.text.addEventListener("input", () => {
refreshHighlight();
scheduleDetect();
});
els.text.addEventListener("scroll", () => {
const pre = els.highlight.parentElement;
@@ -305,7 +251,6 @@
});
els.langChip?.addEventListener("click", openLangModal);
els.langChange?.addEventListener("click", openLangModal);
els.langModal?.addEventListener("click", (e) => {
if (e.target.closest("[data-lang-close]")) closeLangModal();
});
-115
View File
@@ -15,120 +15,6 @@
"css",
];
function tryParseJson(text) {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
}
/**
* @returns {{ lang: string, confidence: number, suggestions: string[] }}
*/
function detectLanguage(text) {
const raw = (text || "").trim();
if (!raw) {
return { lang: "plaintext", confidence: 0, suggestions: LANGUAGES };
}
const scores = Object.fromEntries(LANGUAGES.map((l) => [l, 0]));
if (
(raw.startsWith("{") || raw.startsWith("[")) &&
tryParseJson(raw)
) {
scores.json += 10;
} else if (/^\s*[{[]/.test(raw) && /["']?\w+["']?\s*:/.test(raw)) {
scores.json += 4;
}
if (
/^---\s*$/m.test(raw) ||
(/^[\w.-]+\s*:\s+\S+/m.test(raw) &&
!tryParseJson(raw) &&
!raw.startsWith("<"))
) {
scores.yaml += 5;
}
if (/^\s*-\s+\w+/m.test(raw) && /:\s/.test(raw)) scores.yaml += 2;
if (
/\b(def|class|import|from|async def)\b/.test(raw) ||
/^\s*@\w+/m.test(raw)
) {
scores.python += 5;
}
if (/print\(|__name__|self\./.test(raw)) scores.python += 2;
if (
/\b(function|const|let|var|=>|console\.)\b/.test(raw) ||
/\brequire\s*\(/.test(raw)
) {
scores.javascript += 4;
}
if (/\b(interface|type)\s+\w+|:\s*\w+(\[\])?\s*[=;]/.test(raw)) {
scores.typescript += 5;
}
if (/\bimport\s+type\b|\bas\s+const\b/.test(raw)) scores.typescript += 2;
if (/\b(package|func|fmt\.|:=)\b/.test(raw)) scores.go += 5;
if (/\b(fn |let mut|impl |pub fn|cargo)\b/.test(raw)) scores.rust += 5;
if (
/\b(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE|FROM|WHERE)\b/i.test(raw)
) {
scores.sql += 6;
}
if (/^\s*#!.*\b(bash|sh|zsh)\b/m.test(raw) || /^\s*(sudo |export |echo )/m.test(raw)) {
scores.bash += 4;
}
if (/<\/?[a-zA-Z][\w:-]*[\s>]/.test(raw) || raw.startsWith("<!DOCTYPE")) {
scores.html += 6;
}
if (/[{;]\s*$/m.test(raw) && /[.#]?[\w-]+\s*\{/.test(raw) && !tryParseJson(raw)) {
scores.css += 5;
}
if (/^#{1,6}\s+\S+/m.test(raw) || /\[[^\]]+\]\([^)]+\)/.test(raw)) {
scores.markdown += 4;
}
const ranked = Object.entries(scores)
.filter(([lang]) => lang !== "plaintext")
.sort((a, b) => b[1] - a[1]);
const best = ranked[0];
const second = ranked[1];
if (!best || best[1] < 3) {
return {
lang: "plaintext",
confidence: 0.2,
suggestions: ranked
.filter(([, s]) => s > 0)
.slice(0, 4)
.map(([l]) => l)
.concat(["plaintext"]),
};
}
const confidence =
best[1] >= 8
? 0.95
: best[1] >= 5
? 0.75
: second && best[1] - second[1] <= 1
? 0.45
: 0.6;
const suggestions = ranked
.filter(([, s]) => s > 0)
.slice(0, 5)
.map(([l]) => l);
if (!suggestions.includes("plaintext")) suggestions.push("plaintext");
return { lang: best[0], confidence, suggestions };
}
function hljsLang(lang) {
const map = {
plaintext: "plaintext",
@@ -182,7 +68,6 @@
}
window.WrappedHighlight = {
detectLanguage,
highlightElement,
syncThemeStylesheet,
languages,
+94 -20
View File
@@ -22,11 +22,8 @@
"create.title": "Wrap. Send. Vanish.",
"create.lede": "Wrap text, images or files into a one-time token. Encryption happens in your browser — the server never sees plaintext.",
"create.language": "Highlight language",
"create.languageChange": "Change type",
"create.languagePickTitle": "Text type",
"create.languagePickHint": "Choose how the text should be highlighted.",
"create.languageUnsure": "Not sure — maybe: {suggestions}. Or change type.",
"create.languageUnsurePlain": "Could not detect type — change type if needed.",
"create.text": "Text",
"create.textPlaceholder": "Paste secrets, configs, notes…",
"create.drop": "Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)",
@@ -39,7 +36,8 @@
"create.working": "Encrypting and uploading…",
"create.openToken": "Open a token",
"create.successTitle": "Token issued",
"create.successWarn": "One-time unwrap. After someone opens it, ciphertext is destroyed on the server.",
"create.successWarnTitle": "One-time unwrap",
"create.successWarnHint": "After opening, ciphertext is deleted on the server.",
"create.shareLink": "Share link",
"create.token": "Wrapped token",
"create.another": "Create another",
@@ -79,17 +77,23 @@
"unwrap.password": "Password (if set)",
"unwrap.submit": "Unwrap",
"unwrap.working": "Decrypting…",
"unwrap.destroyed": "Server copy destroyed. Preview lives only in this browser session.",
"unwrap.destroyedTitle": "Server copy destroyed",
"unwrap.destroyedHint": "Preview lives only in this browser session.",
"unwrap.needKey": "Missing encryption key. Open the full share link (with #key) or paste the full wrapped token.",
"unwrap.badPassword": "Wrong password.",
"unwrap.badPasswordRemaining": "Wrong password. Attempts left: {n} of {max}.",
"unwrap.passwordLocked": "Too many wrong passwords. This wrap has been destroyed.",
"unwrap.badPassword": "Wrong password",
"unwrap.badPasswordHint": "Check the password and try again.",
"unwrap.passwordRequired": "Password required",
"unwrap.passwordRequiredHint": "This wrap is password-protected.",
"unwrap.attemptsLeft": "{n} of {max} attempts left",
"unwrap.passwordLocked": "Too many wrong passwords",
"unwrap.passwordLockedHint": "This wrap has been destroyed.",
"unwrap.unavailable": "Unavailable (already used, expired, or invalid).",
"common.copy": "Copy",
"common.copied": "Copied",
"common.download": "Download",
"common.error": "Something went wrong.",
"common.working": "Working…",
"admin.nav.stats": "Stats",
"admin.nav.settings": "Settings",
"admin.nav.audit": "Audit",
"admin.nav.danger": "Danger",
@@ -97,6 +101,33 @@
"admin.nav.logout": "Logout",
"admin.brand.tagline": "Admin console",
"admin.settings.title": "Settings",
"admin.stats.title": "Statistics",
"admin.stats.updated": "Updated",
"admin.stats.minioTitle": "MinIO (pending ciphertext)",
"admin.stats.objects": "object(s)",
"admin.stats.dbPending": "DB pending",
"admin.stats.unwrappedTitle": "Unwrapped (all time)",
"admin.stats.auditOk": "audit ok",
"admin.stats.last24h": "24h",
"admin.stats.pendingTitle": "Not unwrapped",
"admin.stats.items": "item(s)",
"admin.stats.uploadedTitle": "Uploaded (all time)",
"admin.stats.wrapsSection": "Wraps by status",
"admin.stats.extraSection": "More",
"admin.stats.col.status": "Status",
"admin.stats.col.count": "Count",
"admin.stats.col.size": "Size",
"admin.stats.col.items": "Items",
"admin.stats.status.pending": "pending",
"admin.stats.status.consumed": "consumed",
"admin.stats.status.expired": "expired",
"admin.stats.total": "Total",
"admin.stats.withPassword": "With password",
"admin.stats.created24h": "Created (24h)",
"admin.stats.created7d": "Created (7d)",
"admin.stats.createsOk": "Creates ok (audit)",
"admin.stats.createsFail": "Creates fail (audit)",
"admin.stats.unwrapsFail": "Unwrap fails (audit)",
"admin.audit.title": "Audit",
"admin.tab.limits": "Limits",
"admin.tab.rate": "Rate limits",
@@ -166,6 +197,12 @@
"admin.audit.filter": "Filter",
"admin.audit.allEvents": "All events",
"admin.audit.perPage": "Per page",
"admin.audit.clear": "Clear",
"admin.audit.clearConfirmTitle": "Clear audit",
"admin.audit.clearConfirm": "Delete all audit events? This cannot be undone.",
"admin.audit.clearConfirmOk": "Clear all",
"admin.audit.clearedPrefix": "Cleared",
"admin.audit.clearedSuffix": "audit event(s).",
"admin.audit.shown": "events shown",
"admin.audit.of": "of",
"admin.audit.events": "events",
@@ -202,11 +239,8 @@
"create.title": "Упакуй. Отправь. Исчезни.",
"create.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.",
"create.language": "Язык подсветки",
"create.languageChange": "Изменить тип",
"create.languagePickTitle": "Тип текста",
"create.languagePickHint": "Выберите, как подсвечивать текст.",
"create.languageUnsure": "Не уверен — возможно: {suggestions}. Или измените тип.",
"create.languageUnsurePlain": "Не удалось определить тип — при необходимости измените вручную.",
"create.text": "Текст",
"create.textPlaceholder": "Вставь секреты, конфиги, заметки…",
"create.drop": "Перетащи файлы, выбери или вставь скриншот из буфера (⌘V / Ctrl+V)",
@@ -219,7 +253,8 @@
"create.working": "Шифрование и загрузка…",
"create.openToken": "Открыть токен",
"create.successTitle": "Токен выдан",
"create.successWarn": "Unwrap один раз. После открытия ciphertext удаляется на сервере.",
"create.successWarnTitle": "Расшифруй один раз",
"create.successWarnHint": "После открытия ciphertext удаляется на сервере.",
"create.shareLink": "Ссылка",
"create.token": "Wrapped-токен",
"create.another": "Создать ещё",
@@ -251,7 +286,7 @@
"relative.inHours": "через {n} ч",
"relative.inDays": "через {n} дн",
"relative.soon": "скоро",
"unwrap.eyebrow": "Unwrap",
"unwrap.eyebrow": "Расшифруй",
"unwrap.title": "Открыть один раз",
"unwrap.lede": "Вставь wrapped-токен или открой ссылку. Ciphertext скачивается один раз и удаляется на сервере.",
"unwrap.token": "Wrapped-токен или ID",
@@ -259,17 +294,23 @@
"unwrap.password": "Пароль (если задан)",
"unwrap.submit": "Расшифровать",
"unwrap.working": "Расшифровка…",
"unwrap.destroyed": "Копия на сервере уничтожена. Превью только в этой сессии браузера.",
"unwrap.destroyedTitle": "Копия на сервере уничтожена",
"unwrap.destroyedHint": "Превью только в этой сессии браузера.",
"unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.",
"unwrap.badPassword": "Неверный пароль.",
"unwrap.badPasswordRemaining": "Неверный пароль. Осталось попыток: {n} из {max}.",
"unwrap.passwordLocked": "Слишком много неверных паролей. Этот wrap уничтожен.",
"unwrap.badPassword": "Неверный пароль",
"unwrap.badPasswordHint": "Проверьте пароль и попробуйте снова.",
"unwrap.passwordRequired": "Нужен пароль",
"unwrap.passwordRequiredHint": "Этот wrap защищён паролем.",
"unwrap.attemptsLeft": "Осталось попыток: {n} из {max}",
"unwrap.passwordLocked": "Слишком много неверных паролей",
"unwrap.passwordLockedHint": "Этот wrap уничтожен.",
"unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).",
"common.copy": "Копировать",
"common.copied": "Скопировано",
"common.download": "Скачать",
"common.error": "Что-то пошло не так.",
"common.working": "Подождите…",
"admin.nav.stats": "Статистика",
"admin.nav.settings": "Настройки",
"admin.nav.audit": "Аудит",
"admin.nav.danger": "Опасная зона",
@@ -277,6 +318,33 @@
"admin.nav.logout": "Выйти",
"admin.brand.tagline": "Консоль администратора",
"admin.settings.title": "Настройки",
"admin.stats.title": "Статистика",
"admin.stats.updated": "Обновлено",
"admin.stats.minioTitle": "MinIO (нерасшифрованный ciphertext)",
"admin.stats.objects": "объект(ов)",
"admin.stats.dbPending": "в БД pending",
"admin.stats.unwrappedTitle": "Расшифровано (за всё время)",
"admin.stats.auditOk": "audit ok",
"admin.stats.last24h": "за 24ч",
"admin.stats.pendingTitle": "Не расшифровано",
"admin.stats.items": "элемент(ов)",
"admin.stats.uploadedTitle": "Загружено (за всё время)",
"admin.stats.wrapsSection": "Wraps по статусу",
"admin.stats.extraSection": "Ещё",
"admin.stats.col.status": "Статус",
"admin.stats.col.count": "Кол-во",
"admin.stats.col.size": "Размер",
"admin.stats.col.items": "Элементы",
"admin.stats.status.pending": "pending",
"admin.stats.status.consumed": "consumed",
"admin.stats.status.expired": "expired",
"admin.stats.total": "Всего",
"admin.stats.withPassword": "С паролем",
"admin.stats.created24h": "Создано (24ч)",
"admin.stats.created7d": "Создано (7д)",
"admin.stats.createsOk": "Создания ok (audit)",
"admin.stats.createsFail": "Создания fail (audit)",
"admin.stats.unwrapsFail": "Ошибки расшифровки (audit)",
"admin.audit.title": "Аудит",
"admin.tab.limits": "Лимиты",
"admin.tab.rate": "Rate limits",
@@ -302,15 +370,15 @@
"admin.limits.auditRetention": "Хранение аудита (дни)",
"admin.rate.title": "Rate limits (на IP / минуту)",
"admin.rate.create": "Создание",
"admin.rate.unwrap": "Unwrap",
"admin.rate.unwrap": "Расшифровка",
"admin.rate.adminLogin": "Вход в админку",
"admin.mime.title": "MIME allowlist",
"admin.mime.hint": "Один шаблон на строку. Точные типы и wildcards вроде image/*.",
"admin.mime.examples": "Примеры / рекомендуемые значения",
"admin.passwordMode.title": "Режим пароля",
"admin.passwordMode.client_only": "Пароль также оборачивает ciphertext в браузере. Сервер хранит Argon2id-хеш и отклоняет неверный пароль до одноразового unwrap (опечатка не сжигает пакет).",
"admin.passwordMode.client_only": "Пароль также оборачивает ciphertext в браузере. Сервер хранит Argon2id-хеш и отклоняет неверный пароль до одноразовой расшифровки (опечатка не сжигает пакет).",
"admin.passwordMode.server_gate": "Сервер хранит Argon2id-хеш и отдаёт ciphertext только после верного пароля. Ciphertext по-прежнему шифруется на клиенте тем же паролем.",
"admin.password.maxAttempts": "Неверных вводов пароля (unwrap)",
"admin.password.maxAttempts": "Неверных вводов пароля (расшифровка)",
"admin.password.maxAttemptsHint": "После стольких неверных паролей wrap уничтожается. По умолчанию: 3.",
"admin.captcha.title": "CAPTCHA",
"admin.captcha.provider": "Провайдер",
@@ -346,6 +414,12 @@
"admin.audit.filter": "Фильтр",
"admin.audit.allEvents": "Все события",
"admin.audit.perPage": "На странице",
"admin.audit.clear": "Очистить",
"admin.audit.clearConfirmTitle": "Очистить аудит",
"admin.audit.clearConfirm": "Удалить все события аудита? Это нельзя отменить.",
"admin.audit.clearConfirmOk": "Очистить всё",
"admin.audit.clearedPrefix": "Удалено",
"admin.audit.clearedSuffix": "событий аудита.",
"admin.audit.shown": "событий показано",
"admin.audit.of": "из",
"admin.audit.events": "событий",
+73 -19
View File
@@ -1,5 +1,5 @@
(() => {
const t = (k) => window.WrappedI18n.t(k);
const t = (k, vars) => window.WrappedI18n.t(k, vars);
let settings = null;
const els = {
@@ -7,19 +7,60 @@
password: document.getElementById("unwrap-password"),
btn: document.getElementById("unwrap-btn"),
error: document.getElementById("form-error"),
errorTitle: document.getElementById("form-error-title"),
errorHint: document.getElementById("form-error-hint"),
errorAttempts: document.getElementById("form-error-attempts"),
form: document.getElementById("unwrap-form"),
result: document.getElementById("result-panel"),
items: document.getElementById("items"),
captchaSlot: document.getElementById("captcha-slot"),
};
function showError(msg) {
els.error.textContent = msg;
function showError(title, hint, attempts) {
if (!els.error) return;
if (els.errorTitle) els.errorTitle.textContent = title || "";
if (els.errorHint) {
if (hint) {
els.errorHint.textContent = hint;
els.errorHint.classList.remove("hidden");
} else {
els.errorHint.textContent = "";
els.errorHint.classList.add("hidden");
}
}
if (els.errorAttempts) {
if (attempts) {
els.errorAttempts.textContent = attempts;
els.errorAttempts.classList.remove("hidden");
} else {
els.errorAttempts.textContent = "";
els.errorAttempts.classList.add("hidden");
}
}
// Fallback if structured nodes missing
if (!els.errorTitle) els.error.textContent = [title, hint, attempts].filter(Boolean).join(" ");
els.error.classList.remove("hidden");
}
function clearError() {
if (!els.error) return;
els.error.classList.add("hidden");
if (els.errorTitle) els.errorTitle.textContent = "";
if (els.errorHint) {
els.errorHint.textContent = "";
els.errorHint.classList.add("hidden");
}
if (els.errorAttempts) {
els.errorAttempts.textContent = "";
els.errorAttempts.classList.add("hidden");
}
}
function attemptsLabel(detail) {
const n = Number(detail?.attempts_remaining);
const max = Number(detail?.attempts_max);
if (!Number.isFinite(n) || !Number.isFinite(max)) return "";
return t("unwrap.attemptsLeft", { n, max });
}
function loadCaptcha() {
@@ -151,24 +192,33 @@
const detail = err.detail;
if (detail && typeof detail === "object") {
if (detail.code === "password_locked") {
showError(t("unwrap.passwordLocked"));
showError(
t("unwrap.passwordLocked"),
t("unwrap.passwordLockedHint")
);
return;
}
if (detail.code === "password_required") {
showError(
t("unwrap.passwordRequired"),
t("unwrap.passwordRequiredHint"),
attemptsLabel(detail)
);
els.password?.focus();
return;
}
if (detail.code === "bad_password") {
const n = Number(detail.attempts_remaining);
const max = Number(detail.attempts_max);
if (Number.isFinite(n)) {
showError(
t("unwrap.badPasswordRemaining", {
n,
max: Number.isFinite(max) ? max : n,
})
);
return;
}
showError(
t("unwrap.badPassword"),
t("unwrap.badPasswordHint"),
attemptsLabel(detail)
);
els.password?.focus();
els.password?.select?.();
return;
}
}
showError(t("unwrap.badPassword"));
showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint"));
return;
}
if (!resp.ok) {
@@ -185,9 +235,12 @@
els.password.value || null
);
} catch (err) {
if (err.message === "bad_password" || err.message === "password_required") {
// Should be rare now (server gates first); keep UX clear.
showError(t("unwrap.badPassword"));
if (err.message === "password_required") {
showError(t("unwrap.passwordRequired"), t("unwrap.passwordRequiredHint"));
return;
}
if (err.message === "bad_password") {
showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint"));
return;
}
showError(t("common.error"));
@@ -218,3 +271,4 @@
init().catch(() => showError(t("common.error")));
})();