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