From 8361b8e3f2595a48f30ff0241acd14a9dbaeebc8 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 18 Jul 2026 23:50:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D1=83=20=D0=B8=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D1=83=20?= =?UTF-8?q?=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0;=20=D0=B4=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20UI=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=BA=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Страница /admin/stats: MinIO, pending/consumed, загрузки, audit-метрики - Кнопка «Очистить» в аудите с подтверждением - Красивые callout’ы и ошибки пароля; пустой пароль не тратит попытку - Убрать автоопределение языка; «Расшифруй» в RU UI --- README.md | 25 +-- app/api/admin.py | 44 ++++- app/api/wraps.py | 26 ++- app/services/audit.py | 8 + app/services/stats.py | 159 ++++++++++++++++ app/services/storage.py | 23 +++ app/static/css/app.css | 336 +++++++++++++++++++++++++++++++-- app/static/js/admin-audit.js | 17 ++ app/static/js/create.js | 59 +----- app/static/js/highlight-ui.js | 115 ----------- app/static/js/i18n.js | 114 +++++++++-- app/static/js/unwrap.js | 92 +++++++-- app/templates/admin_audit.html | 39 +++- app/templates/admin_base.html | 6 +- app/templates/admin_stats.html | 167 ++++++++++++++++ app/templates/create.html | 14 +- app/templates/unwrap.html | 21 ++- 17 files changed, 1007 insertions(+), 258 deletions(-) create mode 100644 app/services/stats.py create mode 100644 app/templates/admin_stats.html diff --git a/README.md b/README.md index 9a01f45..b990b99 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - zero-knowledge шифрование на клиенте (AES-GCM) - одноразовое открытие (unwrap) — ciphertext удаляется с сервера - опциональный пароль с лимитом попыток (по умолчанию 3, настраивается в админке) -- подсветка синтаксиса (highlight.js), автоопределение языка текста +- подсветка синтаксиса (highlight.js), ручной выбор языка текста - вложения: drag-and-drop, выбор файлов, вставка скриншота из буфера - RU / EN, светлая и тёмная тема - CAPTCHA: Cloudflare Turnstile и/или hCaptcha @@ -323,13 +323,14 @@ services: ## Админка -- UI: `/admin` (логин `/admin/login`) +- UI: `/admin` (логин `/admin/login`) → после входа открывается **Статистика** +- **Stats** — MinIO (объём нерасшифрованного ciphertext), расшифровано / pending, загрузки за всё время, статусы wraps, audit-счётчики - **Limits** — размер upload, TTL, retention audit - **Rate limits** — create / unwrap / admin login - **MIME** — allowlist - **Password** — режим (`client_only` / `server_gate`) и лимит неверных вводов при unwrap - **CAPTCHA** — провайдер и site keys -- **Audit** — фильтры, пагинация (10/25/50/100 на страницу), номера страниц +- **Audit** — фильтры, пагинация, очистка лога - **Danger** — полная очистка wraps и объектов в MinIO - JSON Admin API: HTTP Basic (`ADMIN_USERNAME` / `ADMIN_PASSWORD`) @@ -348,19 +349,13 @@ services: Серверного «зашифруй за меня» нет. -При неверном пароле unwrap возвращает `403` с телом вида: +Ошибки пароля при unwrap — `403`: -```json -{ - "detail": { - "code": "bad_password", - "attempts_remaining": 2, - "attempts_max": 3 - } -} -``` - -После исчерпания попыток: `"code": "password_locked"`, wrap уничтожен. +| `detail.code` | Когда | +|---------------|--------| +| `password_required` | пароль не введён (попытка не списывается) | +| `bad_password` | неверный пароль (`attempts_remaining` / `attempts_max`) | +| `password_locked` | попытки исчерпаны, wrap уничтожен | При `DOCS_ENABLED=true` — Swagger `/docs` и OpenAPI `/openapi.json`. diff --git a/app/api/admin.py b/app/api/admin.py index 1eb310a..56f86a1 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -20,11 +20,13 @@ from app.services.audit import ( count_audit, list_audit, list_audit_event_types, + purge_all_audit, purge_old_audit, write_audit, ) from app.services.rate_limit import rate_limiter from app.services.settings_service import PASSWORD_MODE_HELP, get_or_create_settings +from app.services.stats import collect_stats from app.services.wraps import cleanup_expired_meta, expire_due_wraps, purge_all_wraps, request_meta router = APIRouter(prefix="/admin", include_in_schema=False) @@ -35,14 +37,14 @@ templates = Jinja2Templates(directory="app/templates") @router.get("") async def admin_root(request: Request): if get_admin_user(request): - return RedirectResponse("/admin/settings", status_code=302) + return RedirectResponse("/admin/stats", status_code=302) return RedirectResponse("/admin/login", status_code=302) @router.get("/login") async def login_page(request: Request): if get_admin_user(request): - return RedirectResponse("/admin/settings", status_code=302) + return RedirectResponse("/admin/stats", status_code=302) return templates.TemplateResponse( request, "admin_login.html", @@ -93,7 +95,7 @@ async def login_submit(request: Request, db: AsyncSession = Depends(get_db)): "cleaned_meta": cleaned, }, ) - response = RedirectResponse("/admin/settings", status_code=302) + response = RedirectResponse("/admin/stats", status_code=302) set_admin_cookie(response, username) return response @@ -122,6 +124,24 @@ async def logout(request: Request, db: AsyncSession = Depends(get_db)): return response +@router.get("/stats") +async def stats_page( + request: Request, + db: AsyncSession = Depends(get_db), + _admin: AdminWebAuth = ..., +): + stats = await collect_stats(db) + return templates.TemplateResponse( + request, + "admin_stats.html", + { + "title": "Stats", + "active": "stats", + "stats": stats, + }, + ) + + @router.get("/settings") async def settings_page( request: Request, @@ -355,6 +375,24 @@ async def audit_page( ) +@router.post("/audit/clear") +async def audit_clear( + request: Request, + db: AsyncSession = Depends(get_db), + admin: AdminWebAuth = ..., +): + meta = request_meta(request) + deleted = await purge_all_audit(db) + await write_audit( + db, + event_type="admin.audit_clear", + success=True, + **meta, + details={"admin": admin, "deleted": deleted}, + ) + return RedirectResponse(f"/admin/audit?cleared={deleted}", status_code=302) + + @api_router.get("/settings", summary="Get admin settings") async def admin_settings_api( db: AsyncSession = Depends(get_db), diff --git a/app/api/wraps.py b/app/api/wraps.py index 6abf56b..9b02d96 100644 --- a/app/api/wraps.py +++ b/app/api/wraps.py @@ -253,7 +253,31 @@ async def unwrap( 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): + # Empty password: ask to enter it, do not burn an attempt. + if not (body.password or "").strip(): + remaining = max(0, max_attempts - int(wrap.password_fail_count or 0)) + await write_audit( + db, + event_type="wrap.unwrap", + success=False, + wrap_id=wrap_id, + **meta, + details={ + "reason": "password_required", + "attempts_remaining": remaining, + "attempts_max": max_attempts, + }, + ) + raise HTTPException( + status_code=403, + detail={ + "code": "password_required", + "attempts_remaining": remaining, + "attempts_max": max_attempts, + }, + ) + + if 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) diff --git a/app/services/audit.py b/app/services/audit.py index dd74fc3..bc1940c 100644 --- a/app/services/audit.py +++ b/app/services/audit.py @@ -15,6 +15,7 @@ KNOWN_AUDIT_EVENT_TYPES = [ "admin.logout", "admin.settings_update", "admin.purge", + "admin.audit_clear", ] @@ -51,6 +52,13 @@ async def purge_old_audit(db: AsyncSession, retention_days: int) -> int: return result.rowcount or 0 +async def purge_all_audit(db: AsyncSession) -> int: + """Delete all audit events. Returns number of deleted rows.""" + result = await db.execute(delete(AuditEvent)) + await db.commit() + return int(result.rowcount or 0) + + def _audit_filters(event_type: str | None, wrap_id: str | None): filters = [] if event_type: diff --git a/app/services/stats.py b/app/services/stats.py new file mode 100644 index 0000000..c5d50d0 --- /dev/null +++ b/app/services/stats.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy import case, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import AuditEvent, Wrap, WrapStatus +from app.services.storage import storage + + +def format_bytes(n: int) -> str: + n = int(n or 0) + if n < 1024: + return f"{n} B" + kb = n / 1024 + if kb < 1024: + return f"{kb:.1f} KB" if kb < 10 else f"{round(kb)} KB" + mb = kb / 1024 + if mb < 1024: + return f"{mb:.2f} MB" if mb < 10 else f"{mb:.1f} MB" + gb = mb / 1024 + return f"{gb:.2f} GB" + + +async def collect_stats(db: AsyncSession) -> dict[str, Any]: + # Wrap aggregates by status + wrap_rows = ( + await db.execute( + select( + Wrap.status, + func.count(Wrap.id), + func.coalesce(func.sum(Wrap.size_bytes), 0), + func.coalesce(func.sum(Wrap.item_count), 0), + ).group_by(Wrap.status) + ) + ).all() + + by_status: dict[str, dict[str, int]] = { + "pending": {"count": 0, "size_bytes": 0, "items": 0}, + "consumed": {"count": 0, "size_bytes": 0, "items": 0}, + "expired": {"count": 0, "size_bytes": 0, "items": 0}, + } + for status, count, size_bytes, items in wrap_rows: + key = status.value if hasattr(status, "value") else str(status) + by_status[key] = { + "count": int(count or 0), + "size_bytes": int(size_bytes or 0), + "items": int(items or 0), + } + + totals = await db.execute( + select( + func.count(Wrap.id), + func.coalesce(func.sum(Wrap.size_bytes), 0), + func.coalesce(func.sum(Wrap.item_count), 0), + func.coalesce( + func.sum(case((Wrap.has_password.is_(True), 1), else_=0)), + 0, + ), + ) + ) + total_wraps, total_size, total_items, with_password = totals.one() + + # Last 24h / 7d creates + now = datetime.now(timezone.utc) + day_ago = now - timedelta(days=1) + week_ago = now - timedelta(days=7) + + created_24h = int( + ( + await db.execute( + select(func.count()).select_from(Wrap).where(Wrap.created_at >= day_ago) + ) + ).scalar_one() + or 0 + ) + created_7d = int( + ( + await db.execute( + select(func.count()).select_from(Wrap).where(Wrap.created_at >= week_ago) + ) + ).scalar_one() + or 0 + ) + consumed_24h = int( + ( + await db.execute( + select(func.count()) + .select_from(Wrap) + .where( + Wrap.status == WrapStatus.consumed, + Wrap.consumed_at.is_not(None), + Wrap.consumed_at >= day_ago, + ) + ) + ).scalar_one() + or 0 + ) + + # Audit counters (all-time successes / failures) + audit_rows = ( + await db.execute( + select( + AuditEvent.event_type, + AuditEvent.success, + func.count(AuditEvent.id), + ).group_by(AuditEvent.event_type, AuditEvent.success) + ) + ).all() + audit: dict[str, dict[str, int]] = {} + for event_type, success, count in audit_rows: + bucket = audit.setdefault(event_type, {"ok": 0, "fail": 0}) + if success: + bucket["ok"] += int(count or 0) + else: + bucket["fail"] += int(count or 0) + + # MinIO live objects under wraps/ + try: + minio = await storage.prefix_stats("wraps/") + minio_error = None + except Exception as exc: # noqa: BLE001 — surface soft error in UI + minio = {"objects": 0, "bytes": 0} + minio_error = str(exc)[:200] + + pending = by_status["pending"] + consumed = by_status["consumed"] + expired = by_status["expired"] + + return { + "wraps_total": int(total_wraps or 0), + "wraps_pending": pending["count"], + "wraps_consumed": consumed["count"], + "wraps_expired": expired["count"], + "wraps_with_password": int(with_password or 0), + "items_total": int(total_items or 0), + "items_pending": pending["items"], + "size_total_bytes": int(total_size or 0), + "size_pending_bytes": pending["size_bytes"], + "size_consumed_bytes": consumed["size_bytes"], + "size_expired_bytes": expired["size_bytes"], + "size_total_human": format_bytes(int(total_size or 0)), + "size_pending_human": format_bytes(pending["size_bytes"]), + "size_consumed_human": format_bytes(consumed["size_bytes"]), + "created_24h": created_24h, + "created_7d": created_7d, + "consumed_24h": consumed_24h, + "audit_creates_ok": audit.get("wrap.create", {}).get("ok", 0), + "audit_creates_fail": audit.get("wrap.create", {}).get("fail", 0), + "audit_unwraps_ok": audit.get("wrap.unwrap", {}).get("ok", 0), + "audit_unwraps_fail": audit.get("wrap.unwrap", {}).get("fail", 0), + "minio_objects": int(minio.get("objects") or 0), + "minio_bytes": int(minio.get("bytes") or 0), + "minio_human": format_bytes(int(minio.get("bytes") or 0)), + "minio_error": minio_error, + "generated_at": now, + } diff --git a/app/services/storage.py b/app/services/storage.py index 415f58a..8f553c5 100644 --- a/app/services/storage.py +++ b/app/services/storage.py @@ -85,5 +85,28 @@ class ObjectStorage: token = resp.get("NextContinuationToken") return deleted + async def prefix_stats(self, prefix: str = "wraps/") -> dict[str, int]: + """Count objects and total bytes under prefix.""" + objects = 0 + total_bytes = 0 + async with self.client() as client: + token: str | None = None + while True: + kwargs: dict[str, Any] = { + "Bucket": self.settings.s3_bucket, + "Prefix": prefix, + "MaxKeys": 1000, + } + if token: + kwargs["ContinuationToken"] = token + resp = await client.list_objects_v2(**kwargs) + for obj in resp.get("Contents") or []: + objects += 1 + total_bytes += int(obj.get("Size") or 0) + if not resp.get("IsTruncated"): + break + token = resp.get("NextContinuationToken") + return {"objects": objects, "bytes": total_bytes} + storage = ObjectStorage() diff --git a/app/static/css/app.css b/app/static/css/app.css index b4d47ff..cd90f57 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -243,17 +243,69 @@ html[data-theme="dark"] .icon-btn:hover { .lede { color: var(--muted); margin: 0 0 1.5rem; max-width: 42rem; } .composer, .success-panel, .result-panel { display: grid; gap: 0.9rem; } +.result-panel > .success-callout { + justify-self: stretch; +} .success-panel { justify-items: stretch; text-align: left; } -.success-panel > h2, -.success-panel > .warn { +.success-panel > h2 { text-align: center; + margin: 0; } .success-panel > .btn.ghost { justify-self: center; } +.success-callout { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin: 0; + padding: 0.85rem 1.1rem; + text-align: left; + border-radius: 14px; + border: 1px solid rgba(232, 168, 56, 0.35); + background: linear-gradient(160deg, rgba(232, 168, 56, 0.14), rgba(13, 20, 32, 0.28)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} +html[data-theme="light"] .success-callout { + background: linear-gradient(160deg, rgba(232, 168, 56, 0.14), rgba(255, 255, 255, 0.9)); + border-color: rgba(196, 130, 20, 0.28); +} +.success-callout-icon { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + display: grid; + place-items: center; + border-radius: 11px; + background: rgba(232, 168, 56, 0.18); + color: var(--warn); + font-size: 1rem; +} +html[data-theme="light"] .success-callout-icon { + background: rgba(232, 168, 56, 0.16); +} +.success-callout-body { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} +.success-callout-body strong { + font-size: 0.95rem; + font-weight: 650; + color: var(--text); + letter-spacing: 0.01em; +} +.success-callout-body span { + font-size: 0.82rem; + font-weight: 500; + color: var(--muted); + line-height: 1.35; +} label { display: block; font-size: 0.86rem; color: var(--muted); margin-bottom: 0.35rem; } @@ -334,16 +386,19 @@ input, select, textarea, .code-input { .lang-bar { display: flex; flex-wrap: wrap; - align-items: flex-end; + align-items: center; gap: 0.55rem 0.75rem; } .lang-current { - display: grid; - gap: 0.35rem; + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.55rem 0.7rem; } .lang-label { font-size: 0.86rem; color: var(--muted); + line-height: 1.2; } .lang-chip { appearance: none; @@ -368,11 +423,6 @@ input, select, textarea, .code-input { font-size: 0.7rem; color: var(--muted); } -.lang-hint { - margin: -0.35rem 0 0; - font-size: 0.8rem; - color: var(--warn); -} .lang-modal-panel { width: min(480px, 100%); } @@ -637,6 +687,74 @@ html[data-theme="light"] .brand-mark { .ok { color: var(--ok); } .warn { color: var(--warn); } +.form-alert { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0; + padding: 0.85rem 1.1rem; + border-radius: 14px; + border: 1px solid var(--line); + background: var(--panel); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} +.form-alert.danger { + border-color: rgba(214, 69, 85, 0.4); + background: linear-gradient(160deg, rgba(214, 69, 85, 0.14), rgba(13, 20, 32, 0.28)); +} +html[data-theme="light"] .form-alert.danger { + background: linear-gradient(160deg, rgba(214, 69, 85, 0.1), rgba(255, 255, 255, 0.92)); + border-color: rgba(214, 69, 85, 0.28); +} +.form-alert-icon { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + display: grid; + place-items: center; + border-radius: 11px; + font-size: 1rem; +} +.form-alert.danger .form-alert-icon { + background: rgba(214, 69, 85, 0.16); + color: var(--danger); +} +.form-alert-body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + min-width: 0; +} +.form-alert-body strong { + font-size: 0.95rem; + font-weight: 650; + color: var(--text); +} +.form-alert-body > span:not(.form-alert-attempts) { + font-size: 0.82rem; + font-weight: 500; + color: var(--muted); + line-height: 1.35; +} +.form-alert-attempts { + margin-top: 0.2rem; + display: inline-flex; + align-items: center; + padding: 0.18rem 0.55rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 650; + letter-spacing: 0.01em; + color: var(--warn); + background: rgba(232, 168, 56, 0.16); + border: 1px solid rgba(232, 168, 56, 0.3); +} +html[data-theme="light"] .form-alert-attempts { + background: rgba(232, 168, 56, 0.14); + border-color: rgba(196, 130, 20, 0.28); +} + .copy-row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; } .site-footer { @@ -698,6 +816,49 @@ html[data-theme="light"] .footer-pillars li { font-weight: 500; line-height: 1.25; } +@media (max-width: 720px) { + .site-footer { + max-width: 100%; + padding: 0.35rem 0.6rem 1.5rem; + } + .footer-pillars { + flex-wrap: nowrap; + gap: 0.3rem; + width: 100%; + } + .footer-pillars li { + flex: 1 1 0; + max-width: none; + min-width: 0; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 0.2rem; + padding: 0.4rem 0.3rem; + border-radius: 9px; + text-align: center; + } + .footer-pillars i { + font-size: 0.72rem; + width: auto; + } + .footer-pillars span { + align-items: center; + gap: 0.05rem; + } + .footer-pillars strong { + font-size: 0.62rem; + line-height: 1.15; + } + .footer-pillars small { + font-size: 0.55rem; + line-height: 1.2; + } + .footer-copy { + margin-top: 0.65rem; + font-size: 0.72rem; + } +} .footer-copy { margin: 0.85rem 0 0; font-size: 0.8rem; @@ -1061,6 +1222,142 @@ html[data-theme="light"] .admin-nav { padding: 0 0.25rem; margin: 0 0 0.85rem; } +.stats-page { + display: grid; + gap: 1.1rem; +} +.stats-updated { + margin: 0; + padding: 0 0.15rem; +} +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; +} +@media (max-width: 1100px) { + .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 620px) { + .stats-grid { grid-template-columns: 1fr; } +} +.stat-card { + display: grid; + gap: 0.35rem; + padding: 1rem 1.05rem; + border-radius: 14px; + border: 1px solid var(--line); + background: var(--panel); + box-shadow: var(--shadow); + min-width: 0; +} +.stat-card.accent { + border-color: rgba(61, 214, 198, 0.35); + background: linear-gradient(160deg, rgba(61, 214, 198, 0.12), rgba(13, 20, 32, 0.35)); +} +html[data-theme="light"] .stat-card.accent { + background: linear-gradient(160deg, rgba(47, 111, 237, 0.1), rgba(255, 255, 255, 0.92)); + border-color: rgba(47, 111, 237, 0.22); +} +.stat-card-head { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--muted); +} +.stat-card-head i { + color: var(--accent-2); + width: 1rem; + text-align: center; +} +.stat-card-head h2 { + margin: 0; + font-size: 0.82rem; + font-weight: 600; + line-height: 1.25; +} +.stat-value { + margin: 0.15rem 0 0; + font-size: 1.65rem; + font-weight: 700; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + color: var(--text); +} +.stat-sub { + margin: 0; + font-size: 0.78rem; + color: var(--muted); + line-height: 1.35; +} +.stat-num { + font-family: var(--font-mono); + font-weight: 650; + color: var(--accent-2); +} +.stat-error { + margin: 0.25rem 0 0; + font-size: 0.75rem; +} +.stats-sections { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 0.85rem; +} +@media (max-width: 900px) { + .stats-sections { grid-template-columns: 1fr; } +} +.stats-section { + padding: 1rem 1.05rem; + border-radius: 14px; + border: 1px solid var(--line); + background: var(--panel); +} +.stats-section h3 { + margin: 0 0 0.75rem; + font-size: 0.95rem; + font-weight: 650; +} +.stats-table-wrap { overflow: auto; } +.stats-table { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; +} +.stats-table th, +.stats-table td { + text-align: left; + padding: 0.5rem 0.55rem; + border-bottom: 1px solid var(--line); +} +.stats-table th { + color: var(--muted); + font-weight: 600; + font-size: 0.78rem; +} +.stats-total-row td { + font-weight: 650; + border-bottom: 0; +} +.stats-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.55rem; +} +.stats-list li { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: baseline; + font-size: 0.9rem; + color: var(--muted); +} +.stats-list strong { + color: var(--text); +} + .audit-page { display: grid; gap: 0.85rem; @@ -1098,13 +1395,30 @@ html[data-theme="light"] .admin-nav { height: 42px; align-self: end; } +.audit-meta-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.6rem 1rem; + padding: 0 0.2rem; +} .audit-meta { display: flex; gap: 0.4rem; align-items: baseline; color: var(--muted); font-size: 0.85rem; - padding: 0 0.2rem; +} +.audit-clear-form { + margin: 0; +} +.audit-clear-form .btn.tiny { + min-width: 6.5rem; +} +.audit-clear-form .btn[disabled] { + opacity: 0.45; + pointer-events: none; } .audit-count { color: var(--accent-2); diff --git a/app/static/js/admin-audit.js b/app/static/js/admin-audit.js index 0ee2630..3ad705c 100644 --- a/app/static/js/admin-audit.js +++ b/app/static/js/admin-audit.js @@ -2,6 +2,7 @@ const form = document.getElementById("audit-filter-form"); const typeSelect = document.getElementById("audit-event-type"); const perPage = document.getElementById("audit-per-page"); + const clearForm = document.getElementById("audit-clear-form"); function resetToFirstPageAndSubmit() { if (!form) return; @@ -13,6 +14,22 @@ typeSelect?.addEventListener("change", resetToFirstPageAndSubmit); perPage?.addEventListener("change", resetToFirstPageAndSubmit); + clearForm?.addEventListener("submit", async (e) => { + e.preventDefault(); + const t = (k, fallback) => window.WrappedI18n?.t(k) || fallback; + const ok = await window.WrappedModal.confirm({ + title: t("admin.audit.clearConfirmTitle", "Clear audit"), + message: t( + "admin.audit.clearConfirm", + "Delete all audit events? This cannot be undone." + ), + confirmLabel: t("admin.audit.clearConfirmOk", "Clear all"), + cancelLabel: t("modal.confirm.cancel", "Cancel"), + danger: true, + }); + if (ok) clearForm.submit(); + }); + document.querySelectorAll(".audit-time[data-ts]").forEach((el) => { const raw = el.getAttribute("data-ts"); if (!raw) return; diff --git a/app/static/js/create.js b/app/static/js/create.js index d1a312c..496add8 100644 --- a/app/static/js/create.js +++ b/app/static/js/create.js @@ -3,16 +3,12 @@ const files = []; let settings = null; let captchaWidgetId = null; - let langManual = false; - let detectTimer = null; const els = { text: document.getElementById("payload-text"), lang: document.getElementById("text-language"), langChip: document.getElementById("lang-chip"), langChipText: document.getElementById("lang-chip-text"), - langChange: document.getElementById("lang-change"), - langHint: document.getElementById("lang-hint"), langModal: document.getElementById("lang-modal"), langGrid: document.getElementById("lang-grid"), highlight: document.getElementById("code-highlight"), @@ -57,11 +53,10 @@ return t(`lang.${id}`) || id; } - function setLanguage(id, { manual = false, silent = false } = {}) { + function setLanguage(id, { silent = false } = {}) { const lang = window.WrappedHighlight.languages().includes(id) ? id : "plaintext"; els.lang.value = lang; if (els.langChipText) els.langChipText.textContent = langLabel(lang); - if (manual) langManual = true; if (!silent) refreshHighlight(); } @@ -77,49 +72,10 @@ function syncEditorHeight() { if (!els.text || !els.highlight) return; els.highlight.parentElement.style.height = "auto"; - // Keep highlight pre matching textarea scroll height const pre = els.highlight.parentElement; pre.style.minHeight = `${Math.max(220, els.text.scrollHeight)}px`; } - function updateLangHint(detection) { - if (!els.langHint) return; - if (langManual || !els.text.value.trim()) { - els.langHint.classList.add("hidden"); - els.langHint.textContent = ""; - return; - } - if (detection.confidence >= 0.6) { - els.langHint.classList.add("hidden"); - els.langHint.textContent = ""; - return; - } - const suggestions = (detection.suggestions || []) - .filter((l) => l !== detection.lang) - .slice(0, 3) - .map(langLabel); - els.langHint.textContent = suggestions.length - ? t("create.languageUnsure", { suggestions: suggestions.join(", ") }) - : t("create.languageUnsurePlain"); - els.langHint.classList.remove("hidden"); - } - - function autoDetectLanguage() { - if (langManual) { - refreshHighlight(); - return; - } - const detection = window.WrappedHighlight.detectLanguage(els.text.value); - setLanguage(detection.lang, { silent: true }); - refreshHighlight(); - updateLangHint(detection); - } - - function scheduleDetect() { - clearTimeout(detectTimer); - detectTimer = setTimeout(autoDetectLanguage, 220); - } - function openLangModal() { if (!els.langModal || !els.langGrid) return; els.langGrid.innerHTML = ""; @@ -129,8 +85,7 @@ btn.className = "lang-option" + (id === els.lang.value ? " active" : ""); btn.textContent = langLabel(id); btn.addEventListener("click", () => { - setLanguage(id, { manual: true }); - updateLangHint({ confidence: 1, suggestions: [] }); + setLanguage(id); closeLangModal(); }); els.langGrid.appendChild(btn); @@ -283,20 +238,11 @@ if (pasted.length) { e.preventDefault(); addFiles(pasted); - return; - } - // Text paste into textarea triggers input; detect after paste event - if (document.activeElement === els.text) { - setTimeout(() => { - langManual = false; - autoDetectLanguage(); - }, 0); } }); els.text.addEventListener("input", () => { refreshHighlight(); - scheduleDetect(); }); els.text.addEventListener("scroll", () => { const pre = els.highlight.parentElement; @@ -305,7 +251,6 @@ }); els.langChip?.addEventListener("click", openLangModal); - els.langChange?.addEventListener("click", openLangModal); els.langModal?.addEventListener("click", (e) => { if (e.target.closest("[data-lang-close]")) closeLangModal(); }); diff --git a/app/static/js/highlight-ui.js b/app/static/js/highlight-ui.js index 79a50c2..652a6b2 100644 --- a/app/static/js/highlight-ui.js +++ b/app/static/js/highlight-ui.js @@ -15,120 +15,6 @@ "css", ]; - function tryParseJson(text) { - try { - JSON.parse(text); - return true; - } catch { - return false; - } - } - - /** - * @returns {{ lang: string, confidence: number, suggestions: string[] }} - */ - function detectLanguage(text) { - const raw = (text || "").trim(); - if (!raw) { - return { lang: "plaintext", confidence: 0, suggestions: LANGUAGES }; - } - - const scores = Object.fromEntries(LANGUAGES.map((l) => [l, 0])); - - if ( - (raw.startsWith("{") || raw.startsWith("[")) && - tryParseJson(raw) - ) { - scores.json += 10; - } else if (/^\s*[{[]/.test(raw) && /["']?\w+["']?\s*:/.test(raw)) { - scores.json += 4; - } - - if ( - /^---\s*$/m.test(raw) || - (/^[\w.-]+\s*:\s+\S+/m.test(raw) && - !tryParseJson(raw) && - !raw.startsWith("<")) - ) { - scores.yaml += 5; - } - if (/^\s*-\s+\w+/m.test(raw) && /:\s/.test(raw)) scores.yaml += 2; - - if ( - /\b(def|class|import|from|async def)\b/.test(raw) || - /^\s*@\w+/m.test(raw) - ) { - scores.python += 5; - } - if (/print\(|__name__|self\./.test(raw)) scores.python += 2; - - if ( - /\b(function|const|let|var|=>|console\.)\b/.test(raw) || - /\brequire\s*\(/.test(raw) - ) { - scores.javascript += 4; - } - if (/\b(interface|type)\s+\w+|:\s*\w+(\[\])?\s*[=;]/.test(raw)) { - scores.typescript += 5; - } - if (/\bimport\s+type\b|\bas\s+const\b/.test(raw)) scores.typescript += 2; - - if (/\b(package|func|fmt\.|:=)\b/.test(raw)) scores.go += 5; - if (/\b(fn |let mut|impl |pub fn|cargo)\b/.test(raw)) scores.rust += 5; - if ( - /\b(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE|FROM|WHERE)\b/i.test(raw) - ) { - scores.sql += 6; - } - if (/^\s*#!.*\b(bash|sh|zsh)\b/m.test(raw) || /^\s*(sudo |export |echo )/m.test(raw)) { - scores.bash += 4; - } - if (/<\/?[a-zA-Z][\w:-]*[\s>]/.test(raw) || raw.startsWith(" lang !== "plaintext") - .sort((a, b) => b[1] - a[1]); - const best = ranked[0]; - const second = ranked[1]; - - if (!best || best[1] < 3) { - return { - lang: "plaintext", - confidence: 0.2, - suggestions: ranked - .filter(([, s]) => s > 0) - .slice(0, 4) - .map(([l]) => l) - .concat(["plaintext"]), - }; - } - - const confidence = - best[1] >= 8 - ? 0.95 - : best[1] >= 5 - ? 0.75 - : second && best[1] - second[1] <= 1 - ? 0.45 - : 0.6; - - const suggestions = ranked - .filter(([, s]) => s > 0) - .slice(0, 5) - .map(([l]) => l); - if (!suggestions.includes("plaintext")) suggestions.push("plaintext"); - - return { lang: best[0], confidence, suggestions }; - } - function hljsLang(lang) { const map = { plaintext: "plaintext", @@ -182,7 +68,6 @@ } window.WrappedHighlight = { - detectLanguage, highlightElement, syncThemeStylesheet, languages, diff --git a/app/static/js/i18n.js b/app/static/js/i18n.js index 8beb012..dffe2c2 100644 --- a/app/static/js/i18n.js +++ b/app/static/js/i18n.js @@ -22,11 +22,8 @@ "create.title": "Wrap. Send. Vanish.", "create.lede": "Wrap text, images or files into a one-time token. Encryption happens in your browser — the server never sees plaintext.", "create.language": "Highlight language", - "create.languageChange": "Change type", "create.languagePickTitle": "Text type", "create.languagePickHint": "Choose how the text should be highlighted.", - "create.languageUnsure": "Not sure — maybe: {suggestions}. Or change type.", - "create.languageUnsurePlain": "Could not detect type — change type if needed.", "create.text": "Text", "create.textPlaceholder": "Paste secrets, configs, notes…", "create.drop": "Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)", @@ -39,7 +36,8 @@ "create.working": "Encrypting and uploading…", "create.openToken": "Open a token", "create.successTitle": "Token issued", - "create.successWarn": "One-time unwrap. After someone opens it, ciphertext is destroyed on the server.", + "create.successWarnTitle": "One-time unwrap", + "create.successWarnHint": "After opening, ciphertext is deleted on the server.", "create.shareLink": "Share link", "create.token": "Wrapped token", "create.another": "Create another", @@ -79,17 +77,23 @@ "unwrap.password": "Password (if set)", "unwrap.submit": "Unwrap", "unwrap.working": "Decrypting…", - "unwrap.destroyed": "Server copy destroyed. Preview lives only in this browser session.", + "unwrap.destroyedTitle": "Server copy destroyed", + "unwrap.destroyedHint": "Preview lives only in this browser session.", "unwrap.needKey": "Missing encryption key. Open the full share link (with #key) or paste the full wrapped token.", - "unwrap.badPassword": "Wrong password.", - "unwrap.badPasswordRemaining": "Wrong password. Attempts left: {n} of {max}.", - "unwrap.passwordLocked": "Too many wrong passwords. This wrap has been destroyed.", + "unwrap.badPassword": "Wrong password", + "unwrap.badPasswordHint": "Check the password and try again.", + "unwrap.passwordRequired": "Password required", + "unwrap.passwordRequiredHint": "This wrap is password-protected.", + "unwrap.attemptsLeft": "{n} of {max} attempts left", + "unwrap.passwordLocked": "Too many wrong passwords", + "unwrap.passwordLockedHint": "This wrap has been destroyed.", "unwrap.unavailable": "Unavailable (already used, expired, or invalid).", "common.copy": "Copy", "common.copied": "Copied", "common.download": "Download", "common.error": "Something went wrong.", "common.working": "Working…", + "admin.nav.stats": "Stats", "admin.nav.settings": "Settings", "admin.nav.audit": "Audit", "admin.nav.danger": "Danger", @@ -97,6 +101,33 @@ "admin.nav.logout": "Logout", "admin.brand.tagline": "Admin console", "admin.settings.title": "Settings", + "admin.stats.title": "Statistics", + "admin.stats.updated": "Updated", + "admin.stats.minioTitle": "MinIO (pending ciphertext)", + "admin.stats.objects": "object(s)", + "admin.stats.dbPending": "DB pending", + "admin.stats.unwrappedTitle": "Unwrapped (all time)", + "admin.stats.auditOk": "audit ok", + "admin.stats.last24h": "24h", + "admin.stats.pendingTitle": "Not unwrapped", + "admin.stats.items": "item(s)", + "admin.stats.uploadedTitle": "Uploaded (all time)", + "admin.stats.wrapsSection": "Wraps by status", + "admin.stats.extraSection": "More", + "admin.stats.col.status": "Status", + "admin.stats.col.count": "Count", + "admin.stats.col.size": "Size", + "admin.stats.col.items": "Items", + "admin.stats.status.pending": "pending", + "admin.stats.status.consumed": "consumed", + "admin.stats.status.expired": "expired", + "admin.stats.total": "Total", + "admin.stats.withPassword": "With password", + "admin.stats.created24h": "Created (24h)", + "admin.stats.created7d": "Created (7d)", + "admin.stats.createsOk": "Creates ok (audit)", + "admin.stats.createsFail": "Creates fail (audit)", + "admin.stats.unwrapsFail": "Unwrap fails (audit)", "admin.audit.title": "Audit", "admin.tab.limits": "Limits", "admin.tab.rate": "Rate limits", @@ -166,6 +197,12 @@ "admin.audit.filter": "Filter", "admin.audit.allEvents": "All events", "admin.audit.perPage": "Per page", + "admin.audit.clear": "Clear", + "admin.audit.clearConfirmTitle": "Clear audit", + "admin.audit.clearConfirm": "Delete all audit events? This cannot be undone.", + "admin.audit.clearConfirmOk": "Clear all", + "admin.audit.clearedPrefix": "Cleared", + "admin.audit.clearedSuffix": "audit event(s).", "admin.audit.shown": "events shown", "admin.audit.of": "of", "admin.audit.events": "events", @@ -202,11 +239,8 @@ "create.title": "Упакуй. Отправь. Исчезни.", "create.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.", "create.language": "Язык подсветки", - "create.languageChange": "Изменить тип", "create.languagePickTitle": "Тип текста", "create.languagePickHint": "Выберите, как подсвечивать текст.", - "create.languageUnsure": "Не уверен — возможно: {suggestions}. Или измените тип.", - "create.languageUnsurePlain": "Не удалось определить тип — при необходимости измените вручную.", "create.text": "Текст", "create.textPlaceholder": "Вставь секреты, конфиги, заметки…", "create.drop": "Перетащи файлы, выбери или вставь скриншот из буфера (⌘V / Ctrl+V)", @@ -219,7 +253,8 @@ "create.working": "Шифрование и загрузка…", "create.openToken": "Открыть токен", "create.successTitle": "Токен выдан", - "create.successWarn": "Unwrap один раз. После открытия ciphertext удаляется на сервере.", + "create.successWarnTitle": "Расшифруй один раз", + "create.successWarnHint": "После открытия ciphertext удаляется на сервере.", "create.shareLink": "Ссылка", "create.token": "Wrapped-токен", "create.another": "Создать ещё", @@ -251,7 +286,7 @@ "relative.inHours": "через {n} ч", "relative.inDays": "через {n} дн", "relative.soon": "скоро", - "unwrap.eyebrow": "Unwrap", + "unwrap.eyebrow": "Расшифруй", "unwrap.title": "Открыть один раз", "unwrap.lede": "Вставь wrapped-токен или открой ссылку. Ciphertext скачивается один раз и удаляется на сервере.", "unwrap.token": "Wrapped-токен или ID", @@ -259,17 +294,23 @@ "unwrap.password": "Пароль (если задан)", "unwrap.submit": "Расшифровать", "unwrap.working": "Расшифровка…", - "unwrap.destroyed": "Копия на сервере уничтожена. Превью только в этой сессии браузера.", + "unwrap.destroyedTitle": "Копия на сервере уничтожена", + "unwrap.destroyedHint": "Превью только в этой сессии браузера.", "unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.", - "unwrap.badPassword": "Неверный пароль.", - "unwrap.badPasswordRemaining": "Неверный пароль. Осталось попыток: {n} из {max}.", - "unwrap.passwordLocked": "Слишком много неверных паролей. Этот wrap уничтожен.", + "unwrap.badPassword": "Неверный пароль", + "unwrap.badPasswordHint": "Проверьте пароль и попробуйте снова.", + "unwrap.passwordRequired": "Нужен пароль", + "unwrap.passwordRequiredHint": "Этот wrap защищён паролем.", + "unwrap.attemptsLeft": "Осталось попыток: {n} из {max}", + "unwrap.passwordLocked": "Слишком много неверных паролей", + "unwrap.passwordLockedHint": "Этот wrap уничтожен.", "unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).", "common.copy": "Копировать", "common.copied": "Скопировано", "common.download": "Скачать", "common.error": "Что-то пошло не так.", "common.working": "Подождите…", + "admin.nav.stats": "Статистика", "admin.nav.settings": "Настройки", "admin.nav.audit": "Аудит", "admin.nav.danger": "Опасная зона", @@ -277,6 +318,33 @@ "admin.nav.logout": "Выйти", "admin.brand.tagline": "Консоль администратора", "admin.settings.title": "Настройки", + "admin.stats.title": "Статистика", + "admin.stats.updated": "Обновлено", + "admin.stats.minioTitle": "MinIO (нерасшифрованный ciphertext)", + "admin.stats.objects": "объект(ов)", + "admin.stats.dbPending": "в БД pending", + "admin.stats.unwrappedTitle": "Расшифровано (за всё время)", + "admin.stats.auditOk": "audit ok", + "admin.stats.last24h": "за 24ч", + "admin.stats.pendingTitle": "Не расшифровано", + "admin.stats.items": "элемент(ов)", + "admin.stats.uploadedTitle": "Загружено (за всё время)", + "admin.stats.wrapsSection": "Wraps по статусу", + "admin.stats.extraSection": "Ещё", + "admin.stats.col.status": "Статус", + "admin.stats.col.count": "Кол-во", + "admin.stats.col.size": "Размер", + "admin.stats.col.items": "Элементы", + "admin.stats.status.pending": "pending", + "admin.stats.status.consumed": "consumed", + "admin.stats.status.expired": "expired", + "admin.stats.total": "Всего", + "admin.stats.withPassword": "С паролем", + "admin.stats.created24h": "Создано (24ч)", + "admin.stats.created7d": "Создано (7д)", + "admin.stats.createsOk": "Создания ok (audit)", + "admin.stats.createsFail": "Создания fail (audit)", + "admin.stats.unwrapsFail": "Ошибки расшифровки (audit)", "admin.audit.title": "Аудит", "admin.tab.limits": "Лимиты", "admin.tab.rate": "Rate limits", @@ -302,15 +370,15 @@ "admin.limits.auditRetention": "Хранение аудита (дни)", "admin.rate.title": "Rate limits (на IP / минуту)", "admin.rate.create": "Создание", - "admin.rate.unwrap": "Unwrap", + "admin.rate.unwrap": "Расшифровка", "admin.rate.adminLogin": "Вход в админку", "admin.mime.title": "MIME allowlist", "admin.mime.hint": "Один шаблон на строку. Точные типы и wildcards вроде image/*.", "admin.mime.examples": "Примеры / рекомендуемые значения", "admin.passwordMode.title": "Режим пароля", - "admin.passwordMode.client_only": "Пароль также оборачивает ciphertext в браузере. Сервер хранит Argon2id-хеш и отклоняет неверный пароль до одноразового unwrap (опечатка не сжигает пакет).", + "admin.passwordMode.client_only": "Пароль также оборачивает ciphertext в браузере. Сервер хранит Argon2id-хеш и отклоняет неверный пароль до одноразовой расшифровки (опечатка не сжигает пакет).", "admin.passwordMode.server_gate": "Сервер хранит Argon2id-хеш и отдаёт ciphertext только после верного пароля. Ciphertext по-прежнему шифруется на клиенте тем же паролем.", - "admin.password.maxAttempts": "Неверных вводов пароля (unwrap)", + "admin.password.maxAttempts": "Неверных вводов пароля (расшифровка)", "admin.password.maxAttemptsHint": "После стольких неверных паролей wrap уничтожается. По умолчанию: 3.", "admin.captcha.title": "CAPTCHA", "admin.captcha.provider": "Провайдер", @@ -346,6 +414,12 @@ "admin.audit.filter": "Фильтр", "admin.audit.allEvents": "Все события", "admin.audit.perPage": "На странице", + "admin.audit.clear": "Очистить", + "admin.audit.clearConfirmTitle": "Очистить аудит", + "admin.audit.clearConfirm": "Удалить все события аудита? Это нельзя отменить.", + "admin.audit.clearConfirmOk": "Очистить всё", + "admin.audit.clearedPrefix": "Удалено", + "admin.audit.clearedSuffix": "событий аудита.", "admin.audit.shown": "событий показано", "admin.audit.of": "из", "admin.audit.events": "событий", diff --git a/app/static/js/unwrap.js b/app/static/js/unwrap.js index d3ed253..4892e77 100644 --- a/app/static/js/unwrap.js +++ b/app/static/js/unwrap.js @@ -1,5 +1,5 @@ (() => { - const t = (k) => window.WrappedI18n.t(k); + const t = (k, vars) => window.WrappedI18n.t(k, vars); let settings = null; const els = { @@ -7,19 +7,60 @@ password: document.getElementById("unwrap-password"), btn: document.getElementById("unwrap-btn"), error: document.getElementById("form-error"), + errorTitle: document.getElementById("form-error-title"), + errorHint: document.getElementById("form-error-hint"), + errorAttempts: document.getElementById("form-error-attempts"), form: document.getElementById("unwrap-form"), result: document.getElementById("result-panel"), items: document.getElementById("items"), captchaSlot: document.getElementById("captcha-slot"), }; - function showError(msg) { - els.error.textContent = msg; + function showError(title, hint, attempts) { + if (!els.error) return; + if (els.errorTitle) els.errorTitle.textContent = title || ""; + if (els.errorHint) { + if (hint) { + els.errorHint.textContent = hint; + els.errorHint.classList.remove("hidden"); + } else { + els.errorHint.textContent = ""; + els.errorHint.classList.add("hidden"); + } + } + if (els.errorAttempts) { + if (attempts) { + els.errorAttempts.textContent = attempts; + els.errorAttempts.classList.remove("hidden"); + } else { + els.errorAttempts.textContent = ""; + els.errorAttempts.classList.add("hidden"); + } + } + // Fallback if structured nodes missing + if (!els.errorTitle) els.error.textContent = [title, hint, attempts].filter(Boolean).join(" "); els.error.classList.remove("hidden"); } function clearError() { + if (!els.error) return; els.error.classList.add("hidden"); + if (els.errorTitle) els.errorTitle.textContent = ""; + if (els.errorHint) { + els.errorHint.textContent = ""; + els.errorHint.classList.add("hidden"); + } + if (els.errorAttempts) { + els.errorAttempts.textContent = ""; + els.errorAttempts.classList.add("hidden"); + } + } + + function attemptsLabel(detail) { + const n = Number(detail?.attempts_remaining); + const max = Number(detail?.attempts_max); + if (!Number.isFinite(n) || !Number.isFinite(max)) return ""; + return t("unwrap.attemptsLeft", { n, max }); } function loadCaptcha() { @@ -151,24 +192,33 @@ const detail = err.detail; if (detail && typeof detail === "object") { if (detail.code === "password_locked") { - showError(t("unwrap.passwordLocked")); + showError( + t("unwrap.passwordLocked"), + t("unwrap.passwordLockedHint") + ); + return; + } + if (detail.code === "password_required") { + showError( + t("unwrap.passwordRequired"), + t("unwrap.passwordRequiredHint"), + attemptsLabel(detail) + ); + els.password?.focus(); return; } if (detail.code === "bad_password") { - const n = Number(detail.attempts_remaining); - const max = Number(detail.attempts_max); - if (Number.isFinite(n)) { - showError( - t("unwrap.badPasswordRemaining", { - n, - max: Number.isFinite(max) ? max : n, - }) - ); - return; - } + showError( + t("unwrap.badPassword"), + t("unwrap.badPasswordHint"), + attemptsLabel(detail) + ); + els.password?.focus(); + els.password?.select?.(); + return; } } - showError(t("unwrap.badPassword")); + showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint")); return; } if (!resp.ok) { @@ -185,9 +235,12 @@ els.password.value || null ); } catch (err) { - if (err.message === "bad_password" || err.message === "password_required") { - // Should be rare now (server gates first); keep UX clear. - showError(t("unwrap.badPassword")); + if (err.message === "password_required") { + showError(t("unwrap.passwordRequired"), t("unwrap.passwordRequiredHint")); + return; + } + if (err.message === "bad_password") { + showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint")); return; } showError(t("common.error")); @@ -218,3 +271,4 @@ init().catch(() => showError(t("common.error"))); })(); + diff --git a/app/templates/admin_audit.html b/app/templates/admin_audit.html index 08ec3c1..6750e4f 100644 --- a/app/templates/admin_audit.html +++ b/app/templates/admin_audit.html @@ -1,5 +1,13 @@ {% extends "admin_base.html" %} {% block admin_content %} +{% if request.query_params.get('cleared') is not none %} +

+ Cleared + {{ request.query_params.get('cleared') }} + audit event(s). +

+{% endif %} +
@@ -27,15 +35,28 @@
-
- {% if total %} - {{ from_idx }}–{{ to_idx }} - of - {{ total }} - {% else %} - 0 - {% endif %} - events +
+
+ {% if total %} + {{ from_idx }}–{{ to_idx }} + of + {{ total }} + {% else %} + 0 + {% endif %} + events +
+
+ +
diff --git a/app/templates/admin_base.html b/app/templates/admin_base.html index 7e96f61..9d02b99 100644 --- a/app/templates/admin_base.html +++ b/app/templates/admin_base.html @@ -27,6 +27,10 @@
-

{{ title }}

+

{{ title }}

{% block admin_content %}{% endblock %}
diff --git a/app/templates/admin_stats.html b/app/templates/admin_stats.html new file mode 100644 index 0000000..f20fecc --- /dev/null +++ b/app/templates/admin_stats.html @@ -0,0 +1,167 @@ +{% extends "admin_base.html" %} +{% block admin_content %} +
+

+ Updated + +

+ +
+
+
+ +

MinIO (pending ciphertext)

+
+

{{ stats.minio_human }}

+

+ {{ stats.minio_objects }} + object(s) + · + DB pending + {{ stats.size_pending_human }} +

+ {% if stats.minio_error %} +

{{ stats.minio_error }}

+ {% endif %} +
+ +
+
+ +

Unwrapped (all time)

+
+

{{ stats.wraps_consumed }}

+

+ audit ok + {{ stats.audit_unwraps_ok }} + · + 24h + {{ stats.consumed_24h }} +

+
+ +
+
+ +

Not unwrapped

+
+

{{ stats.wraps_pending }}

+

+ {{ stats.size_pending_human }} + · + {{ stats.items_pending }} + item(s) +

+
+ +
+
+ +

Uploaded (all time)

+
+

{{ stats.wraps_total }}

+

+ {{ stats.items_total }} + item(s) + · + {{ stats.size_total_human }} +

+
+
+ +
+
+

Wraps by status

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusCountSizeItems
pending{{ stats.wraps_pending }}{{ stats.size_pending_human }}{{ stats.items_pending }}
consumed{{ stats.wraps_consumed }}{{ stats.size_consumed_human }}
expired{{ stats.wraps_expired }}
Total{{ stats.wraps_total }}{{ stats.size_total_human }}{{ stats.items_total }}
+
+
+ +
+

More

+
    +
  • + With password + {{ stats.wraps_with_password }} +
  • +
  • + Created (24h) + {{ stats.created_24h }} +
  • +
  • + Created (7d) + {{ stats.created_7d }} +
  • +
  • + Creates ok (audit) + {{ stats.audit_creates_ok }} +
  • +
  • + Creates fail (audit) + {{ stats.audit_creates_fail }} +
  • +
  • + Unwrap fails (audit) + {{ stats.audit_unwraps_fail }} +
  • +
+
+
+
+{% endblock %} +{% block admin_scripts %} + +{% endblock %} diff --git a/app/templates/create.html b/app/templates/create.html index 153ca7c..b54af22 100644 --- a/app/templates/create.html +++ b/app/templates/create.html @@ -10,12 +10,8 @@
-
-
@@ -59,7 +55,15 @@