From 806f2bdb1b83408548cb289cd822268d53821a1e Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Wed, 29 Jul 2026 11:33:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=BF=D1=83=D1=89=D0=B5=D0=BD=200.?= =?UTF-8?q?1.3:=20UX=20convenience=20pack,=20unwrap/admin=20polish=20?= =?UTF-8?q?=D0=B8=20cache-bust=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены Share/QR PNG, zip all, size meter, PWA-manifest, системная тема и haptic на copy; улучшены success/unwrap и статистика админки (EN/RU). --- Jenkinsfile | 1 + README.md | 19 +- VERSION | 2 +- app/main.py | 3 + app/models.py | 4 + app/services/stats.py | 53 +++- app/static/css/app.css | 424 +++++++++++++++++++++++++++++- app/static/js/admin-nav.js | 52 ++++ app/static/js/create.js | 140 +++++++++- app/static/js/i18n.js | 96 ++++++- app/static/js/theme.js | 11 +- app/static/js/ui.js | 20 +- app/static/js/unwrap.js | 271 +++++++++++++++++-- app/static/manifest.webmanifest | 17 ++ app/static_url.py | 25 ++ app/templates/admin_audit.html | 2 +- app/templates/admin_base.html | 88 ++++--- app/templates/admin_login.html | 14 +- app/templates/admin_settings.html | 2 +- app/templates/admin_stats.html | 83 +++++- app/templates/base.html | 20 +- app/templates/create.html | 68 ++++- app/templates/unwrap.html | 30 ++- helm/wrapped/Chart.yaml | 4 +- helm/wrapped/values.yaml | 2 +- pyproject.toml | 2 +- 26 files changed, 1317 insertions(+), 136 deletions(-) create mode 100644 app/static/js/admin-nav.js create mode 100644 app/static/manifest.webmanifest create mode 100644 app/static_url.py diff --git a/Jenkinsfile b/Jenkinsfile index a62f56f..fa6b688 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -11,6 +11,7 @@ // harbor-devops-tools-push-pull-access — Harbor devops-tools (robot) // docker-hub — Docker Hub (inecs) // ssh-gitea-key — SCM checkout Gitea +// gitea-jenkins-token — Gitea PAT; для статусов коммита: write:repository // k3s-kubeconfig — в job Deploy (Jenkinsfile.deploy) // // Deploy job: devops-tools/wrapped/wrapped-deploy/main diff --git a/README.md b/README.md index e2d79dd..3271997 100644 --- a/README.md +++ b/README.md @@ -338,16 +338,27 @@ services: Ключ в `#fragment` не уходит на сервер в запросе страницы. +### UX (v0.1.3+) + +- После создания: **QR** на share-link, **скачать QR (PNG)**, **Web Share** (если есть `navigator.share`), чеклист и trust-строка про `#key`; отдельные кнопки Copy для ссылки / токена / пароля; пароль не советуется слать в той же переписке. +- Create: счётчик размера `≈ used / max` и предупреждение near-limit (сверх лимита — `create.tooLarge`). +- Unwrap: Enter в поле пароля отправляет форму; после `password_required` / `bad_password` — focus+select; при ≥2 элементах — **скачать всё** (zip через JSZip); trust-строка на результате; спокойный экран «ссылка недоступна» для already used / expired (anti-enumeration); отдельные состояния для `password_locked`, rate limit, CAPTCHA, ошибки расшифровки. +- Картинки после unwrap: inline-превью и **lightbox** (тап/клик). +- Тема при первом визите следует `prefers-color-scheme` (пока нет выбора в `localStorage`); лёгкий haptic после успешного Copy. +- Минимальный **PWA**: `manifest.webmanifest` (без Service Worker для API). +- UI строки EN/RU через `i18n.js`. +- Статика (`/static/...`) отдаётся с `?v=версия.mtime` (cache-bust после деплоя). + --- ## Админка -Логин: `/admin/login`. После входа — **Статистика** (`/admin/stats`). +Логин: `/admin/login`. После входа — **Статистика** (`/admin/stats`). На мобиле меню — через гамбургер. | Раздел | URL | Что делает | |--------|-----|------------| -| **Статистика** | `/admin/stats` | MinIO: объём и число объектов нерасшифрованного ciphertext; расшифровано / pending; загрузки и items за всё время; таблица по статусам wraps; с паролем; создано за 24ч/7д; счётчики audit (create/unwrap ok/fail) | -| **Настройки** | `/admin/settings` | Limits (upload, TTL, retention audit), rate limits, MIME allowlist, пароль (режим + лимит попыток), CAPTCHA | +| **Статистика** | `/admin/stats` | MinIO pending ciphertext; успешные unwrap vs сожжения паролем; pending / uploads; таблица статусов (count/size/items); with password; 24h/7d creates; audit create/unwrap; разбивка fail-причин unwrap (all-time и 24h) | +| **Настройки** | `/admin/settings` | Limits (upload, TTL, retention audit), rate limits, MIME allowlist (в т.ч. YAML / `application/octet-stream` для kubeconfig и неизвестных типов), пароль, CAPTCHA | | **Аудит** | `/admin/audit` | Фильтры, пагинация (10/25/50/100), номера страниц, кнопка **Очистить** (с подтверждением; пишется событие `admin.audit_clear`) | | **Опасная зона** | `/admin/danger` | Полная очистка wraps и объектов в MinIO (`PURGE`) | @@ -452,7 +463,7 @@ Credentials (Global, как в job’ах): | ID | Тип | Назначение | |----|-----|------------| | `ssh-gitea-key` | SSH Username with private key | SCM checkout | -| `gitea-jenkins-token` | Secret text / token | API Gitea (webhook/API при необходимости) | +| `gitea-jenkins-token` | Secret text (Gitea PAT) | SCM/API Gitea. Для commit status из Multibranch нужен scope **`write:repository`** (сейчас при read-only в логе: `Could not send notifications` / 403/405). Checkout достаточно `read:repository` | | `harbor-devops-tools-push-pull-access` | Username/password | Harbor `devops-tools` (robot) | | `docker-hub` | Username/password | Docker Hub `inecs` | | `k3s-kubeconfig` | Secret file | Helm/kubectl deploy в K3S | diff --git a/VERSION b/VERSION index d917d3e..845639e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.2 +0.1.4 diff --git a/app/main.py b/app/main.py index f3dfcaa..6ff2e3b 100644 --- a/app/main.py +++ b/app/main.py @@ -14,6 +14,7 @@ from app.config import get_settings from app.db import SessionLocal from app.services.settings_service import get_or_create_settings from app.services.storage import storage +from app.static_url import static_url from app.version import get_app_version, get_app_version_label OPENAPI_TAGS = [ @@ -102,8 +103,10 @@ def create_app() -> FastAPI: # Callables so Jinja re-reads VERSION (dev mount / after bump) on each render templates.env.globals["app_version"] = get_app_version templates.env.globals["app_version_label"] = get_app_version_label + templates.env.globals["static_url"] = static_url admin.templates.env.globals["app_version"] = get_app_version admin.templates.env.globals["app_version_label"] = get_app_version_label + admin.templates.env.globals["static_url"] = static_url @app.get("/health", tags=["System"], summary="Health check", include_in_schema=docs_on) async def health(): diff --git a/app/models.py b/app/models.py index e15ad09..6cc3eb7 100644 --- a/app/models.py +++ b/app/models.py @@ -138,6 +138,10 @@ DEFAULT_MIME_ALLOWLIST = [ "text/javascript", "application/json", "application/xml", + "application/yaml", + "application/x-yaml", + "text/yaml", + "application/octet-stream", "application/pdf", "application/zip", "application/x-zip-compressed", diff --git a/app/services/stats.py b/app/services/stats.py index c5d50d0..e0656ba 100644 --- a/app/services/stats.py +++ b/app/services/stats.py @@ -117,6 +117,49 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]: else: bucket["fail"] += int(count or 0) + reason_col = AuditEvent.details.op("->>")("reason") + + password_burns = int( + ( + await db.execute( + select(func.count()) + .select_from(AuditEvent) + .where( + AuditEvent.event_type == "wrap.unwrap", + AuditEvent.success.is_(False), + reason_col == "password_attempts_exceeded", + ) + ) + ).scalar_one() + or 0 + ) + + async def unwrap_fail_reasons(since: datetime | None = None) -> list[dict[str, Any]]: + filters = [ + AuditEvent.event_type == "wrap.unwrap", + AuditEvent.success.is_(False), + ] + if since is not None: + filters.append(AuditEvent.created_at >= since) + rows = ( + await db.execute( + select( + func.coalesce(reason_col, "unknown"), + func.count(AuditEvent.id), + ) + .where(*filters) + .group_by(func.coalesce(reason_col, "unknown")) + .order_by(func.count(AuditEvent.id).desc()) + ) + ).all() + return [ + {"reason": str(reason or "unknown"), "count": int(count or 0)} + for reason, count in rows + ] + + unwrap_fail_reasons_all = await unwrap_fail_reasons() + unwrap_fail_reasons_24h = await unwrap_fail_reasons(day_ago) + # MinIO live objects under wraps/ try: minio = await storage.prefix_stats("wraps/") @@ -128,6 +171,7 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]: pending = by_status["pending"] consumed = by_status["consumed"] expired = by_status["expired"] + audit_unwraps_ok = audit.get("wrap.unwrap", {}).get("ok", 0) return { "wraps_total": int(total_wraps or 0), @@ -137,6 +181,8 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]: "wraps_with_password": int(with_password or 0), "items_total": int(total_items or 0), "items_pending": pending["items"], + "items_consumed": consumed["items"], + "items_expired": expired["items"], "size_total_bytes": int(total_size or 0), "size_pending_bytes": pending["size_bytes"], "size_consumed_bytes": consumed["size_bytes"], @@ -144,13 +190,18 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]: "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"]), + "size_expired_human": format_bytes(expired["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_ok": audit_unwraps_ok, "audit_unwraps_fail": audit.get("wrap.unwrap", {}).get("fail", 0), + "unwraps_success": audit_unwraps_ok, + "password_burns": password_burns, + "unwrap_fail_reasons": unwrap_fail_reasons_all, + "unwrap_fail_reasons_24h": unwrap_fail_reasons_24h, "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)), diff --git a/app/static/css/app.css b/app/static/css/app.css index 4bbb20d..2760bea 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -113,6 +113,7 @@ html[data-theme="light"] body { display: flex; align-items: center; justify-content: space-between; + gap: 0.75rem; padding: 0.85rem 0.75rem; max-width: none; width: 100%; @@ -262,6 +263,14 @@ html[data-theme="dark"] .icon-btn:hover { .result-panel > .success-callout { justify-self: stretch; } +.result-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.result-panel > .success-trust { + margin: 0; +} .success-panel { justify-items: stretch; text-align: left; @@ -273,6 +282,149 @@ html[data-theme="dark"] .icon-btn:hover { .success-panel > .btn.ghost { justify-self: center; } +.success-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1.25rem 1.5rem; + align-items: start; + min-width: 0; +} +.success-main { + display: grid; + gap: 0.55rem; + min-width: 0; +} +.success-main > label { + margin-top: 0.35rem; +} +.success-password-block { + display: grid; + gap: 0.45rem; + margin-bottom: 0.35rem; +} +.success-password-badge { + display: inline-flex; + align-items: center; + gap: 0.45rem; + width: fit-content; + max-width: 100%; + margin: 0; + padding: 0.4rem 0.7rem; + border-radius: 10px; + border: 1px solid rgba(61, 214, 198, 0.35); + background: rgba(61, 214, 198, 0.1); + color: var(--accent-2); + font-size: 0.82rem; + font-weight: 650; +} +.success-password-hint { + margin: 0; + font-size: 0.84rem; + line-height: 1.4; +} +.success-checklist { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.35rem; +} +.success-checklist li { + position: relative; + padding-left: 1.35rem; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.4; +} +.success-checklist li::before { + content: ""; + position: absolute; + left: 0; + top: 0.35rem; + width: 0.55rem; + height: 0.55rem; + border-radius: 2px; + border: 1.5px solid var(--accent-2); + opacity: 0.85; +} +.success-trust { + margin: 0; + font-size: 0.84rem; + line-height: 1.45; +} +.success-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.35rem; +} +.success-actions .btn { + flex: 1 1 auto; + min-width: 8rem; +} +.size-meter { + margin: 0; + font-variant-numeric: tabular-nums; +} +.size-meter.is-warn { + color: #e8a838; +} +.size-meter.is-over { + color: var(--danger); +} +.success-qr { + display: grid; + justify-items: center; + gap: 0.45rem; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--panel); + min-width: 0; +} +.share-qr { + width: 180px; + height: 180px; + display: grid; + place-items: center; + background: #fff; + border-radius: 12px; + padding: 0.5rem; + box-sizing: border-box; +} +.share-qr img, +.share-qr canvas { + display: block; + max-width: 100%; + height: auto; +} +.success-qr-hint { + margin: 0; + text-align: center; + font-size: 0.78rem; + max-width: 11rem; + line-height: 1.35; +} +@media (max-width: 860px) { + .success-layout { + grid-template-columns: 1fr; + } + .success-qr { + order: -1; + justify-self: center; + width: min(100%, 280px); + } + .share-qr { + width: min(220px, 70vw); + height: min(220px, 70vw); + } + .copy-row { + grid-template-columns: 1fr; + } + .copy-row .btn { + width: 100%; + } +} .success-callout { display: flex; align-items: center; @@ -441,12 +593,28 @@ input, select, textarea, .code-input { } .lang-modal-panel { width: min(480px, 100%); + display: flex; + flex-direction: column; + overflow: hidden; +} +.lang-modal-panel .modal-title, +.lang-modal-panel .modal-message, +.lang-modal-panel .modal-actions { + flex-shrink: 0; +} +.lang-modal-panel .modal-message { + margin-bottom: 0.75rem; } .lang-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.45rem; - margin-bottom: 1rem; + margin-bottom: 0.85rem; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } @media (max-width: 520px) { .lang-grid { grid-template-columns: 1fr; } @@ -947,6 +1115,110 @@ html[data-theme="light"] .about-version { transform: none; } +.item-card img, +.item-preview-img { + display: block; + max-width: 100%; + width: auto; + height: auto; + border-radius: 10px; + margin-top: 0.65rem; + cursor: zoom-in; +} +.item-preview-hint { + margin: 0.4rem 0 0; + font-size: 0.78rem; +} + +.unwrap-state { + display: grid; + justify-items: center; + gap: 0.75rem; + text-align: center; + padding: 1.5rem 0.5rem 0.5rem; + min-width: 0; +} +.unwrap-state.hidden { + display: none; +} +.unwrap-state-icon { + width: 64px; + height: 64px; + display: grid; + place-items: center; + border-radius: 18px; + border: 1px solid rgba(110, 168, 255, 0.25); + background: linear-gradient(160deg, rgba(110, 168, 255, 0.14), rgba(13, 20, 32, 0.35)); + color: var(--accent-2); + font-size: 1.45rem; +} +html[data-theme="light"] .unwrap-state-icon { + background: linear-gradient(160deg, rgba(47, 111, 237, 0.12), rgba(255, 255, 255, 0.9)); +} +.unwrap-state h2 { + margin: 0; + font-size: 1.35rem; + font-weight: 650; + max-width: 22rem; + line-height: 1.25; +} +.unwrap-state .lede { + margin: 0; + max-width: 28rem; +} +.unwrap-state .btn { + margin-top: 0.35rem; +} + +.image-lightbox { + position: fixed; + inset: 0; + z-index: 1100; + display: grid; + grid-template-rows: auto 1fr auto; + align-items: center; + justify-items: center; + gap: 0.75rem; + padding: max(0.75rem, env(safe-area-inset-top, 0px)) + max(0.75rem, env(safe-area-inset-right, 0px)) + max(0.75rem, env(safe-area-inset-bottom, 0px)) + max(0.75rem, env(safe-area-inset-left, 0px)); + background: rgba(6, 10, 18, 0.88); + backdrop-filter: blur(6px); +} +.image-lightbox.hidden { + display: none; +} +.image-lightbox-close { + justify-self: end; + width: 42px; + height: 42px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(20, 28, 42, 0.7); + color: #fff; + cursor: pointer; + display: grid; + place-items: center; +} +.image-lightbox img { + max-width: min(100%, 960px); + max-height: min(78dvh, 78vh); + width: auto; + height: auto; + object-fit: contain; + border-radius: 12px; + cursor: default; +} +.image-lightbox-caption { + margin: 0; + color: rgba(255, 255, 255, 0.85); + font-size: 0.9rem; + text-align: center; + max-width: 90vw; + word-break: break-word; +} + .items { display: grid; gap: 1rem; @@ -1000,11 +1272,6 @@ html[data-theme="light"] .item-card pre, html[data-theme="light"] .item-card .item-text-pre { background: rgba(16, 32, 56, 0.05); } -.item-card img { - max-width: 100%; - border-radius: 8px; - margin-top: 0.6rem; -} /* Admin */ .admin-body { min-height: 100vh; } @@ -1074,6 +1341,15 @@ html[data-theme="light"] .item-card .item-text-pre { margin: 0; font-size: 0.82rem; } +.admin-top-tools { + display: flex; + align-items: center; + gap: 0.45rem; + flex-shrink: 0; +} +.admin-nav-toggle { + display: none; +} .admin-top-right { display: flex; align-items: center; @@ -1099,6 +1375,10 @@ html[data-theme="light"] .item-card .item-text-pre { backdrop-filter: blur(12px); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12); } +.admin-nav-logout-form { + display: inline; + margin: 0; +} .admin-nav-link { display: inline-flex; align-items: center; @@ -1142,6 +1422,113 @@ html[data-theme="light"] .item-card .item-text-pre { html[data-theme="light"] .admin-nav { box-shadow: 0 8px 24px rgba(20, 40, 70, 0.08); } + +@media (max-width: 860px) { + .admin-top { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "brand tools" + "nav nav"; + align-items: center; + gap: 0.55rem 0.65rem; + padding: 0.7rem 0.65rem; + } + .admin-top > .brand { + grid-area: brand; + min-width: 0; + } + .admin-top-tools { + grid-area: tools; + } + .admin-top .brand-tagline { + max-width: 42vw; + } + .admin-nav-toggle { + display: grid; + } + .admin-nav { + grid-area: nav; + display: none; + flex-direction: column; + flex-wrap: nowrap; + align-items: stretch; + width: 100%; + max-height: min(70dvh, 28rem); + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + padding: 0.4rem; + gap: 0.2rem; + } + .admin-top.is-nav-open .admin-nav { + display: flex; + } + .admin-nav-link { + width: 100%; + justify-content: flex-start; + padding: 0.7rem 0.85rem; + font-size: 0.92rem; + } + .admin-nav-logout-form { + display: block; + width: 100%; + } + .admin-nav-logout-form .admin-nav-link { + width: 100%; + } + .admin-shell { + padding: 0 0.65rem 2rem; + } + .admin-shell > h1 { + font-size: 1.35rem; + margin-bottom: 0.75rem; + } + .admin-tablist { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + scrollbar-width: thin; + } + .admin-tab { + flex: 0 0 auto; + } + .admin-tabpanel { + padding: 0.9rem; + } + .audit-filters { + display: grid; + grid-template-columns: 1fr; + align-items: stretch; + } + .audit-field, + .audit-field-grow { + min-width: 0; + width: 100%; + } + .audit-filter-btn { + width: 100%; + height: auto; + min-height: 42px; + } + .audit-table { + min-width: 720px; + } + .admin-login-toggles { + top: max(0.75rem, env(safe-area-inset-top, 0px)); + right: max(0.75rem, env(safe-area-inset-right, 0px)); + } +} + +@media (min-width: 861px) { + .admin-top > .brand { + flex: 0 0 auto; + } + .admin-nav { + margin-left: auto; + } +} .danger-page { max-width: 520px; } @@ -1262,12 +1649,9 @@ html[data-theme="light"] .admin-nav { } .stats-grid { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(200px, 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; } } @@ -1643,6 +2027,11 @@ body.modal-open { display: grid; place-items: center; padding: 1.25rem; + /* iOS: безопасные отступы от notch / home indicator */ + padding: max(0.75rem, env(safe-area-inset-top, 0px)) + max(0.75rem, env(safe-area-inset-right, 0px)) + max(0.75rem, env(safe-area-inset-bottom, 0px)) + max(0.75rem, env(safe-area-inset-left, 0px)); } .modal-root.hidden { display: none; @@ -1659,6 +2048,10 @@ html[data-theme="light"] .modal-backdrop { .modal-panel { position: relative; width: min(420px, 100%); + max-height: calc(100vh - 1.5rem); + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; @@ -1666,6 +2059,17 @@ html[data-theme="light"] .modal-backdrop { padding: 1.35rem 1.4rem 1.25rem; animation: modal-in 0.18s ease-out; } +@supports (height: 100dvh) { + .modal-panel { + max-height: calc(100dvh - 1.5rem); + } +} +/* Список языков: скролл внутри сетки, заголовок и Cancel остаются на экране */ +.modal-panel.lang-modal-panel { + overflow: hidden; + display: flex; + flex-direction: column; +} @keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } diff --git a/app/static/js/admin-nav.js b/app/static/js/admin-nav.js new file mode 100644 index 0000000..be39e29 --- /dev/null +++ b/app/static/js/admin-nav.js @@ -0,0 +1,52 @@ +(() => { + const top = document.getElementById("admin-top"); + const toggle = document.getElementById("admin-nav-toggle"); + const nav = document.getElementById("admin-nav"); + if (!top || !toggle || !nav) return; + + const mq = window.matchMedia("(max-width: 860px)"); + + const syncLabel = () => { + const open = top.classList.contains("is-nav-open"); + const key = open ? "admin.nav.closeMenu" : "admin.nav.openMenu"; + const label = window.WrappedI18n?.t(key) || (open ? "Close menu" : "Menu"); + toggle.setAttribute("aria-label", label); + toggle.setAttribute("title", label); + }; + + const setOpen = (open) => { + top.classList.toggle("is-nav-open", open); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + const icon = toggle.querySelector("i"); + if (icon) { + icon.className = open ? "fa-solid fa-xmark" : "fa-solid fa-bars"; + } + syncLabel(); + }; + + toggle.addEventListener("click", () => { + setOpen(!top.classList.contains("is-nav-open")); + }); + + nav.addEventListener("click", (e) => { + if (!mq.matches) return; + if (e.target.closest("a.admin-nav-link")) setOpen(false); + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && top.classList.contains("is-nav-open")) { + setOpen(false); + toggle.focus(); + } + }); + + mq.addEventListener("change", () => { + if (!mq.matches) setOpen(false); + else syncLabel(); + }); + + if (window.WrappedI18n?.onChange) { + window.WrappedI18n.onChange(syncLabel); + } + syncLabel(); +})(); diff --git a/app/static/js/create.js b/app/static/js/create.js index 496add8..086a213 100644 --- a/app/static/js/create.js +++ b/app/static/js/create.js @@ -25,8 +25,15 @@ composer: document.querySelector(".composer"), shareLink: document.getElementById("share-link"), shareToken: document.getElementById("share-token"), + sharePassword: document.getElementById("share-password"), + passwordBlock: document.getElementById("success-password-block"), + shareQr: document.getElementById("share-qr"), expiresMeta: document.getElementById("expires-meta"), captchaSlot: document.getElementById("captcha-slot"), + sizeMeter: document.getElementById("size-meter"), + checkPasswordItem: document.getElementById("check-password-item"), + shareNative: document.getElementById("share-native"), + downloadQr: document.getElementById("download-qr"), }; function showError(msg) { @@ -100,6 +107,34 @@ document.body.classList.remove("modal-open"); } + function estimatePayloadBytes() { + const textBytes = new TextEncoder().encode(els.text?.value || "").length; + const fileBytes = files.reduce((sum, f) => sum + (Number(f.size) || 0), 0); + return textBytes + fileBytes; + } + + function refreshSizeMeter() { + if (!els.sizeMeter || !settings) return; + const used = estimatePayloadBytes(); + const max = Number(settings.max_upload_bytes) || 0; + if (!max) { + els.sizeMeter.textContent = ""; + els.sizeMeter.classList.remove("is-warn", "is-over"); + return; + } + const fmt = window.WrappedUI.formatBytes; + let text = t("create.sizeMeter", { + used: fmt(used), + max: fmt(max), + }); + const near = used > max * 0.85 && used <= max; + const over = used > max; + if (near) text = `${text} · ${t("create.sizeNearLimit")}`; + els.sizeMeter.textContent = text; + els.sizeMeter.classList.toggle("is-over", over); + els.sizeMeter.classList.toggle("is-warn", near); + } + function renderFiles() { els.fileList.innerHTML = ""; files.forEach((f, idx) => { @@ -115,6 +150,7 @@ li.appendChild(btn); els.fileList.appendChild(li); }); + refreshSizeMeter(); } function addFiles(list) { @@ -207,6 +243,8 @@ loadCaptcha(); setLanguage("plaintext", { silent: true }); refreshHighlight(); + refreshSizeMeter(); + syncShareControls(); } els.dropzone.addEventListener("click", () => els.fileInput.click()); @@ -243,6 +281,7 @@ els.text.addEventListener("input", () => { refreshHighlight(); + refreshSizeMeter(); }); els.text.addEventListener("scroll", () => { const pre = els.highlight.parentElement; @@ -260,19 +299,104 @@ } }); + function renderShareQr(link) { + if (!els.shareQr) return; + els.shareQr.innerHTML = ""; + if (!link || typeof QRCode === "undefined") return; + const size = window.matchMedia("(max-width: 860px)").matches ? 200 : 164; + // eslint-disable-next-line no-new + new QRCode(els.shareQr, { + text: link, + width: size, + height: size, + colorDark: "#0d1420", + colorLight: "#ffffff", + correctLevel: QRCode.CorrectLevel.M, + }); + } + + function syncShareControls() { + if (els.shareNative) { + const canShare = typeof navigator.share === "function"; + els.shareNative.classList.toggle("hidden", !canShare); + els.shareNative.setAttribute("aria-label", t("create.share")); + } + if (els.downloadQr) { + els.downloadQr.setAttribute("aria-label", t("create.downloadQr")); + } + } + + function showSuccess({ link, token, password, expiresAt }) { + els.shareLink.value = link; + els.shareToken.value = token; + lastExpiresAt = expiresAt; + refreshExpiresMeta(); + if (password) { + els.sharePassword.value = password; + els.passwordBlock.classList.remove("hidden"); + els.checkPasswordItem?.classList.remove("hidden"); + } else { + els.sharePassword.value = ""; + els.passwordBlock.classList.add("hidden"); + els.checkPasswordItem?.classList.add("hidden"); + } + renderShareQr(link); + syncShareControls(); + els.composer.classList.add("hidden"); + els.success.classList.remove("hidden"); + els.success.scrollIntoView({ behavior: "smooth", block: "start" }); + } + async function copyFrom(input, btn) { - await navigator.clipboard.writeText(input.value); + try { + await window.WrappedUI.copyText(input.value); + } catch { + await navigator.clipboard.writeText(input.value); + } const old = btn.textContent; btn.textContent = t("common.copied"); setTimeout(() => (btn.textContent = old), 1200); } + function downloadQrPng() { + if (!els.shareQr) return; + const canvas = els.shareQr.querySelector("canvas"); + const img = els.shareQr.querySelector("img"); + let href = ""; + if (canvas && canvas.toDataURL) { + href = canvas.toDataURL("image/png"); + } else if (img?.src) { + href = img.src; + } + if (!href) return; + const a = document.createElement("a"); + a.href = href; + a.download = "wrapped-qr.png"; + a.click(); + } + document.getElementById("copy-link").addEventListener("click", () => { copyFrom(els.shareLink, document.getElementById("copy-link")); }); document.getElementById("copy-token").addEventListener("click", () => { copyFrom(els.shareToken, document.getElementById("copy-token")); }); + document.getElementById("copy-password")?.addEventListener("click", () => { + copyFrom(els.sharePassword, document.getElementById("copy-password")); + }); + els.shareNative?.addEventListener("click", async () => { + if (typeof navigator.share !== "function" || !els.shareLink.value) return; + try { + await navigator.share({ + title: t("create.shareTitle"), + text: t("create.shareText"), + url: els.shareLink.value, + }); + } catch { + /* user cancelled or unsupported */ + } + }); + els.downloadQr?.addEventListener("click", downloadQrPng); document.getElementById("create-another").addEventListener("click", () => { location.reload(); }); @@ -346,12 +470,12 @@ const data = await resp.json(); const token = window.WrappedCrypto.buildToken(data.wrap_id, keyB64url); const link = `${location.origin}${data.share_path}#${keyB64url}`; - els.shareLink.value = link; - els.shareToken.value = token; - lastExpiresAt = data.expires_at; - refreshExpiresMeta(); - els.composer.classList.add("hidden"); - els.success.classList.remove("hidden"); + showSuccess({ + link, + token, + password, + expiresAt: data.expires_at, + }); } catch { showError(t("common.error")); } finally { @@ -380,6 +504,8 @@ window.WrappedI18n.onChange(() => { fillTtl(); refreshExpiresMeta(); + refreshSizeMeter(); + syncShareControls(); setLanguage(els.lang.value, { silent: true }); refreshHighlight(); if (els.langChipText) els.langChipText.textContent = langLabel(els.lang.value); diff --git a/app/static/js/i18n.js b/app/static/js/i18n.js index 1226699..b39cf6f 100644 --- a/app/static/js/i18n.js +++ b/app/static/js/i18n.js @@ -39,6 +39,20 @@ "create.successTitle": "Token issued", "create.successWarnTitle": "One-time unwrap", "create.successWarnHint": "After opening, ciphertext is deleted on the server.", + "create.passwordProtected": "Password protected", + "create.sharePassword": "Password", + "create.passwordSeparateHint": "Do not send the password in the same chat as the link.", + "create.qrHint": "Scan to open the share link", + "create.share": "Share", + "create.shareTitle": "Wrapped link", + "create.shareText": "One-time secure link from Wrapped", + "create.downloadQr": "Download QR", + "create.checkLink": "Copy the share link", + "create.checkPassword": "Send the password in a separate message", + "create.checkExpires": "Note the expiry time", + "create.trustKey": "The encryption key lives only in the link #fragment — the server never sees it.", + "create.sizeMeter": "≈ {used} / {max}", + "create.sizeNearLimit": "Approaching size limit", "create.shareLink": "Share link", "create.token": "Wrapped token", "create.another": "Create another", @@ -78,17 +92,36 @@ "unwrap.password": "Password (if set)", "unwrap.submit": "Unwrap", "unwrap.working": "Decrypting…", + "unwrap.workingFetch": "Downloading…", + "unwrap.workingDecrypt": "Decrypting…", "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.badToken": "Invalid token", + "unwrap.badTokenHint": "Paste a full wrapped token or open a valid share link.", "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).", + "unwrap.passwordLockedHint": "This wrap has been destroyed after too many failed attempts.", + "unwrap.unavailable": "Unavailable", + "unwrap.unavailableHint": "Already used, expired, or invalid.", + "unwrap.goneTitle": "This link is no longer available", + "unwrap.goneHint": "Wrapped links are one-time and can expire. The ciphertext is gone from the server.", + "unwrap.rateLimited": "Too many requests", + "unwrap.rateLimitedHint": "Wait a minute and try again.", + "unwrap.captchaFailed": "CAPTCHA failed", + "unwrap.captchaFailedHint": "Complete the CAPTCHA and try again.", + "unwrap.storageError": "Temporary storage error", + "unwrap.storageErrorHint": "Try again in a moment.", + "unwrap.decryptFailed": "Could not decrypt", + "unwrap.decryptFailedHint": "The key in the link may be wrong. Ciphertext was already removed from the server.", + "unwrap.createOwn": "Create your own wrap", + "unwrap.tapPreview": "Tap image to enlarge", + "unwrap.downloadAll": "Download all", + "unwrap.trustKey": "The key was only in the link #fragment and was never sent to the server.", "common.copy": "Copy", "common.copied": "Copied", "common.download": "Download", @@ -100,6 +133,8 @@ "admin.nav.danger": "Danger", "admin.nav.site": "Site", "admin.nav.logout": "Logout", + "admin.nav.openMenu": "Menu", + "admin.nav.closeMenu": "Close menu", "admin.brand.tagline": "Admin console", "admin.settings.title": "Settings", "admin.stats.title": "Statistics", @@ -107,14 +142,21 @@ "admin.stats.minioTitle": "MinIO (pending ciphertext)", "admin.stats.objects": "object(s)", "admin.stats.dbPending": "DB pending", - "admin.stats.unwrappedTitle": "Unwrapped (all time)", + "admin.stats.unwrappedTitle": "Successful unwraps", "admin.stats.auditOk": "audit ok", + "admin.stats.dbConsumed": "DB consumed", "admin.stats.last24h": "24h", "admin.stats.pendingTitle": "Not unwrapped", "admin.stats.items": "item(s)", "admin.stats.uploadedTitle": "Uploaded (all time)", + "admin.stats.passwordBurnsTitle": "Burned by password", + "admin.stats.passwordBurnsHint": "Destroyed after too many wrong passwords", "admin.stats.wrapsSection": "Wraps by status", "admin.stats.extraSection": "More", + "admin.stats.failReasonsSection": "Unwrap fail reasons (all time)", + "admin.stats.failReasons24hSection": "Unwrap fail reasons (24h)", + "admin.stats.col.reason": "Reason", + "admin.stats.noFailReasons": "No failed unwraps yet.", "admin.stats.col.status": "Status", "admin.stats.col.count": "Count", "admin.stats.col.size": "Size", @@ -257,6 +299,20 @@ "create.successTitle": "Токен выдан", "create.successWarnTitle": "Расшифруй один раз", "create.successWarnHint": "После открытия ciphertext удаляется на сервере.", + "create.passwordProtected": "Защищено паролем", + "create.sharePassword": "Пароль", + "create.passwordSeparateHint": "Не отправляйте пароль в той же переписке, что и ссылку.", + "create.qrHint": "Отсканируйте, чтобы открыть ссылку", + "create.share": "Поделиться", + "create.shareTitle": "Ссылка Wrapped", + "create.shareText": "Одноразовая защищённая ссылка из Wrapped", + "create.downloadQr": "Скачать QR", + "create.checkLink": "Скопируйте ссылку", + "create.checkPassword": "Отправьте пароль отдельным сообщением", + "create.checkExpires": "Учтите срок действия", + "create.trustKey": "Ключ шифрования только во фрагменте ссылки #… — сервер его не видит.", + "create.sizeMeter": "≈ {used} / {max}", + "create.sizeNearLimit": "Близко к лимиту размера", "create.shareLink": "Ссылка", "create.token": "Wrapped-токен", "create.another": "Создать ещё", @@ -296,17 +352,36 @@ "unwrap.password": "Пароль (если задан)", "unwrap.submit": "Расшифровать", "unwrap.working": "Расшифровка…", + "unwrap.workingFetch": "Скачивание…", + "unwrap.workingDecrypt": "Расшифровка…", "unwrap.destroyedTitle": "Копия на сервере уничтожена", "unwrap.destroyedHint": "Превью только в этой сессии браузера.", "unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.", + "unwrap.badToken": "Неверный токен", + "unwrap.badTokenHint": "Вставьте полный wrapped-токен или откройте корректную ссылку.", "unwrap.badPassword": "Неверный пароль", "unwrap.badPasswordHint": "Проверьте пароль и попробуйте снова.", "unwrap.passwordRequired": "Нужен пароль", "unwrap.passwordRequiredHint": "Этот wrap защищён паролем.", "unwrap.attemptsLeft": "Осталось попыток: {n} из {max}", "unwrap.passwordLocked": "Слишком много неверных паролей", - "unwrap.passwordLockedHint": "Этот wrap уничтожен.", - "unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).", + "unwrap.passwordLockedHint": "Wrap уничтожен после исчерпания попыток.", + "unwrap.unavailable": "Недоступно", + "unwrap.unavailableHint": "Уже использовано, истекло или неверно.", + "unwrap.goneTitle": "Ссылка больше недоступна", + "unwrap.goneHint": "Wrapped-ссылки одноразовые и могут истекать. Ciphertext уже удалён с сервера.", + "unwrap.rateLimited": "Слишком много запросов", + "unwrap.rateLimitedHint": "Подождите минуту и попробуйте снова.", + "unwrap.captchaFailed": "CAPTCHA не пройдена", + "unwrap.captchaFailedHint": "Пройдите CAPTCHA и попробуйте снова.", + "unwrap.storageError": "Временная ошибка хранилища", + "unwrap.storageErrorHint": "Попробуйте чуть позже.", + "unwrap.decryptFailed": "Не удалось расшифровать", + "unwrap.decryptFailedHint": "Ключ в ссылке может быть неверным. Ciphertext уже удалён с сервера.", + "unwrap.createOwn": "Создать свой wrap", + "unwrap.tapPreview": "Нажмите на изображение, чтобы увеличить", + "unwrap.downloadAll": "Скачать всё", + "unwrap.trustKey": "Ключ был только во фрагменте ссылки #… и не уходил на сервер.", "common.copy": "Копировать", "common.copied": "Скопировано", "common.download": "Скачать", @@ -318,6 +393,8 @@ "admin.nav.danger": "Опасная зона", "admin.nav.site": "Сайт", "admin.nav.logout": "Выйти", + "admin.nav.openMenu": "Меню", + "admin.nav.closeMenu": "Закрыть меню", "admin.brand.tagline": "Консоль администратора", "admin.settings.title": "Настройки", "admin.stats.title": "Статистика", @@ -325,14 +402,21 @@ "admin.stats.minioTitle": "MinIO (нерасшифрованный ciphertext)", "admin.stats.objects": "объект(ов)", "admin.stats.dbPending": "в БД pending", - "admin.stats.unwrappedTitle": "Расшифровано (за всё время)", + "admin.stats.unwrappedTitle": "Успешные unwrap", "admin.stats.auditOk": "audit ok", + "admin.stats.dbConsumed": "в БД consumed", "admin.stats.last24h": "за 24ч", "admin.stats.pendingTitle": "Не расшифровано", "admin.stats.items": "элемент(ов)", "admin.stats.uploadedTitle": "Загружено (за всё время)", + "admin.stats.passwordBurnsTitle": "Сожжено паролем", + "admin.stats.passwordBurnsHint": "Уничтожено после слишком многих неверных паролей", "admin.stats.wrapsSection": "Wraps по статусу", "admin.stats.extraSection": "Ещё", + "admin.stats.failReasonsSection": "Причины ошибок unwrap (всё время)", + "admin.stats.failReasons24hSection": "Причины ошибок unwrap (24ч)", + "admin.stats.col.reason": "Причина", + "admin.stats.noFailReasons": "Пока нет неудачных unwrap.", "admin.stats.col.status": "Статус", "admin.stats.col.count": "Кол-во", "admin.stats.col.size": "Размер", diff --git a/app/static/js/theme.js b/app/static/js/theme.js index 2aabd44..a35d3b1 100644 --- a/app/static/js/theme.js +++ b/app/static/js/theme.js @@ -1,8 +1,14 @@ (() => { const KEY = "wrapped.theme"; + function systemTheme() { + return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + } + function preferred() { - return localStorage.getItem(KEY) || "dark"; + const stored = localStorage.getItem(KEY); + if (stored === "light" || stored === "dark") return stored; + return systemTheme(); } function apply(theme) { @@ -16,7 +22,8 @@ } function toggle() { - const next = preferred() === "dark" ? "light" : "dark"; + const current = document.documentElement.getAttribute("data-theme") || preferred(); + const next = current === "dark" ? "light" : "dark"; localStorage.setItem(KEY, next); apply(next); } diff --git a/app/static/js/ui.js b/app/static/js/ui.js index fc817b6..45040e9 100644 --- a/app/static/js/ui.js +++ b/app/static/js/ui.js @@ -8,10 +8,13 @@ busyEl.className = "busy-overlay hidden"; busyEl.setAttribute("aria-live", "polite"); busyEl.setAttribute("aria-busy", "true"); + const favicon = + document.querySelector('link[rel="icon"][type="image/svg+xml"]')?.href || + "/static/favicon.svg"; busyEl.innerHTML = `

@@ -48,5 +51,18 @@ return `${gb.toFixed(2)} GB`; } - window.WrappedUI = { showBusy, hideBusy, formatBytes }; + function hapticLight() { + try { + navigator.vibrate?.(12); + } catch { + /* ignore */ + } + } + + async function copyText(text) { + await navigator.clipboard.writeText(text || ""); + hapticLight(); + } + + window.WrappedUI = { showBusy, hideBusy, formatBytes, hapticLight, copyText }; })(); diff --git a/app/static/js/unwrap.js b/app/static/js/unwrap.js index 3b8a0f0..ed4d9aa 100644 --- a/app/static/js/unwrap.js +++ b/app/static/js/unwrap.js @@ -1,6 +1,7 @@ (() => { const t = (k, vars) => window.WrappedI18n.t(k, vars); let settings = null; + const objectUrls = []; const els = { token: document.getElementById("token-input"), @@ -11,11 +12,24 @@ errorHint: document.getElementById("form-error-hint"), errorAttempts: document.getElementById("form-error-attempts"), form: document.getElementById("unwrap-form"), + head: document.getElementById("unwrap-head"), + state: document.getElementById("unwrap-state"), + stateTitle: document.getElementById("unwrap-state-title"), + stateHint: document.getElementById("unwrap-state-hint"), + stateFa: document.getElementById("unwrap-state-fa"), result: document.getElementById("result-panel"), items: document.getElementById("items"), captchaSlot: document.getElementById("captcha-slot"), + lightbox: document.getElementById("image-lightbox"), + lightboxImg: document.getElementById("lightbox-img"), + lightboxCaption: document.getElementById("lightbox-caption"), + lightboxClose: document.getElementById("lightbox-close"), + downloadAll: document.getElementById("download-all"), }; + let lastPack = null; + let lastWrapId = null; + function showError(title, hint, attempts) { if (!els.error) return; if (els.errorTitle) els.errorTitle.textContent = title || ""; @@ -37,7 +51,6 @@ 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"); } @@ -56,6 +69,17 @@ } } + function showState({ title, hint, icon = "fa-link-slash" }) { + clearError(); + els.form?.classList.add("hidden"); + els.head?.classList.add("hidden"); + els.result?.classList.add("hidden"); + if (els.stateFa) els.stateFa.className = `fa-solid ${icon}`; + if (els.stateTitle) els.stateTitle.textContent = title || ""; + if (els.stateHint) els.stateHint.textContent = hint || ""; + els.state?.classList.remove("hidden"); + } + function attemptsLabel(detail) { const n = Number(detail?.attempts_remaining); const max = Number(detail?.attempts_max); @@ -63,6 +87,41 @@ return t("unwrap.attemptsLeft", { n, max }); } + function trackUrl(url) { + objectUrls.push(url); + return url; + } + + function revokeAllUrls() { + while (objectUrls.length) { + try { + URL.revokeObjectURL(objectUrls.pop()); + } catch { + /* ignore */ + } + } + } + + function closeLightbox() { + if (!els.lightbox) return; + els.lightbox.classList.add("hidden"); + document.body.classList.remove("modal-open"); + if (els.lightboxImg) { + els.lightboxImg.removeAttribute("src"); + els.lightboxImg.alt = ""; + } + if (els.lightboxCaption) els.lightboxCaption.textContent = ""; + } + + function openLightbox(src, caption) { + if (!els.lightbox || !els.lightboxImg) return; + els.lightboxImg.src = src; + els.lightboxImg.alt = caption || ""; + if (els.lightboxCaption) els.lightboxCaption.textContent = caption || ""; + els.lightbox.classList.remove("hidden"); + document.body.classList.add("modal-open"); + } + function loadCaptcha() { els.captchaSlot.innerHTML = ""; const provider = settings.captcha_provider; @@ -105,10 +164,11 @@ function downloadBlob(name, blob) { const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); + const url = URL.createObjectURL(blob); + a.href = url; a.download = name; a.click(); - setTimeout(() => URL.revokeObjectURL(a.href), 2000); + setTimeout(() => URL.revokeObjectURL(url), 2000); } function makeDownloadBtn(onClick) { @@ -120,6 +180,12 @@ return btn; } + function focusPassword({ select = false } = {}) { + if (!els.password) return; + els.password.focus(); + if (select) els.password.select?.(); + } + function makeCopyBtn(getText) { const btn = document.createElement("button"); btn.className = "btn copy-btn"; @@ -129,7 +195,11 @@ btn.innerHTML = label(); btn.addEventListener("click", async () => { try { - await navigator.clipboard.writeText(getText() || ""); + if (window.WrappedUI?.copyText) { + await window.WrappedUI.copyText(getText() || ""); + } else { + await navigator.clipboard.writeText(getText() || ""); + } btn.innerHTML = `${t("common.copied")}`; setTimeout(() => { btn.innerHTML = label(); @@ -141,7 +211,71 @@ return btn; } + function itemToBlob(item) { + if (item.type === "text") { + const lang = item.language || "plaintext"; + return { + name: `wrapped-${lang}.txt`, + blob: new Blob([item.content || ""], { type: "text/plain" }), + }; + } + if (item.type === "file") { + const bytes = window.WrappedCrypto.base64ToBytes(item.data_b64); + const mime = item.mime || "application/octet-stream"; + return { + name: item.name || "file", + blob: new Blob([bytes], { type: mime }), + }; + } + return null; + } + + function uniqueZipName(name, used) { + let base = name || "file"; + if (!used.has(base)) { + used.add(base); + return base; + } + const dot = base.lastIndexOf("."); + const stem = dot > 0 ? base.slice(0, dot) : base; + const ext = dot > 0 ? base.slice(dot) : ""; + let i = 2; + let candidate = `${stem}-${i}${ext}`; + while (used.has(candidate)) { + i += 1; + candidate = `${stem}-${i}${ext}`; + } + used.add(candidate); + return candidate; + } + + async function downloadAllZip() { + if (!lastPack || typeof JSZip === "undefined") return; + const items = lastPack.items || []; + if (items.length < 2) return; + const zip = new JSZip(); + const used = new Set(); + for (const item of items) { + const entry = itemToBlob(item); + if (!entry) continue; + zip.file(uniqueZipName(entry.name, used), entry.blob); + } + const blob = await zip.generateAsync({ type: "blob" }); + const id = lastWrapId || "pack"; + downloadBlob(`wrapped-${id}.zip`, blob); + } + + function syncDownloadAll() { + if (!els.downloadAll) return; + const count = lastPack?.items?.length || 0; + const show = count >= 2 && typeof JSZip !== "undefined"; + els.downloadAll.classList.toggle("hidden", !show); + els.downloadAll.setAttribute("aria-label", t("unwrap.downloadAll")); + } + function renderPackage(pack) { + revokeAllUrls(); + lastPack = pack; els.items.innerHTML = ""; for (const item of pack.items || []) { const card = document.createElement("div"); @@ -151,7 +285,16 @@ const text = item.content || ""; const head = document.createElement("div"); head.className = "item-card-head"; - head.innerHTML = `
text · ${lang}
`; + const meta = document.createElement("div"); + const strong = document.createElement("strong"); + strong.textContent = "text"; + meta.appendChild(strong); + meta.appendChild(document.createTextNode(" · ")); + const langEl = document.createElement("span"); + langEl.className = "mono"; + langEl.textContent = lang; + meta.appendChild(langEl); + head.appendChild(meta); const actions = document.createElement("div"); actions.className = "item-card-actions"; actions.appendChild(makeCopyBtn(() => text)); @@ -170,28 +313,86 @@ window.WrappedHighlight.highlightElement(code, text, 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" }); + const mime = item.mime || "application/octet-stream"; + const blob = new Blob([bytes], { type: mime }); + const name = item.name || "file"; const head = document.createElement("div"); head.className = "item-card-head"; - head.innerHTML = `
${item.name} · ${item.mime} · ${window.WrappedUI.formatBytes(bytes.length)}
`; - head.appendChild(makeDownloadBtn(() => downloadBlob(item.name || "file", blob))); + const meta = document.createElement("div"); + const strong = document.createElement("strong"); + strong.textContent = name; + meta.appendChild(strong); + meta.appendChild(document.createTextNode(" · ")); + const mimeEl = document.createElement("span"); + mimeEl.className = "mono"; + mimeEl.textContent = mime; + meta.appendChild(mimeEl); + meta.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`)); + head.appendChild(meta); + head.appendChild(makeDownloadBtn(() => downloadBlob(name, blob))); card.appendChild(head); - if ((item.mime || "").startsWith("image/")) { + if (mime.startsWith("image/")) { + const url = trackUrl(URL.createObjectURL(blob)); const img = document.createElement("img"); - img.alt = item.name; - img.src = URL.createObjectURL(blob); + img.alt = name; + img.src = url; + img.className = "item-preview-img"; + img.loading = "lazy"; + img.addEventListener("click", () => openLightbox(url, name)); card.appendChild(img); + const tip = document.createElement("p"); + tip.className = "hint item-preview-hint"; + tip.textContent = t("unwrap.tapPreview"); + card.appendChild(tip); } } els.items.appendChild(card); } + syncDownloadAll(); } + function mapHttpError(resp, detail) { + if (resp.status === 429 || detail === "rate_limited") { + showError(t("unwrap.rateLimited"), t("unwrap.rateLimitedHint")); + return; + } + if (detail === "captcha_failed" || resp.status === 400) { + if (detail === "captcha_failed") { + showError(t("unwrap.captchaFailed"), t("unwrap.captchaFailedHint")); + return; + } + } + if (resp.status === 500 || detail === "storage_error") { + showError(t("unwrap.storageError"), t("unwrap.storageErrorHint")); + return; + } + if (resp.status === 410 || detail === "unavailable") { + showState({ + title: t("unwrap.goneTitle"), + hint: t("unwrap.goneHint"), + icon: "fa-link-slash", + }); + return; + } + showError(t("unwrap.unavailable"), t("unwrap.unavailableHint")); + } + + els.lightboxClose?.addEventListener("click", closeLightbox); + els.lightbox?.addEventListener("click", (e) => { + if (e.target === els.lightbox) closeLightbox(); + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && els.lightbox && !els.lightbox.classList.contains("hidden")) { + closeLightbox(); + } + }); + window.addEventListener("pagehide", revokeAllUrls); + els.btn.addEventListener("click", async () => { clearError(); const parsed = window.WrappedCrypto.parseToken(els.token.value); if (!parsed) { - showError(t("unwrap.unavailable")); + showError(t("unwrap.badToken"), t("unwrap.badTokenHint")); return; } const key = resolveKey(parsed); @@ -201,7 +402,7 @@ } els.btn.disabled = true; - window.WrappedUI.showBusy(t("unwrap.working")); + window.WrappedUI.showBusy(t("unwrap.workingFetch")); try { const resp = await fetch(`/api/v1/wraps/${encodeURIComponent(parsed.wrapId)}/unwrap`, { method: "POST", @@ -216,10 +417,11 @@ const detail = err.detail; if (detail && typeof detail === "object") { if (detail.code === "password_locked") { - showError( - t("unwrap.passwordLocked"), - t("unwrap.passwordLockedHint") - ); + showState({ + title: t("unwrap.passwordLocked"), + hint: t("unwrap.passwordLockedHint"), + icon: "fa-shield-halved", + }); return; } if (detail.code === "password_required") { @@ -228,7 +430,7 @@ t("unwrap.passwordRequiredHint"), attemptsLabel(detail) ); - els.password?.focus(); + focusPassword({ select: true }); return; } if (detail.code === "bad_password") { @@ -237,19 +439,21 @@ t("unwrap.badPasswordHint"), attemptsLabel(detail) ); - els.password?.focus(); - els.password?.select?.(); + focusPassword({ select: true }); return; } } showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint")); + focusPassword({ select: true }); return; } if (!resp.ok) { - showError(t("unwrap.unavailable")); + const err = await resp.json().catch(() => ({})); + mapHttpError(resp, err.detail); return; } const data = await resp.json(); + window.WrappedUI.showBusy(t("unwrap.workingDecrypt")); const ciphertext = window.WrappedCrypto.base64ToBytes(data.ciphertext_b64); let pack; try { @@ -261,17 +465,22 @@ } catch (err) { if (err.message === "password_required") { showError(t("unwrap.passwordRequired"), t("unwrap.passwordRequiredHint")); + focusPassword({ select: true }); return; } if (err.message === "bad_password") { showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint")); + focusPassword({ select: true }); return; } - showError(t("common.error")); + showError(t("unwrap.decryptFailed"), t("unwrap.decryptFailedHint")); return; } + lastWrapId = parsed.wrapId; renderPackage(pack); els.form.classList.add("hidden"); + els.head?.classList.add("hidden"); + els.state?.classList.add("hidden"); els.result.classList.remove("hidden"); history.replaceState(null, "", location.pathname); } catch { @@ -282,6 +491,23 @@ } }); + els.password?.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + els.btn?.click(); + } + }); + + els.downloadAll?.addEventListener("click", () => { + downloadAllZip().catch(() => {}); + }); + + if (window.WrappedI18n.onChange) { + window.WrappedI18n.onChange(() => { + syncDownloadAll(); + }); + } + async function init() { const resp = await fetch("/api/v1/settings"); settings = await resp.json(); @@ -295,4 +521,3 @@ init().catch(() => showError(t("common.error"))); })(); - diff --git a/app/static/manifest.webmanifest b/app/static/manifest.webmanifest new file mode 100644 index 0000000..081ab98 --- /dev/null +++ b/app/static/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Wrapped", + "short_name": "Wrapped", + "description": "Zero-knowledge one-time encrypted drop for text and files.", + "start_url": "/", + "display": "standalone", + "background_color": "#0b1020", + "theme_color": "#0b1020", + "icons": [ + { + "src": "/static/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/app/static_url.py b/app/static_url.py new file mode 100644 index 0000000..825ab19 --- /dev/null +++ b/app/static_url.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pathlib import Path + +from app.version import get_app_version + +_STATIC_ROOT = Path(__file__).resolve().parent / "static" + + +def static_url(path: str) -> str: + """URL for a file under app/static with cache-busting query (?v=...). + + Stamp = app version + file mtime so CSS/JS updates apply even when VERSION + is unchanged (compose bind-mount / same-tag redeploy). + """ + rel = path.lstrip("/") + if rel.startswith("static/"): + rel = rel[len("static/") :] + stamp = get_app_version() + try: + mtime = int((_STATIC_ROOT / rel).stat().st_mtime) + stamp = f"{stamp}.{mtime}" + except OSError: + pass + return f"/static/{rel}?v={stamp}" diff --git a/app/templates/admin_audit.html b/app/templates/admin_audit.html index 6750e4f..de93176 100644 --- a/app/templates/admin_audit.html +++ b/app/templates/admin_audit.html @@ -140,5 +140,5 @@ {% endblock %} {% block admin_scripts %} - + {% endblock %} diff --git a/app/templates/admin_base.html b/app/templates/admin_base.html index 9d02b99..4ac0da0 100644 --- a/app/templates/admin_base.html +++ b/app/templates/admin_base.html @@ -7,15 +7,17 @@ - - - - + + + + + + -
+
-
- + +
+
- - + + diff --git a/app/templates/admin_settings.html b/app/templates/admin_settings.html index ab63ad9..9724c71 100644 --- a/app/templates/admin_settings.html +++ b/app/templates/admin_settings.html @@ -150,5 +150,5 @@
- + {% endblock %} diff --git a/app/templates/admin_stats.html b/app/templates/admin_stats.html index f20fecc..372fc2a 100644 --- a/app/templates/admin_stats.html +++ b/app/templates/admin_stats.html @@ -28,18 +28,29 @@
-

Unwrapped (all time)

+

Successful unwraps

-

{{ stats.wraps_consumed }}

+

{{ stats.unwraps_success }}

- audit ok - {{ stats.audit_unwraps_ok }} + DB consumed + {{ stats.wraps_consumed }} · 24h {{ stats.consumed_24h }}

+
+
+ +

Burned by password

+
+

{{ stats.password_burns }}

+

+ Destroyed after too many wrong passwords +

+
+
@@ -93,13 +104,13 @@ consumed {{ stats.wraps_consumed }} {{ stats.size_consumed_human }} - — + {{ stats.items_consumed }} expired {{ stats.wraps_expired }} - — - — + {{ stats.size_expired_human }} + {{ stats.items_expired }} Total @@ -142,6 +153,64 @@
+ +
+
+

Unwrap fail reasons (all time)

+
+ + + + + + + + + {% if stats.unwrap_fail_reasons %} + {% for row in stats.unwrap_fail_reasons %} + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
ReasonCount
{{ row.reason }}{{ row.count }}
No failed unwraps yet.
+
+
+ +
+

Unwrap fail reasons (24h)

+
+ + + + + + + + + {% if stats.unwrap_fail_reasons_24h %} + {% for row in stats.unwrap_fail_reasons_24h %} + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
ReasonCount
{{ row.reason }}{{ row.count }}
No failed unwraps yet.
+
+
+
{% endblock %} {% block admin_scripts %} diff --git a/app/templates/base.html b/app/templates/base.html index ad80589..a924562 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -7,10 +7,12 @@ - - - - + + + + + + @@ -71,11 +73,11 @@
- - - - - + + + + + {% block scripts %}{% endblock %} diff --git a/app/templates/create.html b/app/templates/create.html index b54af22..ac900ea 100644 --- a/app/templates/create.html +++ b/app/templates/create.html @@ -24,6 +24,7 @@
    +

    @@ -64,17 +65,59 @@ After opening, ciphertext is deleted on the server.
    - -
    - - + +
      +
    • Copy the share link
    • + +
    • Note the expiry time
    • +
    +

    + The encryption key lives only in the link #fragment — the server never sees it. +

    + +
    +
    + + + +
    + + +
    + + +
    + + +
    + +
    + + +
    + +

    +
    + +
    + +

    Scan to open the share link

    +
    - -
    - - -
    -

    +
    @@ -92,6 +135,7 @@
    {% endblock %} {% block scripts %} - - + + + {% endblock %} diff --git a/app/templates/unwrap.html b/app/templates/unwrap.html index 52f9931..6b7f5dc 100644 --- a/app/templates/unwrap.html +++ b/app/templates/unwrap.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block content %}
    -
    +

    Unwrap

    Reveal once

    Paste a wrapped token or open a share link. Ciphertext is fetched once and deleted from the server.

    @@ -32,6 +32,15 @@
    + +
    + + {% endblock %} {% block scripts %} - - + + + {% endblock %} diff --git a/helm/wrapped/Chart.yaml b/helm/wrapped/Chart.yaml index 88aab9b..d47b2d8 100644 --- a/helm/wrapped/Chart.yaml +++ b/helm/wrapped/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: wrapped description: Zero-knowledge one-time encrypted drop (Wrapped) type: application -version: 0.1.2 -appVersion: "0.1.2" +version: 0.1.4 +appVersion: "0.1.4" diff --git a/helm/wrapped/values.yaml b/helm/wrapped/values.yaml index fc2e967..8b38239 100644 --- a/helm/wrapped/values.yaml +++ b/helm/wrapped/values.yaml @@ -2,7 +2,7 @@ replicaCount: 1 image: repository: inecs/wrapped - tag: "0.1.2" + tag: "0.1.4" pullPolicy: IfNotPresent service: diff --git a/pyproject.toml b/pyproject.toml index ecd5afa..7d23783 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrapped" -version = "0.1.2" +version = "0.1.4" description = "Zero-knowledge one-time encrypted drop service" readme = "README.md" requires-python = ">=3.12"