Улучшить unwrap, UI и админку; обновить документацию.
- Неверный пароль не сжигает wrap сразу: Argon2-гейт до consume и лимит попыток (по умолчанию 3) в настройках - Подсветка синтаксиса, автоопределение языка, спиннеры, размеры файлов, пагинация audit - make push (git, Ctrl-D) и актуальный README
This commit is contained in:
+313
-5
@@ -243,6 +243,17 @@ 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; }
|
||||
.success-panel {
|
||||
justify-items: stretch;
|
||||
text-align: left;
|
||||
}
|
||||
.success-panel > h2,
|
||||
.success-panel > .warn {
|
||||
text-align: center;
|
||||
}
|
||||
.success-panel > .btn.ghost {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
label { display: block; font-size: 0.86rem; color: var(--muted); margin-bottom: 0.35rem; }
|
||||
|
||||
@@ -261,6 +272,250 @@ input, select, textarea, .code-input {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-highlight {
|
||||
margin: 0;
|
||||
min-height: 220px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
overflow: auto;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
tab-size: 2;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.code-highlight code {
|
||||
font: inherit;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
display: block;
|
||||
white-space: inherit;
|
||||
word-break: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.code-editor .code-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-height: 220px;
|
||||
resize: vertical;
|
||||
line-height: 1.45;
|
||||
tab-size: 2;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: var(--text);
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
z-index: 1;
|
||||
}
|
||||
.code-editor .code-input::placeholder {
|
||||
color: var(--muted);
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* Show highlighted tokens under transparent textarea */
|
||||
.code-highlight .hljs,
|
||||
.code-highlight code.hljs {
|
||||
background: transparent !important;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lang-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.55rem 0.75rem;
|
||||
}
|
||||
.lang-current {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.lang-label {
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.lang-chip {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.lang-chip:hover {
|
||||
border-color: var(--accent-2);
|
||||
}
|
||||
.lang-chip i {
|
||||
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%);
|
||||
}
|
||||
.lang-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.lang-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.lang-option {
|
||||
appearance: none;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.lang-option:hover {
|
||||
border-color: var(--accent-2);
|
||||
background: rgba(59, 126, 240, 0.08);
|
||||
}
|
||||
.lang-option.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(61, 214, 198, 0.12);
|
||||
}
|
||||
|
||||
.btn.tiny {
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 9px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expires-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0.35rem 0 0.15rem;
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: center;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(110, 168, 255, 0.22);
|
||||
background: linear-gradient(160deg, rgba(110, 168, 255, 0.1), rgba(13, 20, 32, 0.28));
|
||||
}
|
||||
html[data-theme="light"] .expires-meta {
|
||||
background: linear-gradient(160deg, rgba(47, 111, 237, 0.1), rgba(255, 255, 255, 0.85));
|
||||
border-color: rgba(47, 111, 237, 0.2);
|
||||
}
|
||||
.expires-main {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.expires-rel {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-2);
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(61, 214, 198, 0.12);
|
||||
}
|
||||
html[data-theme="light"] .expires-rel {
|
||||
background: rgba(47, 111, 237, 0.1);
|
||||
}
|
||||
|
||||
.busy-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(8, 14, 24, 0.45);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.busy-overlay.hidden { display: none; }
|
||||
html[data-theme="light"] .busy-overlay {
|
||||
background: rgba(20, 35, 58, 0.28);
|
||||
}
|
||||
body.busy-open { overflow: hidden; }
|
||||
.busy-card {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 1.4rem 1.6rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
min-width: min(260px, 90vw);
|
||||
}
|
||||
.busy-logo img {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
animation: busy-spin 1.1s linear infinite;
|
||||
filter: drop-shadow(0 4px 12px rgba(61, 214, 198, 0.35));
|
||||
}
|
||||
@keyframes busy-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.busy-text {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.item-card-head > div {
|
||||
min-width: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn.download-btn {
|
||||
flex-shrink: 0;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 9px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.download-btn i {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
min-height: 220px;
|
||||
resize: vertical;
|
||||
@@ -782,6 +1037,16 @@ html[data-theme="light"] .admin-nav {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.radio-block small { display: block; color: var(--muted); margin-top: 0.25rem; }
|
||||
.admin-field-spaced {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1.15rem;
|
||||
max-width: 280px;
|
||||
}
|
||||
.admin-field-spaced .hint {
|
||||
margin: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.helper { border: 1px solid var(--line); border-radius: 10px; padding: 0.6rem 0.8rem; }
|
||||
.helper-body ol { padding-left: 1.2rem; color: var(--muted); }
|
||||
.hint { color: var(--muted); font-size: 0.9rem; }
|
||||
@@ -958,14 +1223,15 @@ html[data-theme="light"] .admin-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pagination .btn {
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
min-width: 7rem;
|
||||
.pagination-nav {
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
min-width: 6.5rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.pagination .btn[aria-disabled="true"],
|
||||
.pagination .btn[disabled] {
|
||||
@@ -974,6 +1240,48 @@ html[data-theme="light"] .admin-nav {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.pagination-pages {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.pagination-page {
|
||||
min-width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
padding: 0 0.45rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
.pagination-page:hover {
|
||||
border-color: var(--accent-2);
|
||||
background: rgba(59, 126, 240, 0.08);
|
||||
}
|
||||
.pagination-page.is-current {
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, var(--btn-primary-from), var(--btn-primary-mid) 45%, var(--btn-primary-to));
|
||||
color: var(--btn-primary-fg);
|
||||
box-shadow: 0 4px 12px var(--btn-primary-shadow);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pagination-ellipsis {
|
||||
min-width: 1.4rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
.pagination-status {
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
(() => {
|
||||
const form = document.getElementById("audit-filter-form");
|
||||
const typeSelect = document.getElementById("audit-event-type");
|
||||
if (form && typeSelect) {
|
||||
typeSelect.addEventListener("change", () => form.requestSubmit());
|
||||
const perPage = document.getElementById("audit-per-page");
|
||||
|
||||
function resetToFirstPageAndSubmit() {
|
||||
if (!form) return;
|
||||
const pageInput = form.querySelector('input[name="page"]');
|
||||
if (pageInput) pageInput.value = "1";
|
||||
form.requestSubmit();
|
||||
}
|
||||
|
||||
// Human-friendly local timestamps
|
||||
typeSelect?.addEventListener("change", resetToFirstPageAndSubmit);
|
||||
perPage?.addEventListener("change", resetToFirstPageAndSubmit);
|
||||
|
||||
document.querySelectorAll(".audit-time[data-ts]").forEach((el) => {
|
||||
const raw = el.getAttribute("data-ts");
|
||||
if (!raw) return;
|
||||
|
||||
+156
-15
@@ -1,12 +1,22 @@
|
||||
(() => {
|
||||
const t = (k) => window.WrappedI18n.t(k);
|
||||
const t = (k, vars) => window.WrappedI18n.t(k, vars);
|
||||
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"),
|
||||
editor: document.getElementById("code-editor"),
|
||||
ttl: document.getElementById("ttl-seconds"),
|
||||
password: document.getElementById("password"),
|
||||
generatePassword: document.getElementById("generate-password"),
|
||||
@@ -43,11 +53,103 @@
|
||||
});
|
||||
}
|
||||
|
||||
function langLabel(id) {
|
||||
return t(`lang.${id}`) || id;
|
||||
}
|
||||
|
||||
function setLanguage(id, { manual = false, 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();
|
||||
}
|
||||
|
||||
function refreshHighlight() {
|
||||
window.WrappedHighlight.highlightElement(
|
||||
els.highlight,
|
||||
els.text.value,
|
||||
els.lang.value || "plaintext"
|
||||
);
|
||||
syncEditorHeight();
|
||||
}
|
||||
|
||||
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 = "";
|
||||
for (const id of window.WrappedHighlight.languages()) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "lang-option" + (id === els.lang.value ? " active" : "");
|
||||
btn.textContent = langLabel(id);
|
||||
btn.addEventListener("click", () => {
|
||||
setLanguage(id, { manual: true });
|
||||
updateLangHint({ confidence: 1, suggestions: [] });
|
||||
closeLangModal();
|
||||
});
|
||||
els.langGrid.appendChild(btn);
|
||||
}
|
||||
els.langModal.classList.remove("hidden");
|
||||
document.body.classList.add("modal-open");
|
||||
}
|
||||
|
||||
function closeLangModal() {
|
||||
if (!els.langModal) return;
|
||||
els.langModal.classList.add("hidden");
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
els.fileList.innerHTML = "";
|
||||
files.forEach((f, idx) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<span>${f.name} <small>(${f.type || "file"} · ${f.size} B)</small></span>`;
|
||||
li.innerHTML = `<span>${f.name} <small>(${f.type || "file"} · ${window.WrappedUI.formatBytes(f.size)})</small></span>`;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = "×";
|
||||
@@ -102,7 +204,9 @@
|
||||
|
||||
function refreshExpiresMeta() {
|
||||
if (lastExpiresAt && els.expiresMeta) {
|
||||
els.expiresMeta.textContent = window.WrappedI18n.formatExpires(lastExpiresAt);
|
||||
els.expiresMeta.innerHTML = window.WrappedI18n.formatExpiresHtml
|
||||
? window.WrappedI18n.formatExpiresHtml(lastExpiresAt)
|
||||
: window.WrappedI18n.formatExpires(lastExpiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +250,8 @@
|
||||
settings = await resp.json();
|
||||
fillTtl();
|
||||
loadCaptcha();
|
||||
setLanguage("plaintext", { silent: true });
|
||||
refreshHighlight();
|
||||
}
|
||||
|
||||
els.dropzone.addEventListener("click", () => els.fileInput.click());
|
||||
@@ -177,6 +283,35 @@
|
||||
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;
|
||||
pre.scrollTop = els.text.scrollTop;
|
||||
pre.scrollLeft = els.text.scrollLeft;
|
||||
});
|
||||
|
||||
els.langChip?.addEventListener("click", openLangModal);
|
||||
els.langChange?.addEventListener("click", openLangModal);
|
||||
els.langModal?.addEventListener("click", (e) => {
|
||||
if (e.target.closest("[data-lang-close]")) closeLangModal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && els.langModal && !els.langModal.classList.contains("hidden")) {
|
||||
closeLangModal();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -229,26 +364,28 @@
|
||||
}
|
||||
|
||||
const password = els.password.value || "";
|
||||
const pack = { version: 1, items };
|
||||
const { ciphertext, keyB64url } = await window.WrappedCrypto.encryptPackage(
|
||||
pack,
|
||||
password || null
|
||||
);
|
||||
|
||||
if (ciphertext.byteLength > settings.max_upload_bytes) {
|
||||
showError(t("create.tooLarge"));
|
||||
return;
|
||||
}
|
||||
|
||||
els.wrapBtn.disabled = true;
|
||||
window.WrappedUI.showBusy(t("create.working"));
|
||||
try {
|
||||
const pack = { version: 1, items };
|
||||
const { ciphertext, keyB64url } = await window.WrappedCrypto.encryptPackage(
|
||||
pack,
|
||||
password || null
|
||||
);
|
||||
|
||||
if (ciphertext.byteLength > settings.max_upload_bytes) {
|
||||
showError(t("create.tooLarge"));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
ciphertext_b64: window.WrappedCrypto.bytesToBase64(ciphertext),
|
||||
ttl_seconds: Number(els.ttl.value),
|
||||
content_types: contentTypes,
|
||||
item_count: items.length,
|
||||
has_password: Boolean(password),
|
||||
password: settings.password_mode === "server_gate" && password ? password : null,
|
||||
// Always send password when set so server can gate wrong guesses.
|
||||
password: password || null,
|
||||
captcha_token: captchaToken() || null,
|
||||
};
|
||||
const resp = await fetch("/api/v1/wraps", {
|
||||
@@ -273,6 +410,7 @@
|
||||
} catch {
|
||||
showError(t("common.error"));
|
||||
} finally {
|
||||
window.WrappedUI.hideBusy();
|
||||
els.wrapBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -297,6 +435,9 @@
|
||||
window.WrappedI18n.onChange(() => {
|
||||
fillTtl();
|
||||
refreshExpiresMeta();
|
||||
setLanguage(els.lang.value, { silent: true });
|
||||
refreshHighlight();
|
||||
if (els.langChipText) els.langChipText.textContent = langLabel(els.lang.value);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
(() => {
|
||||
const LANGUAGES = [
|
||||
"plaintext",
|
||||
"markdown",
|
||||
"json",
|
||||
"yaml",
|
||||
"python",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"go",
|
||||
"rust",
|
||||
"sql",
|
||||
"bash",
|
||||
"html",
|
||||
"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",
|
||||
markdown: "markdown",
|
||||
json: "json",
|
||||
yaml: "yaml",
|
||||
python: "python",
|
||||
javascript: "javascript",
|
||||
typescript: "typescript",
|
||||
go: "go",
|
||||
rust: "rust",
|
||||
sql: "sql",
|
||||
bash: "bash",
|
||||
html: "xml",
|
||||
css: "css",
|
||||
};
|
||||
return map[lang] || "plaintext";
|
||||
}
|
||||
|
||||
function highlightElement(codeEl, text, lang) {
|
||||
if (!codeEl) return;
|
||||
const value = text ?? "";
|
||||
if (window.hljs) {
|
||||
try {
|
||||
const res = window.hljs.highlight(value, {
|
||||
language: hljsLang(lang),
|
||||
ignoreIllegals: true,
|
||||
});
|
||||
codeEl.className = `hljs language-${hljsLang(lang)}`;
|
||||
codeEl.innerHTML = res.value;
|
||||
return;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
codeEl.className = "hljs";
|
||||
codeEl.textContent = value;
|
||||
}
|
||||
|
||||
function syncThemeStylesheet() {
|
||||
const dark = document.getElementById("hljs-theme-dark");
|
||||
const light = document.getElementById("hljs-theme-light");
|
||||
if (!dark || !light) return;
|
||||
const isLight = document.documentElement.getAttribute("data-theme") === "light";
|
||||
dark.disabled = isLight;
|
||||
light.disabled = !isLight;
|
||||
}
|
||||
|
||||
function languages() {
|
||||
return LANGUAGES.slice();
|
||||
}
|
||||
|
||||
window.WrappedHighlight = {
|
||||
detectLanguage,
|
||||
highlightElement,
|
||||
syncThemeStylesheet,
|
||||
languages,
|
||||
hljsLang,
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", syncThemeStylesheet);
|
||||
const obs = new MutationObserver(syncThemeStylesheet);
|
||||
obs.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
})();
|
||||
+60
-10
@@ -22,6 +22,11 @@
|
||||
"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)",
|
||||
@@ -31,6 +36,7 @@
|
||||
"create.passwordGenerate": "Generate password",
|
||||
"create.passwordGenerateTip": "Generate a strong password",
|
||||
"create.submit": "Create wrapped token",
|
||||
"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.",
|
||||
@@ -40,7 +46,8 @@
|
||||
"create.needPayload": "Add text or at least one file.",
|
||||
"create.tooLarge": "Package exceeds max size.",
|
||||
"create.mimeDenied": "One or more MIME types are not allowed.",
|
||||
"create.expires": "Expires {datetime} · {relative}",
|
||||
"create.expires": "Expires {datetime}",
|
||||
"create.expiresRelative": "{relative}",
|
||||
"create.ttl.1h": "1 hour",
|
||||
"create.ttl.6h": "6 hours",
|
||||
"create.ttl.24h": "24 hours",
|
||||
@@ -71,14 +78,18 @@
|
||||
"unwrap.tokenPlaceholder": "wrapped_v1.… or wrap id",
|
||||
"unwrap.password": "Password (if set)",
|
||||
"unwrap.submit": "Unwrap",
|
||||
"unwrap.working": "Decrypting…",
|
||||
"unwrap.destroyed": "Server copy destroyed. 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.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.settings": "Settings",
|
||||
"admin.nav.audit": "Audit",
|
||||
"admin.nav.danger": "Danger",
|
||||
@@ -117,8 +128,10 @@
|
||||
"admin.mime.hint": "One pattern per line. Supports exact types and wildcards like image/*.",
|
||||
"admin.mime.examples": "Examples / suggested defaults",
|
||||
"admin.passwordMode.title": "Password mode",
|
||||
"admin.passwordMode.client_only": "Password is verified only in the browser after ciphertext download. Maximum zero-knowledge: the server never checks the password. Best when you trust link secrecy and want strongest ZK.",
|
||||
"admin.passwordMode.server_gate": "Server stores an Argon2id hash and releases ciphertext only after a correct password. Slightly weaker ZK metadata but stops offline bruteforce if someone steals the share link without the password.",
|
||||
"admin.passwordMode.client_only": "Password also wraps ciphertext in the browser. Server stores an Argon2id hash and rejects wrong passwords before the one-time unwrap (mistypes do not burn the package).",
|
||||
"admin.passwordMode.server_gate": "Server stores an Argon2id hash and releases ciphertext only after a correct password. Ciphertext is still encrypted client-side with the same password.",
|
||||
"admin.password.maxAttempts": "Wrong password attempts (unwrap)",
|
||||
"admin.password.maxAttemptsHint": "After this many wrong passwords the wrap is destroyed. Default: 3.",
|
||||
"admin.captcha.title": "CAPTCHA",
|
||||
"admin.captcha.provider": "Provider",
|
||||
"admin.captcha.turnstileSite": "Turnstile site key",
|
||||
@@ -152,6 +165,7 @@
|
||||
"admin.audit.wrapId": "Wrap ID",
|
||||
"admin.audit.filter": "Filter",
|
||||
"admin.audit.allEvents": "All events",
|
||||
"admin.audit.perPage": "Per page",
|
||||
"admin.audit.shown": "events shown",
|
||||
"admin.audit.of": "of",
|
||||
"admin.audit.events": "events",
|
||||
@@ -188,6 +202,11 @@
|
||||
"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)",
|
||||
@@ -197,6 +216,7 @@
|
||||
"create.passwordGenerate": "Сгенерировать пароль",
|
||||
"create.passwordGenerateTip": "Сгенерировать надёжный пароль",
|
||||
"create.submit": "Создать wrapped-токен",
|
||||
"create.working": "Шифрование и загрузка…",
|
||||
"create.openToken": "Открыть токен",
|
||||
"create.successTitle": "Токен выдан",
|
||||
"create.successWarn": "Unwrap один раз. После открытия ciphertext удаляется на сервере.",
|
||||
@@ -206,7 +226,8 @@
|
||||
"create.needPayload": "Добавь текст или хотя бы один файл.",
|
||||
"create.tooLarge": "Пакет превышает лимит размера.",
|
||||
"create.mimeDenied": "Один или несколько MIME-типов запрещены.",
|
||||
"create.expires": "Истекает {datetime} · {relative}",
|
||||
"create.expires": "Истекает {datetime}",
|
||||
"create.expiresRelative": "{relative}",
|
||||
"create.ttl.1h": "1 час",
|
||||
"create.ttl.6h": "6 часов",
|
||||
"create.ttl.24h": "24 часа",
|
||||
@@ -237,14 +258,18 @@
|
||||
"unwrap.tokenPlaceholder": "wrapped_v1.… или ID",
|
||||
"unwrap.password": "Пароль (если задан)",
|
||||
"unwrap.submit": "Расшифровать",
|
||||
"unwrap.working": "Расшифровка…",
|
||||
"unwrap.destroyed": "Копия на сервере уничтожена. Превью только в этой сессии браузера.",
|
||||
"unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.",
|
||||
"unwrap.badPassword": "Неверный пароль.",
|
||||
"unwrap.badPasswordRemaining": "Неверный пароль. Осталось попыток: {n} из {max}.",
|
||||
"unwrap.passwordLocked": "Слишком много неверных паролей. Этот wrap уничтожен.",
|
||||
"unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).",
|
||||
"common.copy": "Копировать",
|
||||
"common.copied": "Скопировано",
|
||||
"common.download": "Скачать",
|
||||
"common.error": "Что-то пошло не так.",
|
||||
"common.working": "Подождите…",
|
||||
"admin.nav.settings": "Настройки",
|
||||
"admin.nav.audit": "Аудит",
|
||||
"admin.nav.danger": "Опасная зона",
|
||||
@@ -283,8 +308,10 @@
|
||||
"admin.mime.hint": "Один шаблон на строку. Точные типы и wildcards вроде image/*.",
|
||||
"admin.mime.examples": "Примеры / рекомендуемые значения",
|
||||
"admin.passwordMode.title": "Режим пароля",
|
||||
"admin.passwordMode.client_only": "Пароль проверяется только в браузере после скачивания ciphertext. Максимальный zero-knowledge: сервер пароль не проверяет. Лучше, если доверяете секретности ссылки.",
|
||||
"admin.passwordMode.server_gate": "Сервер хранит Argon2id-хеш и отдаёт ciphertext только после верного пароля. Чуть слабее ZK по метаданным, но защищает от офлайн-брута при утечке ссылки без пароля.",
|
||||
"admin.passwordMode.client_only": "Пароль также оборачивает ciphertext в браузере. Сервер хранит Argon2id-хеш и отклоняет неверный пароль до одноразового unwrap (опечатка не сжигает пакет).",
|
||||
"admin.passwordMode.server_gate": "Сервер хранит Argon2id-хеш и отдаёт ciphertext только после верного пароля. Ciphertext по-прежнему шифруется на клиенте тем же паролем.",
|
||||
"admin.password.maxAttempts": "Неверных вводов пароля (unwrap)",
|
||||
"admin.password.maxAttemptsHint": "После стольких неверных паролей wrap уничтожается. По умолчанию: 3.",
|
||||
"admin.captcha.title": "CAPTCHA",
|
||||
"admin.captcha.provider": "Провайдер",
|
||||
"admin.captcha.turnstileSite": "Turnstile site key",
|
||||
@@ -318,6 +345,7 @@
|
||||
"admin.audit.wrapId": "Wrap ID",
|
||||
"admin.audit.filter": "Фильтр",
|
||||
"admin.audit.allEvents": "Все события",
|
||||
"admin.audit.perPage": "На странице",
|
||||
"admin.audit.shown": "событий показано",
|
||||
"admin.audit.of": "из",
|
||||
"admin.audit.events": "событий",
|
||||
@@ -358,9 +386,11 @@
|
||||
return current() === "ru" ? "ru-RU" : "en-GB";
|
||||
}
|
||||
|
||||
function formatExpires(iso) {
|
||||
function expiresParts(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return String(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return { datetime: String(iso), relative: "" };
|
||||
}
|
||||
const datetime = new Intl.DateTimeFormat(locale(), {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
@@ -376,7 +406,19 @@
|
||||
else if (mins < 60 * 48) relative = t("relative.inHours", { n: Math.round(mins / 60) });
|
||||
else relative = t("relative.inDays", { n: Math.round(mins / (60 * 24)) });
|
||||
}
|
||||
return t("create.expires", { datetime, relative });
|
||||
return { datetime, relative };
|
||||
}
|
||||
|
||||
function formatExpires(iso) {
|
||||
const { datetime, relative } = expiresParts(iso);
|
||||
return `${t("create.expires", { datetime })} · ${t("create.expiresRelative", { relative })}`;
|
||||
}
|
||||
|
||||
function formatExpiresHtml(iso) {
|
||||
const { datetime, relative } = expiresParts(iso);
|
||||
const main = t("create.expires", { datetime });
|
||||
const rel = t("create.expiresRelative", { relative });
|
||||
return `<span class="expires-main">${main}</span><span class="expires-rel">${rel}</span>`;
|
||||
}
|
||||
|
||||
function apply() {
|
||||
@@ -418,5 +460,13 @@
|
||||
if (btn) btn.addEventListener("click", toggle);
|
||||
});
|
||||
|
||||
window.WrappedI18n = { t, apply, current, locale, formatExpires, onChange };
|
||||
window.WrappedI18n = {
|
||||
t,
|
||||
apply,
|
||||
current,
|
||||
locale,
|
||||
formatExpires,
|
||||
formatExpiresHtml,
|
||||
onChange,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
(() => {
|
||||
let busyEl = null;
|
||||
|
||||
function ensureBusy() {
|
||||
if (busyEl) return busyEl;
|
||||
busyEl = document.createElement("div");
|
||||
busyEl.id = "busy-overlay";
|
||||
busyEl.className = "busy-overlay hidden";
|
||||
busyEl.setAttribute("aria-live", "polite");
|
||||
busyEl.setAttribute("aria-busy", "true");
|
||||
busyEl.innerHTML = `
|
||||
<div class="busy-card">
|
||||
<span class="busy-logo" aria-hidden="true">
|
||||
<img src="/static/favicon.svg" alt="" width="48" height="48" />
|
||||
</span>
|
||||
<p class="busy-text" data-busy-label></p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(busyEl);
|
||||
return busyEl;
|
||||
}
|
||||
|
||||
function showBusy(message) {
|
||||
const el = ensureBusy();
|
||||
const label = el.querySelector("[data-busy-label]");
|
||||
if (label) {
|
||||
label.textContent =
|
||||
message || window.WrappedI18n?.t("common.working") || "Working…";
|
||||
}
|
||||
el.classList.remove("hidden");
|
||||
document.body.classList.add("busy-open");
|
||||
}
|
||||
|
||||
function hideBusy() {
|
||||
if (!busyEl) return;
|
||||
busyEl.classList.add("hidden");
|
||||
document.body.classList.remove("busy-open");
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
const bytes = Number(n) || 0;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`;
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (mb < 1024) return `${mb < 10 ? mb.toFixed(2) : mb.toFixed(1)} MB`;
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
window.WrappedUI = { showBusy, hideBusy, formatBytes };
|
||||
})();
|
||||
+54
-22
@@ -70,44 +70,53 @@
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||||
}
|
||||
|
||||
function makeDownloadBtn(onClick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "btn download-btn";
|
||||
btn.type = "button";
|
||||
btn.innerHTML = `<i class="fa-solid fa-download" aria-hidden="true"></i><span>${t("common.download")}</span>`;
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function renderPackage(pack) {
|
||||
els.items.innerHTML = "";
|
||||
for (const item of pack.items || []) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "item-card";
|
||||
if (item.type === "text") {
|
||||
card.innerHTML = `<div><strong>text</strong> · <span class="mono">${item.language || "plaintext"}</span></div>`;
|
||||
const lang = item.language || "plaintext";
|
||||
const head = document.createElement("div");
|
||||
head.className = "item-card-head";
|
||||
head.innerHTML = `<div><strong>text</strong> · <span class="mono">${lang}</span></div>`;
|
||||
head.appendChild(
|
||||
makeDownloadBtn(() => {
|
||||
downloadBlob(
|
||||
`wrapped-${lang}.txt`,
|
||||
new Blob([item.content || ""], { type: "text/plain" })
|
||||
);
|
||||
})
|
||||
);
|
||||
card.appendChild(head);
|
||||
const pre = document.createElement("pre");
|
||||
pre.textContent = item.content || "";
|
||||
const code = document.createElement("code");
|
||||
pre.appendChild(code);
|
||||
card.appendChild(pre);
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "btn";
|
||||
btn.type = "button";
|
||||
btn.textContent = t("common.download");
|
||||
btn.addEventListener("click", () => {
|
||||
downloadBlob(
|
||||
`wrapped-${item.language || "text"}.txt`,
|
||||
new Blob([item.content || ""], { type: "text/plain" })
|
||||
);
|
||||
});
|
||||
card.appendChild(btn);
|
||||
window.WrappedHighlight.highlightElement(code, item.content || "", lang);
|
||||
} else if (item.type === "file") {
|
||||
const bytes = window.WrappedCrypto.base64ToBytes(item.data_b64);
|
||||
const blob = new Blob([bytes], { type: item.mime || "application/octet-stream" });
|
||||
card.innerHTML = `<div><strong>${item.name}</strong> · <span class="mono">${item.mime}</span> · ${bytes.length} B</div>`;
|
||||
const head = document.createElement("div");
|
||||
head.className = "item-card-head";
|
||||
head.innerHTML = `<div><strong>${item.name}</strong> · <span class="mono">${item.mime}</span> · ${window.WrappedUI.formatBytes(bytes.length)}</div>`;
|
||||
head.appendChild(makeDownloadBtn(() => downloadBlob(item.name || "file", blob)));
|
||||
card.appendChild(head);
|
||||
if ((item.mime || "").startsWith("image/")) {
|
||||
const img = document.createElement("img");
|
||||
img.alt = item.name;
|
||||
img.src = URL.createObjectURL(blob);
|
||||
card.appendChild(img);
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "btn";
|
||||
btn.type = "button";
|
||||
btn.textContent = t("common.download");
|
||||
btn.style.marginTop = "0.6rem";
|
||||
btn.addEventListener("click", () => downloadBlob(item.name || "file", blob));
|
||||
card.appendChild(btn);
|
||||
}
|
||||
els.items.appendChild(card);
|
||||
}
|
||||
@@ -127,6 +136,7 @@
|
||||
}
|
||||
|
||||
els.btn.disabled = true;
|
||||
window.WrappedUI.showBusy(t("unwrap.working"));
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/wraps/${encodeURIComponent(parsed.wrapId)}/unwrap`, {
|
||||
method: "POST",
|
||||
@@ -137,6 +147,27 @@
|
||||
}),
|
||||
});
|
||||
if (resp.status === 403) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
const detail = err.detail;
|
||||
if (detail && typeof detail === "object") {
|
||||
if (detail.code === "password_locked") {
|
||||
showError(t("unwrap.passwordLocked"));
|
||||
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"));
|
||||
return;
|
||||
}
|
||||
@@ -155,6 +186,7 @@
|
||||
);
|
||||
} 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"));
|
||||
return;
|
||||
}
|
||||
@@ -168,6 +200,7 @@
|
||||
} catch {
|
||||
showError(t("common.error"));
|
||||
} finally {
|
||||
window.WrappedUI.hideBusy();
|
||||
els.btn.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -177,7 +210,6 @@
|
||||
settings = await resp.json();
|
||||
loadCaptcha();
|
||||
|
||||
// Prefill from path + hash
|
||||
const pathMatch = location.pathname.match(/\/w\/([A-Za-z0-9]+)/);
|
||||
if (pathMatch && !els.token.value) {
|
||||
els.token.value = pathMatch[1];
|
||||
|
||||
Reference in New Issue
Block a user