Улучшить unwrap, UI и админку; обновить документацию.

- Неверный пароль не сжигает wrap сразу: Argon2-гейт до consume и лимит попыток (по умолчанию 3) в настройках
- Подсветка синтаксиса, автоопределение языка, спиннеры, размеры файлов, пагинация audit
- make push (git, Ctrl-D) и актуальный README
This commit is contained in:
2026-07-18 10:17:05 +03:00
parent 8f2d798e1a
commit 8c8dbd2348
21 changed files with 1170 additions and 178 deletions
+31 -1
View File
@@ -187,6 +187,9 @@ async def settings_save(
"rate_limit_admin_login_per_minute", row.rate_limit_admin_login_per_minute
)
row.audit_retention_days = as_int("audit_retention_days", row.audit_retention_days)
row.password_max_attempts = min(
50, max(1, as_int("password_max_attempts", row.password_max_attempts or 3))
)
row.turnstile_site_key = str(form.get("turnstile_site_key") or "").strip()
row.hcaptcha_site_key = str(form.get("hcaptcha_site_key") or "").strip()
@@ -274,6 +277,25 @@ async def danger_page(
)
def _audit_page_window(page: int, pages: int, radius: int = 2) -> list[int | None]:
"""Page numbers with None as ellipsis gaps, e.g. [1, None, 4, 5, 6, None, 20]."""
if pages <= 1:
return [1] if pages == 1 else []
keep = {1, pages, page}
for i in range(page - radius, page + radius + 1):
if 1 <= i <= pages:
keep.add(i)
ordered = sorted(keep)
window: list[int | None] = []
prev = 0
for n in ordered:
if prev and n - prev > 1:
window.append(None)
window.append(n)
prev = n
return window
@router.get("/audit")
async def audit_page(
request: Request,
@@ -284,7 +306,11 @@ async def audit_page(
wrap_id = request.query_params.get("wrap_id") or None
if event_type == "":
event_type = None
per_page = 50
try:
per_page = int(request.query_params.get("per_page") or "25")
except ValueError:
per_page = 25
per_page = min(100, max(10, per_page))
try:
page = max(1, int(request.query_params.get("page") or "1"))
except ValueError:
@@ -317,11 +343,14 @@ async def audit_page(
"page": page,
"pages": pages,
"per_page": per_page,
"per_page_options": [10, 25, 50, 100],
"page_window": _audit_page_window(page, pages),
"total": total,
"from_idx": from_idx,
"to_idx": to_idx,
"has_prev": page > 1,
"has_next": page < pages,
"show_pagination": total > 0 and pages > 1,
},
)
@@ -340,6 +369,7 @@ async def admin_settings_api(
"mime_allowlist": row.mime_allowlist,
"captcha_provider": row.captcha_provider.value,
"password_mode": row.password_mode.value,
"password_max_attempts": row.password_max_attempts,
"audit_retention_days": row.audit_retention_days,
"rate_limit_create_per_minute": row.rate_limit_create_per_minute,
"rate_limit_unwrap_per_minute": row.rate_limit_unwrap_per_minute,
+53 -12
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.models import PasswordMode, Wrap, WrapStatus
from app.models import Wrap, WrapStatus
from app.schemas import (
PublicSettingsOut,
UnwrapRequest,
@@ -111,12 +111,13 @@ async def create_wrap(
)
raise HTTPException(status_code=400, detail="mime_not_allowed")
# Always store Argon2 hash when a password is set so wrong guesses
# are rejected *before* the one-time ciphertext is consumed.
password_hash = None
if body.has_password:
if settings.password_mode == PasswordMode.server_gate:
if not body.password:
raise HTTPException(status_code=400, detail="password_required")
password_hash = hash_password(body.password)
if not body.password:
raise HTTPException(status_code=400, detail="password_required")
password_hash = hash_password(body.password)
wrap_id = new_wrap_id()
object_key = f"wraps/{wrap_id}.bin"
@@ -250,21 +251,61 @@ async def unwrap(
)
fail("unavailable")
if (
wrap.has_password
and wrap.password_mode == PasswordMode.server_gate
and wrap.password_hash
):
if wrap.has_password and wrap.password_hash:
max_attempts = max(1, int(settings.password_max_attempts or 3))
if not body.password or not verify_password(wrap.password_hash, body.password):
wrap.password_fail_count = int(wrap.password_fail_count or 0) + 1
remaining = max(0, max_attempts - wrap.password_fail_count)
now_fail = datetime.now(timezone.utc)
if wrap.password_fail_count >= max_attempts:
wrap.status = WrapStatus.consumed
wrap.consumed_at = now_fail
await delete_wrap_object(db, wrap)
await db.commit()
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={
"reason": "password_attempts_exceeded",
"attempts": wrap.password_fail_count,
"attempts_max": max_attempts,
},
)
raise HTTPException(
status_code=403,
detail={
"code": "password_locked",
"attempts_remaining": 0,
"attempts_max": max_attempts,
},
)
await db.commit()
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "bad_password"},
details={
"reason": "bad_password",
"attempts": wrap.password_fail_count,
"attempts_remaining": remaining,
"attempts_max": max_attempts,
},
)
raise HTTPException(
status_code=403,
detail={
"code": "bad_password",
"attempts_remaining": remaining,
"attempts_max": max_attempts,
},
)
raise HTTPException(status_code=403, detail="bad_password")
# Atomic consume
from sqlalchemy import update
+3
View File
@@ -63,6 +63,8 @@ class AppSettings(Base):
Enum(PasswordMode, name="password_mode"),
default=PasswordMode.client_only,
)
# Wrong unwrap passwords allowed before the wrap is burned (default 3).
password_max_attempts: Mapped[int] = mapped_column(Integer, default=3)
audit_retention_days: Mapped[int] = mapped_column(Integer, default=90)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
@@ -85,6 +87,7 @@ class Wrap(Base):
item_count: Mapped[int] = mapped_column(Integer, default=1)
has_password: Mapped[bool] = mapped_column(Boolean, default=False)
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
password_fail_count: Mapped[int] = mapped_column(Integer, default=0)
password_mode: Mapped[PasswordMode] = mapped_column(
Enum(PasswordMode, name="password_mode", create_constraint=False),
default=PasswordMode.client_only,
+2
View File
@@ -16,6 +16,7 @@ class PublicSettingsOut(BaseModel):
hcaptcha_site_key: str
password_mode: str
password_mode_description: dict[str, str]
password_max_attempts: int
class WrapCreateRequest(BaseModel):
@@ -66,6 +67,7 @@ class AdminSettingsUpdate(BaseModel):
turnstile_site_key: str | None = None
hcaptcha_site_key: str | None = None
password_mode: str | None = None
password_max_attempts: int | None = Field(default=None, ge=1, le=50)
audit_retention_days: int | None = None
+6 -5
View File
@@ -13,14 +13,14 @@ from app.models import (
PASSWORD_MODE_HELP = {
"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."
"Password also wraps ciphertext in the browser (PBKDF2 + AES-GCM). "
"Server still stores an Argon2id hash and rejects wrong passwords before "
"the one-time unwrap, so a mistyped password does not burn the package."
),
"server_gate": (
"Server stores an Argon2id hash and releases ciphertext only after a correct password. "
"Slightly weaker ZK metadata (server knows password success/fail) but stops offline "
"bruteforce if someone steals the share link without the password."
"Ciphertext is still encrypted client-side with the same password. "
"Stops offline bruteforce if someone steals the share link without the password."
),
}
@@ -69,4 +69,5 @@ def public_settings_payload(row: AppSettings) -> dict:
"hcaptcha_site_key": row.hcaptcha_site_key or "",
"password_mode": row.password_mode.value,
"password_mode_description": PASSWORD_MODE_HELP,
"password_max_attempts": max(1, int(row.password_max_attempts or 3)),
}
+313 -5
View File
@@ -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;
+10 -3
View File
@@ -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
View File
@@ -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);
});
}
+198
View File
@@ -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
View File
@@ -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,
};
})();
+52
View File
@@ -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
View File
@@ -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];
+29 -18
View File
@@ -1,7 +1,7 @@
{% extends "admin_base.html" %}
{% block admin_content %}
<section class="audit-page">
<form class="audit-filters" method="get" action="/admin/audit">
<form class="audit-filters" id="audit-filter-form" method="get" action="/admin/audit">
<input type="hidden" name="page" value="1" />
<label class="audit-field">
<span data-i18n="admin.audit.eventType">Event type</span>
@@ -16,6 +16,14 @@
<span data-i18n="admin.audit.wrapId">Wrap ID</span>
<input name="wrap_id" value="{{ wrap_id }}" class="mono" placeholder="…" autocomplete="off" />
</label>
<label class="audit-field">
<span data-i18n="admin.audit.perPage">Per page</span>
<select name="per_page" id="audit-per-page">
{% for n in per_page_options %}
<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button class="btn primary audit-filter-btn" type="submit" data-i18n="admin.audit.filter">Filter</button>
</form>
@@ -46,7 +54,7 @@
<tbody>
{% for e in events %}
<tr class="{% if not e.success %}audit-row-fail{% endif %}">
<td class="mono audit-time">{{ e.created_at }}</td>
<td class="mono audit-time" data-ts="{{ e.created_at.isoformat() if e.created_at else '' }}">{{ e.created_at }}</td>
<td><span class="audit-event-pill">{{ e.event_type }}</span></td>
<td>
{% if e.success %}
@@ -69,34 +77,39 @@
</table>
</div>
{% if pages > 1 %}
{% if show_pagination %}
<nav class="pagination" aria-label="Pagination">
{% if has_prev %}
<a class="btn" href="/admin/audit?page={{ page - 1 }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<a class="btn pagination-nav" href="/admin/audit?page={{ page - 1 }}&amp;per_page={{ per_page }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span data-i18n="admin.audit.prev">Prev</span>
</a>
{% else %}
<span class="btn" aria-disabled="true" disabled>
<span class="btn pagination-nav" aria-disabled="true">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span data-i18n="admin.audit.prev">Prev</span>
</span>
{% endif %}
<span class="pagination-status">
<span data-i18n="admin.audit.page">Page</span>
{{ page }}
<span data-i18n="admin.audit.of">of</span>
{{ pages }}
</span>
<div class="pagination-pages">
{% for p in page_window %}
{% if p is none %}
<span class="pagination-ellipsis" aria-hidden="true"></span>
{% elif p == page %}
<span class="pagination-page is-current" aria-current="page">{{ p }}</span>
{% else %}
<a class="pagination-page" href="/admin/audit?page={{ p }}&amp;per_page={{ per_page }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">{{ p }}</a>
{% endif %}
{% endfor %}
</div>
{% if has_next %}
<a class="btn" href="/admin/audit?page={{ page + 1 }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<a class="btn pagination-nav" href="/admin/audit?page={{ page + 1 }}&amp;per_page={{ per_page }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<span data-i18n="admin.audit.next">Next</span>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</a>
{% else %}
<span class="btn" aria-disabled="true" disabled>
<span class="btn pagination-nav" aria-disabled="true">
<span data-i18n="admin.audit.next">Next</span>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</span>
@@ -104,9 +117,7 @@
</nav>
{% endif %}
</section>
<script>
document.getElementById("audit-event-type")?.addEventListener("change", (e) => {
e.target.form?.requestSubmit();
});
</script>
{% endblock %}
{% block admin_scripts %}
<script src="/static/js/admin-audit.js"></script>
{% endblock %}
+1
View File
@@ -68,5 +68,6 @@
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/modal.js"></script>
{% block admin_scripts %}{% endblock %}
</body>
</html>
+14
View File
@@ -80,6 +80,20 @@
</span>
</label>
{% endfor %}
<label class="admin-field-spaced">
<span data-i18n="admin.password.maxAttempts">Wrong password attempts (unwrap)</span>
<input
name="password_max_attempts"
type="number"
min="1"
max="50"
step="1"
value="{{ settings.password_max_attempts or 3 }}"
/>
<small class="hint" data-i18n="admin.password.maxAttemptsHint">
After this many wrong passwords the wrap is destroyed. Default: 3.
</small>
</label>
</section>
<section class="admin-tabpanel" data-panel="captcha" role="tabpanel" hidden>
+5
View File
@@ -12,6 +12,8 @@
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link id="hljs-theme-dark" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css" />
<link id="hljs-theme-light" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css" disabled />
{% block head %}{% endblock %}
</head>
<body>
@@ -68,8 +70,11 @@
· <a href="https://devops.org.ru" target="_blank" rel="noopener noreferrer">devops.org.ru</a>
</p>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/ui.js"></script>
<script src="/static/js/highlight-ui.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+30 -19
View File
@@ -2,27 +2,26 @@
{% block content %}
<section class="hero-panel create-panel">
<div class="composer">
<div class="field-row">
<label for="text-language" data-i18n="create.language">Language</label>
<select id="text-language">
<option value="plaintext" data-i18n="lang.plaintext">Обычный текст</option>
<option value="markdown" data-i18n="lang.markdown">Markdown</option>
<option value="json" data-i18n="lang.json">JSON</option>
<option value="yaml" data-i18n="lang.yaml">YAML</option>
<option value="python" data-i18n="lang.python">Python</option>
<option value="javascript" data-i18n="lang.javascript">JavaScript</option>
<option value="typescript" data-i18n="lang.typescript">TypeScript</option>
<option value="go" data-i18n="lang.go">Go</option>
<option value="rust" data-i18n="lang.rust">Rust</option>
<option value="sql" data-i18n="lang.sql">SQL</option>
<option value="bash" data-i18n="lang.bash">Bash</option>
<option value="html" data-i18n="lang.html">HTML</option>
<option value="css" data-i18n="lang.css">CSS</option>
</select>
<div class="lang-bar">
<div class="lang-current">
<span class="lang-label" data-i18n="create.language">Highlight language</span>
<button type="button" class="lang-chip" id="lang-chip" aria-haspopup="dialog">
<span id="lang-chip-text">Plain text</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
</div>
<button type="button" class="btn tiny ghost" id="lang-change" data-i18n="create.languageChange">
Change type
</button>
<input type="hidden" id="text-language" value="plaintext" />
</div>
<p id="lang-hint" class="lang-hint hidden"></p>
<label class="sr-only" for="payload-text" data-i18n="create.text">Text</label>
<textarea id="payload-text" class="code-input" spellcheck="false" data-i18n-placeholder="create.textPlaceholder" placeholder="Paste secrets, configs, notes…"></textarea>
<div class="code-editor" id="code-editor">
<pre class="code-highlight" aria-hidden="true"><code id="code-highlight" class="hljs"></code></pre>
<textarea id="payload-text" class="code-input" spellcheck="false" data-i18n-placeholder="create.textPlaceholder" placeholder="Paste secrets, configs, notes…"></textarea>
</div>
<div id="dropzone" class="dropzone" tabindex="0">
<p data-i18n="create.drop">Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)</p>
@@ -71,10 +70,22 @@
<input id="share-token" class="mono" readonly />
<button type="button" class="btn" id="copy-token" data-i18n="common.copy">Copy</button>
</div>
<p class="meta" id="expires-meta"></p>
<p class="expires-meta" id="expires-meta"></p>
<button type="button" class="btn ghost" id="create-another" data-i18n="create.another">Create another</button>
</div>
</section>
<div id="lang-modal" class="modal-root hidden" role="dialog" aria-modal="true" aria-labelledby="lang-modal-title">
<div class="modal-backdrop" data-lang-close></div>
<div class="modal-panel lang-modal-panel" role="document">
<h2 class="modal-title" id="lang-modal-title" data-i18n="create.languagePickTitle">Text type</h2>
<p class="modal-message" data-i18n="create.languagePickHint">Choose how the text should be highlighted.</p>
<div class="lang-grid" id="lang-grid"></div>
<div class="modal-actions">
<button type="button" class="btn" data-lang-close data-i18n="modal.confirm.cancel">Cancel</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/crypto.js"></script>
+3 -1
View File
@@ -18,7 +18,9 @@
</div>
<div id="captcha-slot" class="captcha-slot"></div>
<p id="form-error" class="error hidden"></p>
<button type="button" id="unwrap-btn" class="btn primary" data-i18n="unwrap.submit">Unwrap</button>
<div class="actions actions-center">
<button type="button" id="unwrap-btn" class="btn primary" data-i18n="unwrap.submit">Unwrap</button>
</div>
</div>
<div id="result-panel" class="result-panel hidden">