Init
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
(() => {
|
||||
const form = document.getElementById("audit-filter-form");
|
||||
const typeSelect = document.getElementById("audit-event-type");
|
||||
if (form && typeSelect) {
|
||||
typeSelect.addEventListener("change", () => form.requestSubmit());
|
||||
}
|
||||
|
||||
// Human-friendly local timestamps
|
||||
document.querySelectorAll(".audit-time[data-ts]").forEach((el) => {
|
||||
const raw = el.getAttribute("data-ts");
|
||||
if (!raw) return;
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return;
|
||||
const locale = window.WrappedI18n?.locale?.() || undefined;
|
||||
el.textContent = new Intl.DateTimeFormat(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(d);
|
||||
el.title = raw;
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,32 @@
|
||||
(() => {
|
||||
const root = document.getElementById("admin-tabs");
|
||||
if (!root) return;
|
||||
|
||||
const tabs = [...root.querySelectorAll(".admin-tab")];
|
||||
const panels = [...root.querySelectorAll(".admin-tabpanel")];
|
||||
|
||||
function activate(name) {
|
||||
const id = name || "limits";
|
||||
tabs.forEach((tab) => {
|
||||
const on = tab.dataset.tab === id;
|
||||
tab.classList.toggle("active", on);
|
||||
tab.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
panels.forEach((panel) => {
|
||||
const on = panel.dataset.panel === id;
|
||||
panel.classList.toggle("active", on);
|
||||
panel.hidden = !on;
|
||||
});
|
||||
const url = new URL(location.href);
|
||||
url.hash = id;
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener("click", () => activate(tab.dataset.tab));
|
||||
});
|
||||
|
||||
const fromHash = (location.hash || "").replace(/^#/, "");
|
||||
const initial = tabs.some((t) => t.dataset.tab === fromHash) ? fromHash : "limits";
|
||||
activate(initial);
|
||||
})();
|
||||
@@ -0,0 +1,304 @@
|
||||
(() => {
|
||||
const t = (k) => window.WrappedI18n.t(k);
|
||||
const files = [];
|
||||
let settings = null;
|
||||
let captchaWidgetId = null;
|
||||
|
||||
const els = {
|
||||
text: document.getElementById("payload-text"),
|
||||
lang: document.getElementById("text-language"),
|
||||
ttl: document.getElementById("ttl-seconds"),
|
||||
password: document.getElementById("password"),
|
||||
generatePassword: document.getElementById("generate-password"),
|
||||
dropzone: document.getElementById("dropzone"),
|
||||
fileInput: document.getElementById("file-input"),
|
||||
fileList: document.getElementById("file-list"),
|
||||
wrapBtn: document.getElementById("wrap-btn"),
|
||||
error: document.getElementById("form-error"),
|
||||
success: document.getElementById("success-panel"),
|
||||
composer: document.querySelector(".composer"),
|
||||
shareLink: document.getElementById("share-link"),
|
||||
shareToken: document.getElementById("share-token"),
|
||||
expiresMeta: document.getElementById("expires-meta"),
|
||||
captchaSlot: document.getElementById("captcha-slot"),
|
||||
};
|
||||
|
||||
function showError(msg) {
|
||||
els.error.textContent = msg;
|
||||
els.error.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
els.error.classList.add("hidden");
|
||||
els.error.textContent = "";
|
||||
}
|
||||
|
||||
function mimeAllowed(ct) {
|
||||
const allow = settings.mime_allowlist || [];
|
||||
const base = (ct || "").split(";")[0].trim().toLowerCase();
|
||||
return allow.some((p) => {
|
||||
const pat = p.toLowerCase();
|
||||
if (pat.endsWith("/*")) return base.startsWith(pat.slice(0, -1));
|
||||
return base === pat;
|
||||
});
|
||||
}
|
||||
|
||||
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>`;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = "×";
|
||||
btn.addEventListener("click", () => {
|
||||
files.splice(idx, 1);
|
||||
renderFiles();
|
||||
});
|
||||
li.appendChild(btn);
|
||||
els.fileList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function addFiles(list) {
|
||||
for (const f of list) {
|
||||
if (!mimeAllowed(f.type || "application/octet-stream")) {
|
||||
showError(t("create.mimeDenied") + `: ${f.type || f.name}`);
|
||||
continue;
|
||||
}
|
||||
files.push(f);
|
||||
}
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
let lastExpiresAt = null;
|
||||
|
||||
function fillTtl() {
|
||||
if (!settings || !els.ttl) return;
|
||||
const max = settings.max_ttl_seconds;
|
||||
const def = settings.default_ttl_seconds;
|
||||
const selected = els.ttl.value ? Number(els.ttl.value) : def;
|
||||
const options = [
|
||||
{ key: "create.ttl.1h", value: 3600 },
|
||||
{ key: "create.ttl.6h", value: 6 * 3600 },
|
||||
{ key: "create.ttl.24h", value: 24 * 3600 },
|
||||
{ key: "create.ttl.3d", value: 3 * 24 * 3600 },
|
||||
{ key: "create.ttl.7d", value: 7 * 24 * 3600 },
|
||||
].filter((o) => o.value <= max);
|
||||
if (!options.find((o) => o.value === def) && def <= max) {
|
||||
options.unshift({ key: "create.ttl.default", value: def, seconds: def });
|
||||
}
|
||||
const pick = options.some((o) => o.value === selected) ? selected : def;
|
||||
els.ttl.innerHTML = options
|
||||
.map((o) => {
|
||||
const label =
|
||||
o.key === "create.ttl.default"
|
||||
? t("create.ttl.default", { seconds: o.seconds })
|
||||
: t(o.key);
|
||||
return `<option value="${o.value}" ${o.value === pick ? "selected" : ""}>${label}</option>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function refreshExpiresMeta() {
|
||||
if (lastExpiresAt && els.expiresMeta) {
|
||||
els.expiresMeta.textContent = window.WrappedI18n.formatExpires(lastExpiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
function loadCaptcha() {
|
||||
els.captchaSlot.innerHTML = "";
|
||||
captchaWidgetId = null;
|
||||
const provider = settings.captcha_provider;
|
||||
if (provider === "turnstile" && settings.turnstile_site_key) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "cf-turnstile";
|
||||
div.dataset.sitekey = settings.turnstile_site_key;
|
||||
els.captchaSlot.appendChild(div);
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
||||
s.async = true;
|
||||
document.body.appendChild(s);
|
||||
} else if (provider === "hcaptcha" && settings.hcaptcha_site_key) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "h-captcha";
|
||||
div.dataset.sitekey = settings.hcaptcha_site_key;
|
||||
els.captchaSlot.appendChild(div);
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://js.hcaptcha.com/1/api.js";
|
||||
s.async = true;
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
}
|
||||
|
||||
function captchaToken() {
|
||||
if (settings.captcha_provider === "turnstile" && window.turnstile) {
|
||||
return window.turnstile.getResponse?.() || "";
|
||||
}
|
||||
if (settings.captcha_provider === "hcaptcha" && window.hcaptcha) {
|
||||
return window.hcaptcha.getResponse?.(captchaWidgetId) || window.hcaptcha.getResponse?.() || "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const resp = await fetch("/api/v1/settings");
|
||||
settings = await resp.json();
|
||||
fillTtl();
|
||||
loadCaptcha();
|
||||
}
|
||||
|
||||
els.dropzone.addEventListener("click", () => els.fileInput.click());
|
||||
els.fileInput.addEventListener("change", () => addFiles(els.fileInput.files));
|
||||
["dragenter", "dragover"].forEach((ev) => {
|
||||
els.dropzone.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
els.dropzone.classList.add("dragover");
|
||||
});
|
||||
});
|
||||
["dragleave", "drop"].forEach((ev) => {
|
||||
els.dropzone.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
els.dropzone.classList.remove("dragover");
|
||||
});
|
||||
});
|
||||
els.dropzone.addEventListener("drop", (e) => addFiles(e.dataTransfer.files));
|
||||
|
||||
document.addEventListener("paste", (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const pasted = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === "file") {
|
||||
const f = item.getAsFile();
|
||||
if (f) pasted.push(f);
|
||||
}
|
||||
}
|
||||
if (pasted.length) {
|
||||
e.preventDefault();
|
||||
addFiles(pasted);
|
||||
}
|
||||
});
|
||||
|
||||
async function copyFrom(input, btn) {
|
||||
await navigator.clipboard.writeText(input.value);
|
||||
const old = btn.textContent;
|
||||
btn.textContent = t("common.copied");
|
||||
setTimeout(() => (btn.textContent = old), 1200);
|
||||
}
|
||||
|
||||
document.getElementById("copy-link").addEventListener("click", () => {
|
||||
copyFrom(els.shareLink, document.getElementById("copy-link"));
|
||||
});
|
||||
document.getElementById("copy-token").addEventListener("click", () => {
|
||||
copyFrom(els.shareToken, document.getElementById("copy-token"));
|
||||
});
|
||||
document.getElementById("create-another").addEventListener("click", () => {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
els.wrapBtn.addEventListener("click", async () => {
|
||||
clearError();
|
||||
const text = els.text.value;
|
||||
if (!text.trim() && files.length === 0) {
|
||||
showError(t("create.needPayload"));
|
||||
return;
|
||||
}
|
||||
|
||||
const items = [];
|
||||
const contentTypes = [];
|
||||
if (text.trim()) {
|
||||
items.push({
|
||||
type: "text",
|
||||
language: els.lang.value,
|
||||
content: text,
|
||||
});
|
||||
contentTypes.push("text/plain");
|
||||
}
|
||||
for (const f of files) {
|
||||
const item = await window.WrappedCrypto.fileToItem(f);
|
||||
items.push(item);
|
||||
contentTypes.push(item.mime);
|
||||
}
|
||||
|
||||
for (const ct of contentTypes) {
|
||||
if (!mimeAllowed(ct)) {
|
||||
showError(t("create.mimeDenied"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
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,
|
||||
captcha_token: captchaToken() || null,
|
||||
};
|
||||
const resp = await fetch("/api/v1/wraps", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showError(err.detail || t("common.error"));
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const token = window.WrappedCrypto.buildToken(data.wrap_id, keyB64url);
|
||||
const link = `${location.origin}${data.share_path}#${keyB64url}`;
|
||||
els.shareLink.value = link;
|
||||
els.shareToken.value = token;
|
||||
lastExpiresAt = data.expires_at;
|
||||
refreshExpiresMeta();
|
||||
els.composer.classList.add("hidden");
|
||||
els.success.classList.remove("hidden");
|
||||
} catch {
|
||||
showError(t("common.error"));
|
||||
} finally {
|
||||
els.wrapBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function generatePassword(length = 20) {
|
||||
const alphabet =
|
||||
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*-_";
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join("");
|
||||
}
|
||||
|
||||
els.generatePassword?.addEventListener("click", () => {
|
||||
const pwd = generatePassword();
|
||||
els.password.type = "text";
|
||||
els.password.value = pwd;
|
||||
els.password.focus();
|
||||
els.password.select();
|
||||
});
|
||||
|
||||
if (window.WrappedI18n.onChange) {
|
||||
window.WrappedI18n.onChange(() => {
|
||||
fillTtl();
|
||||
refreshExpiresMeta();
|
||||
});
|
||||
}
|
||||
|
||||
init().catch(() => showError(t("common.error")));
|
||||
})();
|
||||
@@ -0,0 +1,199 @@
|
||||
(() => {
|
||||
const te = new TextEncoder();
|
||||
const td = new TextDecoder();
|
||||
|
||||
function b64encode(buf) {
|
||||
const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf;
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
function b64decode(str) {
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function b64urlEncode(buf) {
|
||||
return b64encode(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function b64urlDecode(str) {
|
||||
const pad = "=".repeat((4 - (str.length % 4)) % 4);
|
||||
return b64decode(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
||||
}
|
||||
|
||||
async function importAesKey(raw) {
|
||||
return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
]);
|
||||
}
|
||||
|
||||
async function derivePasswordKey(password, salt) {
|
||||
const base = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
te.encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations: 210000, hash: "SHA-256" },
|
||||
base,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
async function encryptPackage(packageObj, password) {
|
||||
const dek = crypto.getRandomValues(new Uint8Array(32));
|
||||
const payload = te.encode(JSON.stringify(packageObj));
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await importAesKey(dek);
|
||||
const encrypted = new Uint8Array(
|
||||
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, payload)
|
||||
);
|
||||
|
||||
let body = encrypted;
|
||||
let bodyNonce = nonce;
|
||||
let flags = 0;
|
||||
let salt = new Uint8Array(16);
|
||||
let outerNonce = new Uint8Array(12);
|
||||
|
||||
if (password) {
|
||||
flags = 1;
|
||||
salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
outerNonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const pwKey = await derivePasswordKey(password, salt);
|
||||
const inner = new Uint8Array(nonce.length + encrypted.length);
|
||||
inner.set(nonce, 0);
|
||||
inner.set(encrypted, nonce.length);
|
||||
body = new Uint8Array(
|
||||
await crypto.subtle.encrypt({ name: "AES-GCM", iv: outerNonce }, pwKey, inner)
|
||||
);
|
||||
bodyNonce = outerNonce;
|
||||
}
|
||||
|
||||
// header: magic(4) version(1) flags(1) salt(16) nonce(12) + ciphertext
|
||||
const header = new Uint8Array(4 + 1 + 1 + 16 + 12);
|
||||
header.set(te.encode("WRPD"), 0);
|
||||
header[4] = 1;
|
||||
header[5] = flags;
|
||||
header.set(salt, 6);
|
||||
header.set(bodyNonce, 22);
|
||||
const out = new Uint8Array(header.length + body.length);
|
||||
out.set(header, 0);
|
||||
out.set(body, header.length);
|
||||
|
||||
return {
|
||||
ciphertext: out,
|
||||
keyB64url: b64urlEncode(dek),
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptPackage(ciphertext, keyB64url, password) {
|
||||
if (ciphertext.length < 34) throw new Error("truncated");
|
||||
const magic = td.decode(ciphertext.subarray(0, 4));
|
||||
if (magic !== "WRPD") throw new Error("bad_magic");
|
||||
const flags = ciphertext[5];
|
||||
const salt = ciphertext.subarray(6, 22);
|
||||
const nonce = ciphertext.subarray(22, 34);
|
||||
const body = ciphertext.subarray(34);
|
||||
const dek = b64urlDecode(keyB64url);
|
||||
|
||||
let plainEncrypted;
|
||||
let innerNonce;
|
||||
|
||||
if (flags & 1) {
|
||||
if (!password) throw new Error("password_required");
|
||||
const pwKey = await derivePasswordKey(password, salt);
|
||||
let inner;
|
||||
try {
|
||||
inner = new Uint8Array(
|
||||
await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, pwKey, body)
|
||||
);
|
||||
} catch {
|
||||
throw new Error("bad_password");
|
||||
}
|
||||
innerNonce = inner.subarray(0, 12);
|
||||
plainEncrypted = inner.subarray(12);
|
||||
} else {
|
||||
innerNonce = nonce;
|
||||
plainEncrypted = body;
|
||||
}
|
||||
|
||||
const key = await importAesKey(dek);
|
||||
let payload;
|
||||
try {
|
||||
payload = new Uint8Array(
|
||||
await crypto.subtle.decrypt({ name: "AES-GCM", iv: innerNonce }, key, plainEncrypted)
|
||||
);
|
||||
} catch {
|
||||
throw new Error("decrypt_failed");
|
||||
}
|
||||
return JSON.parse(td.decode(payload));
|
||||
}
|
||||
|
||||
function buildToken(wrapId, keyB64url) {
|
||||
return `wrapped_v1.${wrapId}.${keyB64url}`;
|
||||
}
|
||||
|
||||
function parseToken(input) {
|
||||
const raw = (input || "").trim();
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith("wrapped_v1.")) {
|
||||
const parts = raw.split(".");
|
||||
if (parts.length < 3) return null;
|
||||
return { wrapId: parts[1], key: parts.slice(2).join(".") };
|
||||
}
|
||||
// bare id — key may come from location.hash
|
||||
if (/^[A-Za-z0-9]+$/.test(raw) && raw.length >= 16) {
|
||||
return { wrapId: raw, key: null };
|
||||
}
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const m = url.pathname.match(/\/w\/([A-Za-z0-9]+)/);
|
||||
if (m) {
|
||||
return { wrapId: m[1], key: url.hash ? url.hash.slice(1) : null };
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
return b64encode(bytes);
|
||||
}
|
||||
|
||||
function base64ToBytes(b64) {
|
||||
return b64decode(b64);
|
||||
}
|
||||
|
||||
async function fileToItem(file) {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
return {
|
||||
type: "file",
|
||||
name: file.name || "file",
|
||||
mime: file.type || "application/octet-stream",
|
||||
data_b64: b64encode(buf),
|
||||
};
|
||||
}
|
||||
|
||||
window.WrappedCrypto = {
|
||||
encryptPackage,
|
||||
decryptPackage,
|
||||
buildToken,
|
||||
parseToken,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
fileToItem,
|
||||
b64urlEncode,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,422 @@
|
||||
(() => {
|
||||
const KEY = "wrapped.lang";
|
||||
const dict = {
|
||||
en: {
|
||||
"footer.tagline": "Zero-knowledge · one-time · encrypted",
|
||||
"footer.lede": "Wrap text, images or files into a one-time token. Encryption happens in your browser — the server never sees plaintext.",
|
||||
"footer.cap.text": "Text",
|
||||
"footer.cap.text.hint": "Secrets, configs, notes",
|
||||
"footer.cap.media": "Images & files",
|
||||
"footer.cap.media.hint": "Screenshots, archives, docs",
|
||||
"footer.cap.browser": "Encrypted in-browser",
|
||||
"footer.cap.browser.hint": "Only ciphertext reaches the server",
|
||||
"footer.pillar.zk": "Zero-knowledge",
|
||||
"footer.pillar.once": "One-time",
|
||||
"footer.pillar.encrypted": "Encrypted",
|
||||
"footer.pillar.zk.hint": "Server never sees content",
|
||||
"footer.pillar.once.hint": "Gone after unwrap",
|
||||
"footer.pillar.encrypted.hint": "Key lives only in your link",
|
||||
"footer.copy.author": "Sergey Antropov",
|
||||
"brand.tagline": "Wrap. Send. Vanish.",
|
||||
"create.eyebrow": "Secure drop",
|
||||
"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.text": "Text",
|
||||
"create.textPlaceholder": "Paste secrets, configs, notes…",
|
||||
"create.drop": "Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)",
|
||||
"create.ttl": "Time to live",
|
||||
"create.password": "Password (optional)",
|
||||
"create.passwordPlaceholder": "Extra unlock secret",
|
||||
"create.passwordGenerate": "Generate password",
|
||||
"create.passwordGenerateTip": "Generate a strong password",
|
||||
"create.submit": "Create wrapped token",
|
||||
"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.shareLink": "Share link",
|
||||
"create.token": "Wrapped token",
|
||||
"create.another": "Create another",
|
||||
"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.ttl.1h": "1 hour",
|
||||
"create.ttl.6h": "6 hours",
|
||||
"create.ttl.24h": "24 hours",
|
||||
"create.ttl.3d": "3 days",
|
||||
"create.ttl.7d": "7 days",
|
||||
"create.ttl.default": "Default ({seconds} s)",
|
||||
"lang.plaintext": "Plain text",
|
||||
"lang.markdown": "Markdown",
|
||||
"lang.json": "JSON",
|
||||
"lang.yaml": "YAML",
|
||||
"lang.python": "Python",
|
||||
"lang.javascript": "JavaScript",
|
||||
"lang.typescript": "TypeScript",
|
||||
"lang.go": "Go",
|
||||
"lang.rust": "Rust",
|
||||
"lang.sql": "SQL",
|
||||
"lang.bash": "Bash",
|
||||
"lang.html": "HTML",
|
||||
"lang.css": "CSS",
|
||||
"relative.inMinutes": "in {n} min",
|
||||
"relative.inHours": "in {n} h",
|
||||
"relative.inDays": "in {n} d",
|
||||
"relative.soon": "soon",
|
||||
"unwrap.eyebrow": "Unwrap",
|
||||
"unwrap.title": "Reveal once",
|
||||
"unwrap.lede": "Paste a wrapped token or open a share link. Ciphertext is fetched once and deleted from the server.",
|
||||
"unwrap.token": "Wrapped token or ID",
|
||||
"unwrap.tokenPlaceholder": "wrapped_v1.… or wrap id",
|
||||
"unwrap.password": "Password (if set)",
|
||||
"unwrap.submit": "Unwrap",
|
||||
"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.unavailable": "Unavailable (already used, expired, or invalid).",
|
||||
"common.copy": "Copy",
|
||||
"common.copied": "Copied",
|
||||
"common.download": "Download",
|
||||
"common.error": "Something went wrong.",
|
||||
"admin.nav.settings": "Settings",
|
||||
"admin.nav.audit": "Audit",
|
||||
"admin.nav.danger": "Danger",
|
||||
"admin.nav.site": "Site",
|
||||
"admin.nav.logout": "Logout",
|
||||
"admin.brand.tagline": "Admin console",
|
||||
"admin.settings.title": "Settings",
|
||||
"admin.audit.title": "Audit",
|
||||
"admin.tab.limits": "Limits",
|
||||
"admin.tab.rate": "Rate limits",
|
||||
"admin.tab.mime": "MIME",
|
||||
"admin.tab.password": "Password",
|
||||
"admin.tab.captcha": "CAPTCHA",
|
||||
"admin.login.sub": "Admin",
|
||||
"admin.login.username": "Username",
|
||||
"admin.login.password": "Password",
|
||||
"admin.login.submit": "Sign in",
|
||||
"admin.login.error.rate": "Too many attempts. Try later.",
|
||||
"admin.login.error.bad": "Invalid credentials",
|
||||
"admin.flash.saved": "Settings saved.",
|
||||
"admin.flash.purgedPrefix": "Purged",
|
||||
"admin.flash.purgedWraps": "wrap(s) and",
|
||||
"admin.flash.purgedObjects": "object(s).",
|
||||
"admin.flash.purgeConfirm": "Type PURGE to confirm forced cleanup.",
|
||||
"admin.flash.purgeError": "Purge failed. Check logs / MinIO connectivity.",
|
||||
"admin.limits.title": "Limits",
|
||||
"admin.limits.maxUpload": "Max upload (MB)",
|
||||
"admin.limits.defaultTtl": "Default TTL (hours)",
|
||||
"admin.limits.maxTtl": "Max TTL (hours)",
|
||||
"admin.limits.auditRetention": "Audit retention (days)",
|
||||
"admin.rate.title": "Rate limits (per IP / minute)",
|
||||
"admin.rate.create": "Create",
|
||||
"admin.rate.unwrap": "Unwrap",
|
||||
"admin.rate.adminLogin": "Admin login",
|
||||
"admin.mime.title": "MIME allowlist",
|
||||
"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.captcha.title": "CAPTCHA",
|
||||
"admin.captcha.provider": "Provider",
|
||||
"admin.captcha.turnstileSite": "Turnstile site key",
|
||||
"admin.captcha.hcaptchaSite": "hCaptcha site key",
|
||||
"admin.captcha.secrets": "Secrets:",
|
||||
"admin.captcha.set": "set",
|
||||
"admin.captcha.missing": "missing",
|
||||
"admin.captcha.viaEnv": "(via env TURNSTILE_SECRET_KEY / HCAPTCHA_SECRET_KEY)",
|
||||
"admin.captcha.helperTitle": "How to get CAPTCHA tokens",
|
||||
"admin.captcha.turnstile.1": "Open Cloudflare Turnstile dashboard",
|
||||
"admin.captcha.turnstile.2": "Create a site widget for your domain (use localhost for dev)",
|
||||
"admin.captcha.turnstile.3": "Copy Site Key into the field above",
|
||||
"admin.captcha.turnstile.4": "Copy Secret Key into env TURNSTILE_SECRET_KEY and restart",
|
||||
"admin.captcha.turnstile.5": "Set provider to turnstile",
|
||||
"admin.captcha.hcaptcha.1": "Open hCaptcha dashboard",
|
||||
"admin.captcha.hcaptcha.2": "New site → copy Sitekey here",
|
||||
"admin.captcha.hcaptcha.3": "Secret into env HCAPTCHA_SECRET_KEY, restart",
|
||||
"admin.captcha.hcaptcha.4": "Set provider to hcaptcha",
|
||||
"admin.settings.save": "Save settings",
|
||||
"admin.danger.title": "Danger zone",
|
||||
"admin.danger.hint": "Force-delete all wraps from the database and all ciphertext objects in MinIO (wraps/). Audit log is kept. This cannot be undone.",
|
||||
"admin.danger.confirmLabel": "Type PURGE to confirm",
|
||||
"admin.danger.submit": "Clear all wraps & files",
|
||||
"admin.danger.confirmDialog": "Delete ALL wraps and files? This cannot be undone.",
|
||||
"admin.danger.confirmTitle": "Confirm purge",
|
||||
"admin.danger.confirmOk": "Delete everything",
|
||||
"modal.confirm.title": "Confirm",
|
||||
"modal.confirm.ok": "Confirm",
|
||||
"modal.confirm.cancel": "Cancel",
|
||||
"admin.audit.eventType": "Event type",
|
||||
"admin.audit.wrapId": "Wrap ID",
|
||||
"admin.audit.filter": "Filter",
|
||||
"admin.audit.allEvents": "All events",
|
||||
"admin.audit.shown": "events shown",
|
||||
"admin.audit.of": "of",
|
||||
"admin.audit.events": "events",
|
||||
"admin.audit.page": "Page",
|
||||
"admin.audit.prev": "Prev",
|
||||
"admin.audit.next": "Next",
|
||||
"admin.audit.col.time": "Time",
|
||||
"admin.audit.col.event": "Event",
|
||||
"admin.audit.col.ok": "OK",
|
||||
"admin.audit.col.wrap": "Wrap",
|
||||
"admin.audit.col.details": "Details",
|
||||
"admin.audit.yes": "yes",
|
||||
"admin.audit.no": "no",
|
||||
"admin.audit.empty": "No events",
|
||||
},
|
||||
ru: {
|
||||
"footer.tagline": "Zero-knowledge · одноразово · зашифровано",
|
||||
"footer.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.",
|
||||
"footer.cap.text": "Текст",
|
||||
"footer.cap.text.hint": "Секреты, конфиги, заметки",
|
||||
"footer.cap.media": "Картинки и файлы",
|
||||
"footer.cap.media.hint": "Скриншоты, архивы, документы",
|
||||
"footer.cap.browser": "Шифрование в браузере",
|
||||
"footer.cap.browser.hint": "На сервер уходит только ciphertext",
|
||||
"footer.pillar.zk": "Zero-knowledge",
|
||||
"footer.pillar.once": "Одноразово",
|
||||
"footer.pillar.encrypted": "Зашифровано",
|
||||
"footer.pillar.zk.hint": "Сервер не видит содержимое",
|
||||
"footer.pillar.once.hint": "После открытия — удаление",
|
||||
"footer.pillar.encrypted.hint": "Ключ только в вашей ссылке",
|
||||
"footer.copy.author": "Сергей Антропов",
|
||||
"brand.tagline": "Упакуй. Отправь. Исчезни.",
|
||||
"create.eyebrow": "Безопасная передача",
|
||||
"create.title": "Упакуй. Отправь. Исчезни.",
|
||||
"create.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.",
|
||||
"create.language": "Язык подсветки",
|
||||
"create.text": "Текст",
|
||||
"create.textPlaceholder": "Вставь секреты, конфиги, заметки…",
|
||||
"create.drop": "Перетащи файлы, выбери или вставь скриншот из буфера (⌘V / Ctrl+V)",
|
||||
"create.ttl": "Время жизни",
|
||||
"create.password": "Пароль (необязательно)",
|
||||
"create.passwordPlaceholder": "Дополнительный секрет",
|
||||
"create.passwordGenerate": "Сгенерировать пароль",
|
||||
"create.passwordGenerateTip": "Сгенерировать надёжный пароль",
|
||||
"create.submit": "Создать wrapped-токен",
|
||||
"create.openToken": "Открыть токен",
|
||||
"create.successTitle": "Токен выдан",
|
||||
"create.successWarn": "Unwrap один раз. После открытия ciphertext удаляется на сервере.",
|
||||
"create.shareLink": "Ссылка",
|
||||
"create.token": "Wrapped-токен",
|
||||
"create.another": "Создать ещё",
|
||||
"create.needPayload": "Добавь текст или хотя бы один файл.",
|
||||
"create.tooLarge": "Пакет превышает лимит размера.",
|
||||
"create.mimeDenied": "Один или несколько MIME-типов запрещены.",
|
||||
"create.expires": "Истекает {datetime} · {relative}",
|
||||
"create.ttl.1h": "1 час",
|
||||
"create.ttl.6h": "6 часов",
|
||||
"create.ttl.24h": "24 часа",
|
||||
"create.ttl.3d": "3 дня",
|
||||
"create.ttl.7d": "7 дней",
|
||||
"create.ttl.default": "По умолчанию ({seconds} с)",
|
||||
"lang.plaintext": "Обычный текст",
|
||||
"lang.markdown": "Markdown",
|
||||
"lang.json": "JSON",
|
||||
"lang.yaml": "YAML",
|
||||
"lang.python": "Python",
|
||||
"lang.javascript": "JavaScript",
|
||||
"lang.typescript": "TypeScript",
|
||||
"lang.go": "Go",
|
||||
"lang.rust": "Rust",
|
||||
"lang.sql": "SQL",
|
||||
"lang.bash": "Bash",
|
||||
"lang.html": "HTML",
|
||||
"lang.css": "CSS",
|
||||
"relative.inMinutes": "через {n} мин",
|
||||
"relative.inHours": "через {n} ч",
|
||||
"relative.inDays": "через {n} дн",
|
||||
"relative.soon": "скоро",
|
||||
"unwrap.eyebrow": "Unwrap",
|
||||
"unwrap.title": "Открыть один раз",
|
||||
"unwrap.lede": "Вставь wrapped-токен или открой ссылку. Ciphertext скачивается один раз и удаляется на сервере.",
|
||||
"unwrap.token": "Wrapped-токен или ID",
|
||||
"unwrap.tokenPlaceholder": "wrapped_v1.… или ID",
|
||||
"unwrap.password": "Пароль (если задан)",
|
||||
"unwrap.submit": "Расшифровать",
|
||||
"unwrap.destroyed": "Копия на сервере уничтожена. Превью только в этой сессии браузера.",
|
||||
"unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.",
|
||||
"unwrap.badPassword": "Неверный пароль.",
|
||||
"unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).",
|
||||
"common.copy": "Копировать",
|
||||
"common.copied": "Скопировано",
|
||||
"common.download": "Скачать",
|
||||
"common.error": "Что-то пошло не так.",
|
||||
"admin.nav.settings": "Настройки",
|
||||
"admin.nav.audit": "Аудит",
|
||||
"admin.nav.danger": "Опасная зона",
|
||||
"admin.nav.site": "Сайт",
|
||||
"admin.nav.logout": "Выйти",
|
||||
"admin.brand.tagline": "Консоль администратора",
|
||||
"admin.settings.title": "Настройки",
|
||||
"admin.audit.title": "Аудит",
|
||||
"admin.tab.limits": "Лимиты",
|
||||
"admin.tab.rate": "Rate limits",
|
||||
"admin.tab.mime": "MIME",
|
||||
"admin.tab.password": "Пароль",
|
||||
"admin.tab.captcha": "CAPTCHA",
|
||||
"admin.login.sub": "Админ",
|
||||
"admin.login.username": "Логин",
|
||||
"admin.login.password": "Пароль",
|
||||
"admin.login.submit": "Войти",
|
||||
"admin.login.error.rate": "Слишком много попыток. Попробуйте позже.",
|
||||
"admin.login.error.bad": "Неверный логин или пароль",
|
||||
"admin.flash.saved": "Настройки сохранены.",
|
||||
"admin.flash.purgedPrefix": "Удалено",
|
||||
"admin.flash.purgedWraps": "wrap(ов) и",
|
||||
"admin.flash.purgedObjects": "объект(ов).",
|
||||
"admin.flash.purgeConfirm": "Введите PURGE для подтверждения очистки.",
|
||||
"admin.flash.purgeError": "Очистка не удалась. Проверьте логи / MinIO.",
|
||||
"admin.limits.title": "Лимиты",
|
||||
"admin.limits.maxUpload": "Макс. размер (МБ)",
|
||||
"admin.limits.defaultTtl": "TTL по умолчанию (часы)",
|
||||
"admin.limits.maxTtl": "Макс. TTL (часы)",
|
||||
"admin.limits.auditRetention": "Хранение аудита (дни)",
|
||||
"admin.rate.title": "Rate limits (на IP / минуту)",
|
||||
"admin.rate.create": "Создание",
|
||||
"admin.rate.unwrap": "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. Максимальный zero-knowledge: сервер пароль не проверяет. Лучше, если доверяете секретности ссылки.",
|
||||
"admin.passwordMode.server_gate": "Сервер хранит Argon2id-хеш и отдаёт ciphertext только после верного пароля. Чуть слабее ZK по метаданным, но защищает от офлайн-брута при утечке ссылки без пароля.",
|
||||
"admin.captcha.title": "CAPTCHA",
|
||||
"admin.captcha.provider": "Провайдер",
|
||||
"admin.captcha.turnstileSite": "Turnstile site key",
|
||||
"admin.captcha.hcaptchaSite": "hCaptcha site key",
|
||||
"admin.captcha.secrets": "Секреты:",
|
||||
"admin.captcha.set": "задан",
|
||||
"admin.captcha.missing": "нет",
|
||||
"admin.captcha.viaEnv": "(через env TURNSTILE_SECRET_KEY / HCAPTCHA_SECRET_KEY)",
|
||||
"admin.captcha.helperTitle": "Как получить токены CAPTCHA",
|
||||
"admin.captcha.turnstile.1": "Откройте Cloudflare Turnstile dashboard",
|
||||
"admin.captcha.turnstile.2": "Создайте виджет для домена (для dev — localhost)",
|
||||
"admin.captcha.turnstile.3": "Скопируйте Site Key в поле выше",
|
||||
"admin.captcha.turnstile.4": "Secret Key в env TURNSTILE_SECRET_KEY и перезапустите",
|
||||
"admin.captcha.turnstile.5": "Выберите provider = turnstile",
|
||||
"admin.captcha.hcaptcha.1": "Откройте hCaptcha dashboard",
|
||||
"admin.captcha.hcaptcha.2": "Новый сайт → Sitekey сюда",
|
||||
"admin.captcha.hcaptcha.3": "Secret в env HCAPTCHA_SECRET_KEY, перезапуск",
|
||||
"admin.captcha.hcaptcha.4": "Выберите provider = hcaptcha",
|
||||
"admin.settings.save": "Сохранить",
|
||||
"admin.danger.title": "Опасная зона",
|
||||
"admin.danger.hint": "Принудительно удаляет все wraps из БД и все ciphertext-объекты в MinIO (wraps/). Аудит сохраняется. Отменить нельзя.",
|
||||
"admin.danger.confirmLabel": "Введите PURGE для подтверждения",
|
||||
"admin.danger.submit": "Очистить все wraps и файлы",
|
||||
"admin.danger.confirmDialog": "Удалить ВСЕ wraps и файлы? Это нельзя отменить.",
|
||||
"admin.danger.confirmTitle": "Подтверждение очистки",
|
||||
"admin.danger.confirmOk": "Удалить всё",
|
||||
"modal.confirm.title": "Подтверждение",
|
||||
"modal.confirm.ok": "Подтвердить",
|
||||
"modal.confirm.cancel": "Отмена",
|
||||
"admin.audit.eventType": "Тип события",
|
||||
"admin.audit.wrapId": "Wrap ID",
|
||||
"admin.audit.filter": "Фильтр",
|
||||
"admin.audit.allEvents": "Все события",
|
||||
"admin.audit.shown": "событий показано",
|
||||
"admin.audit.of": "из",
|
||||
"admin.audit.events": "событий",
|
||||
"admin.audit.page": "Страница",
|
||||
"admin.audit.prev": "Назад",
|
||||
"admin.audit.next": "Далее",
|
||||
"admin.audit.col.time": "Время",
|
||||
"admin.audit.col.event": "Событие",
|
||||
"admin.audit.col.ok": "OK",
|
||||
"admin.audit.col.wrap": "Wrap",
|
||||
"admin.audit.col.details": "Детали",
|
||||
"admin.audit.yes": "да",
|
||||
"admin.audit.no": "нет",
|
||||
"admin.audit.empty": "Нет событий",
|
||||
},
|
||||
};
|
||||
|
||||
const listeners = [];
|
||||
|
||||
function current() {
|
||||
const stored = localStorage.getItem(KEY);
|
||||
if (stored === "en" || stored === "ru") return stored;
|
||||
return "ru";
|
||||
}
|
||||
|
||||
function t(key, vars) {
|
||||
const lang = current();
|
||||
let text = (dict[lang] && dict[lang][key]) || dict.en[key] || key;
|
||||
if (vars) {
|
||||
Object.entries(vars).forEach(([k, v]) => {
|
||||
text = text.replaceAll(`{${k}}`, String(v));
|
||||
});
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function locale() {
|
||||
return current() === "ru" ? "ru-RU" : "en-GB";
|
||||
}
|
||||
|
||||
function formatExpires(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return String(iso);
|
||||
const datetime = new Intl.DateTimeFormat(locale(), {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(d);
|
||||
const diffMs = d.getTime() - Date.now();
|
||||
let relative = t("relative.soon");
|
||||
if (diffMs > 0) {
|
||||
const mins = Math.round(diffMs / 60000);
|
||||
if (mins < 60) relative = t("relative.inMinutes", { n: Math.max(1, mins) });
|
||||
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 });
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const lang = current();
|
||||
document.documentElement.lang = lang;
|
||||
const label = document.getElementById("lang-label");
|
||||
if (label) label.textContent = lang.toUpperCase();
|
||||
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
el.textContent = t(el.getAttribute("data-i18n"));
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||||
el.setAttribute("placeholder", t(el.getAttribute("data-i18n-placeholder")));
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-tooltip]").forEach((el) => {
|
||||
el.textContent = t(el.getAttribute("data-i18n-tooltip"));
|
||||
});
|
||||
listeners.forEach((fn) => {
|
||||
try {
|
||||
fn(lang);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onChange(fn) {
|
||||
listeners.push(fn);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
const next = current() === "en" ? "ru" : "en";
|
||||
localStorage.setItem(KEY, next);
|
||||
apply();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
apply();
|
||||
const btn = document.getElementById("lang-toggle");
|
||||
if (btn) btn.addEventListener("click", toggle);
|
||||
});
|
||||
|
||||
window.WrappedI18n = { t, apply, current, locale, formatExpires, onChange };
|
||||
})();
|
||||
@@ -0,0 +1,86 @@
|
||||
(() => {
|
||||
let root = null;
|
||||
let resolveFn = null;
|
||||
|
||||
function ensure() {
|
||||
if (root) return root;
|
||||
root = document.createElement("div");
|
||||
root.id = "confirm-modal";
|
||||
root.className = "modal-root hidden";
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-modal", "true");
|
||||
root.innerHTML = `
|
||||
<div class="modal-backdrop" data-modal-cancel></div>
|
||||
<div class="modal-panel" role="document">
|
||||
<h2 class="modal-title" id="confirm-modal-title"></h2>
|
||||
<p class="modal-message" id="confirm-modal-message"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" data-modal-cancel id="confirm-modal-cancel"></button>
|
||||
<button type="button" class="btn danger" data-modal-ok id="confirm-modal-ok"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
|
||||
root.addEventListener("click", (e) => {
|
||||
const t = e.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
if (t.closest("[data-modal-cancel]")) finish(false);
|
||||
if (t.closest("[data-modal-ok]")) finish(true);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!root || root.classList.contains("hidden")) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function finish(ok) {
|
||||
if (!root || root.classList.contains("hidden")) return;
|
||||
root.classList.add("hidden");
|
||||
document.body.classList.remove("modal-open");
|
||||
const fn = resolveFn;
|
||||
resolveFn = null;
|
||||
if (fn) fn(ok);
|
||||
}
|
||||
|
||||
function t(key, fallback) {
|
||||
return window.WrappedI18n?.t(key) || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ title?: string, message: string, confirmLabel?: string, cancelLabel?: string, danger?: boolean }} opts
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function confirm(opts) {
|
||||
const el = ensure();
|
||||
const title = opts.title || t("modal.confirm.title", "Confirm");
|
||||
const message = opts.message || "";
|
||||
const confirmLabel = opts.confirmLabel || t("modal.confirm.ok", "Confirm");
|
||||
const cancelLabel = opts.cancelLabel || t("modal.confirm.cancel", "Cancel");
|
||||
const danger = opts.danger !== false;
|
||||
|
||||
el.querySelector("#confirm-modal-title").textContent = title;
|
||||
el.querySelector("#confirm-modal-message").textContent = message;
|
||||
const okBtn = el.querySelector("#confirm-modal-ok");
|
||||
const cancelBtn = el.querySelector("#confirm-modal-cancel");
|
||||
okBtn.textContent = confirmLabel;
|
||||
cancelBtn.textContent = cancelLabel;
|
||||
okBtn.className = danger ? "btn danger" : "btn primary";
|
||||
|
||||
el.classList.remove("hidden");
|
||||
document.body.classList.add("modal-open");
|
||||
okBtn.focus();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolveFn = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
window.WrappedModal = { confirm };
|
||||
})();
|
||||
@@ -0,0 +1,30 @@
|
||||
(() => {
|
||||
const KEY = "wrapped.theme";
|
||||
|
||||
function preferred() {
|
||||
return localStorage.getItem(KEY) || "dark";
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
const sun = document.getElementById("theme-icon-sun");
|
||||
const moon = document.getElementById("theme-icon-moon");
|
||||
if (sun && moon) {
|
||||
sun.classList.toggle("hidden", theme !== "light");
|
||||
moon.classList.toggle("hidden", theme !== "dark");
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
const next = preferred() === "dark" ? "light" : "dark";
|
||||
localStorage.setItem(KEY, next);
|
||||
apply(next);
|
||||
}
|
||||
|
||||
apply(preferred());
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
apply(preferred());
|
||||
const btn = document.getElementById("theme-toggle");
|
||||
if (btn) btn.addEventListener("click", toggle);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,188 @@
|
||||
(() => {
|
||||
const t = (k) => window.WrappedI18n.t(k);
|
||||
let settings = null;
|
||||
|
||||
const els = {
|
||||
token: document.getElementById("token-input"),
|
||||
password: document.getElementById("unwrap-password"),
|
||||
btn: document.getElementById("unwrap-btn"),
|
||||
error: document.getElementById("form-error"),
|
||||
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;
|
||||
els.error.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
els.error.classList.add("hidden");
|
||||
}
|
||||
|
||||
function loadCaptcha() {
|
||||
els.captchaSlot.innerHTML = "";
|
||||
const provider = settings.captcha_provider;
|
||||
if (provider === "turnstile" && settings.turnstile_site_key) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "cf-turnstile";
|
||||
div.dataset.sitekey = settings.turnstile_site_key;
|
||||
els.captchaSlot.appendChild(div);
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
||||
s.async = true;
|
||||
document.body.appendChild(s);
|
||||
} else if (provider === "hcaptcha" && settings.hcaptcha_site_key) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "h-captcha";
|
||||
div.dataset.sitekey = settings.hcaptcha_site_key;
|
||||
els.captchaSlot.appendChild(div);
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://js.hcaptcha.com/1/api.js";
|
||||
s.async = true;
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
}
|
||||
|
||||
function captchaToken() {
|
||||
if (settings.captcha_provider === "turnstile" && window.turnstile) {
|
||||
return window.turnstile.getResponse?.() || "";
|
||||
}
|
||||
if (settings.captcha_provider === "hcaptcha" && window.hcaptcha) {
|
||||
return window.hcaptcha.getResponse?.() || "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveKey(parsed) {
|
||||
if (parsed.key) return parsed.key;
|
||||
if (location.hash && location.hash.length > 1) return location.hash.slice(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
function downloadBlob(name, blob) {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||||
}
|
||||
|
||||
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 pre = document.createElement("pre");
|
||||
pre.textContent = item.content || "";
|
||||
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);
|
||||
} 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>`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
els.btn.addEventListener("click", async () => {
|
||||
clearError();
|
||||
const parsed = window.WrappedCrypto.parseToken(els.token.value);
|
||||
if (!parsed) {
|
||||
showError(t("unwrap.unavailable"));
|
||||
return;
|
||||
}
|
||||
const key = resolveKey(parsed);
|
||||
if (!key) {
|
||||
showError(t("unwrap.needKey"));
|
||||
return;
|
||||
}
|
||||
|
||||
els.btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/wraps/${encodeURIComponent(parsed.wrapId)}/unwrap`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
password: els.password.value || null,
|
||||
captcha_token: captchaToken() || null,
|
||||
}),
|
||||
});
|
||||
if (resp.status === 403) {
|
||||
showError(t("unwrap.badPassword"));
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
showError(t("unwrap.unavailable"));
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const ciphertext = window.WrappedCrypto.base64ToBytes(data.ciphertext_b64);
|
||||
let pack;
|
||||
try {
|
||||
pack = await window.WrappedCrypto.decryptPackage(
|
||||
ciphertext,
|
||||
key,
|
||||
els.password.value || null
|
||||
);
|
||||
} catch (err) {
|
||||
if (err.message === "bad_password" || err.message === "password_required") {
|
||||
showError(t("unwrap.badPassword"));
|
||||
return;
|
||||
}
|
||||
showError(t("common.error"));
|
||||
return;
|
||||
}
|
||||
renderPackage(pack);
|
||||
els.form.classList.add("hidden");
|
||||
els.result.classList.remove("hidden");
|
||||
history.replaceState(null, "", location.pathname);
|
||||
} catch {
|
||||
showError(t("common.error"));
|
||||
} finally {
|
||||
els.btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
const resp = await fetch("/api/v1/settings");
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
init().catch(() => showError(t("common.error")));
|
||||
})();
|
||||
Reference in New Issue
Block a user