Добавить статистику и очистку аудита; доработать UI расшифровки.

- Страница /admin/stats: MinIO, pending/consumed, загрузки, audit-метрики
- Кнопка «Очистить» в аудите с подтверждением
- Красивые callout’ы и ошибки пароля; пустой пароль не тратит попытку
- Убрать автоопределение языка; «Расшифруй» в RU UI
This commit is contained in:
Sergey Antropoff
2026-07-18 23:50:44 +03:00
parent 91719740b9
commit 8361b8e3f2
17 changed files with 1007 additions and 258 deletions
+10 -15
View File
@@ -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`.
+41 -3
View File
@@ -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),
+25 -1
View File
@@ -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)
+8
View File
@@ -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:
+159
View File
@@ -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,
}
+23
View File
@@ -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()
+325 -11
View File
@@ -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);
+17
View File
@@ -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;
+2 -57
View File
@@ -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();
});
-115
View File
@@ -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("<!DOCTYPE")) {
scores.html += 6;
}
if (/[{;]\s*$/m.test(raw) && /[.#]?[\w-]+\s*\{/.test(raw) && !tryParseJson(raw)) {
scores.css += 5;
}
if (/^#{1,6}\s+\S+/m.test(raw) || /\[[^\]]+\]\([^)]+\)/.test(raw)) {
scores.markdown += 4;
}
const ranked = Object.entries(scores)
.filter(([lang]) => lang !== "plaintext")
.sort((a, b) => b[1] - a[1]);
const best = ranked[0];
const second = ranked[1];
if (!best || best[1] < 3) {
return {
lang: "plaintext",
confidence: 0.2,
suggestions: ranked
.filter(([, s]) => s > 0)
.slice(0, 4)
.map(([l]) => l)
.concat(["plaintext"]),
};
}
const confidence =
best[1] >= 8
? 0.95
: best[1] >= 5
? 0.75
: second && best[1] - second[1] <= 1
? 0.45
: 0.6;
const suggestions = ranked
.filter(([, s]) => s > 0)
.slice(0, 5)
.map(([l]) => l);
if (!suggestions.includes("plaintext")) suggestions.push("plaintext");
return { lang: best[0], confidence, suggestions };
}
function hljsLang(lang) {
const map = {
plaintext: "plaintext",
@@ -182,7 +68,6 @@
}
window.WrappedHighlight = {
detectLanguage,
highlightElement,
syncThemeStylesheet,
languages,
+94 -20
View File
@@ -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": "событий",
+73 -19
View File
@@ -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")));
})();
+30 -9
View File
@@ -1,5 +1,13 @@
{% extends "admin_base.html" %}
{% block admin_content %}
{% if request.query_params.get('cleared') is not none %}
<p class="ok">
<span data-i18n="admin.audit.clearedPrefix">Cleared</span>
<span class="audit-count">{{ request.query_params.get('cleared') }}</span>
<span data-i18n="admin.audit.clearedSuffix">audit event(s).</span>
</p>
{% endif %}
<section class="audit-page">
<form class="audit-filters" id="audit-filter-form" method="get" action="/admin/audit">
<input type="hidden" name="page" value="1" />
@@ -27,15 +35,28 @@
<button class="btn primary audit-filter-btn" type="submit" data-i18n="admin.audit.filter">Filter</button>
</form>
<div class="audit-meta">
{% if total %}
<span class="audit-count">{{ from_idx }}{{ to_idx }}</span>
<span data-i18n="admin.audit.of">of</span>
<span class="audit-count">{{ total }}</span>
{% else %}
<span class="audit-count">0</span>
{% endif %}
<span data-i18n="admin.audit.events">events</span>
<div class="audit-meta-row">
<div class="audit-meta">
{% if total %}
<span class="audit-count">{{ from_idx }}{{ to_idx }}</span>
<span data-i18n="admin.audit.of">of</span>
<span class="audit-count">{{ total }}</span>
{% else %}
<span class="audit-count">0</span>
{% endif %}
<span data-i18n="admin.audit.events">events</span>
</div>
<form method="post" action="/admin/audit/clear" id="audit-clear-form" class="audit-clear-form">
<button
type="submit"
class="btn danger tiny"
id="audit-clear-btn"
{% if not total %}disabled{% endif %}
data-i18n="admin.audit.clear"
>
Clear
</button>
</form>
</div>
<div class="audit-table-wrap">
+5 -1
View File
@@ -27,6 +27,10 @@
</a>
<div class="admin-top-right">
<nav class="admin-nav" aria-label="Admin">
<a href="/admin/stats" class="admin-nav-link {% if active == 'stats' %}active{% endif %}">
<i class="fa-solid fa-chart-simple" aria-hidden="true"></i>
<span data-i18n="admin.nav.stats">Stats</span>
</a>
<a href="/admin/settings" class="admin-nav-link {% if active == 'settings' %}active{% endif %}">
<i class="fa-solid fa-sliders" aria-hidden="true"></i>
<span data-i18n="admin.nav.settings">Settings</span>
@@ -62,7 +66,7 @@
</div>
</header>
<main class="admin-shell">
<h1 data-i18n="{% if active == 'audit' %}admin.audit.title{% elif active == 'danger' %}admin.danger.title{% else %}admin.settings.title{% endif %}">{{ title }}</h1>
<h1 data-i18n="{% if active == 'stats' %}admin.stats.title{% elif active == 'audit' %}admin.audit.title{% elif active == 'danger' %}admin.danger.title{% else %}admin.settings.title{% endif %}">{{ title }}</h1>
{% block admin_content %}{% endblock %}
</main>
<script src="/static/js/i18n.js"></script>
+167
View File
@@ -0,0 +1,167 @@
{% extends "admin_base.html" %}
{% block admin_content %}
<section class="stats-page">
<p class="stats-updated hint">
<span data-i18n="admin.stats.updated">Updated</span>
<time id="stats-generated" data-ts="{{ stats.generated_at.isoformat() }}">{{ stats.generated_at }}</time>
</p>
<div class="stats-grid">
<article class="stat-card accent">
<div class="stat-card-head">
<i class="fa-solid fa-database" aria-hidden="true"></i>
<h2 data-i18n="admin.stats.minioTitle">MinIO (pending ciphertext)</h2>
</div>
<p class="stat-value">{{ stats.minio_human }}</p>
<p class="stat-sub">
<span class="stat-num">{{ stats.minio_objects }}</span>
<span data-i18n="admin.stats.objects">object(s)</span>
·
<span data-i18n="admin.stats.dbPending">DB pending</span>
{{ stats.size_pending_human }}
</p>
{% if stats.minio_error %}
<p class="error stat-error">{{ stats.minio_error }}</p>
{% endif %}
</article>
<article class="stat-card">
<div class="stat-card-head">
<i class="fa-solid fa-lock-open" aria-hidden="true"></i>
<h2 data-i18n="admin.stats.unwrappedTitle">Unwrapped (all time)</h2>
</div>
<p class="stat-value">{{ stats.wraps_consumed }}</p>
<p class="stat-sub">
<span data-i18n="admin.stats.auditOk">audit ok</span>
<span class="stat-num">{{ stats.audit_unwraps_ok }}</span>
·
<span data-i18n="admin.stats.last24h">24h</span>
<span class="stat-num">{{ stats.consumed_24h }}</span>
</p>
</article>
<article class="stat-card">
<div class="stat-card-head">
<i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
<h2 data-i18n="admin.stats.pendingTitle">Not unwrapped</h2>
</div>
<p class="stat-value">{{ stats.wraps_pending }}</p>
<p class="stat-sub">
{{ stats.size_pending_human }}
·
<span class="stat-num">{{ stats.items_pending }}</span>
<span data-i18n="admin.stats.items">item(s)</span>
</p>
</article>
<article class="stat-card">
<div class="stat-card-head">
<i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>
<h2 data-i18n="admin.stats.uploadedTitle">Uploaded (all time)</h2>
</div>
<p class="stat-value">{{ stats.wraps_total }}</p>
<p class="stat-sub">
<span class="stat-num">{{ stats.items_total }}</span>
<span data-i18n="admin.stats.items">item(s)</span>
·
{{ stats.size_total_human }}
</p>
</article>
</div>
<div class="stats-sections">
<section class="stats-section">
<h3 data-i18n="admin.stats.wrapsSection">Wraps by status</h3>
<div class="stats-table-wrap">
<table class="stats-table">
<thead>
<tr>
<th data-i18n="admin.stats.col.status">Status</th>
<th data-i18n="admin.stats.col.count">Count</th>
<th data-i18n="admin.stats.col.size">Size</th>
<th data-i18n="admin.stats.col.items">Items</th>
</tr>
</thead>
<tbody>
<tr>
<td data-i18n="admin.stats.status.pending">pending</td>
<td class="mono">{{ stats.wraps_pending }}</td>
<td class="mono">{{ stats.size_pending_human }}</td>
<td class="mono">{{ stats.items_pending }}</td>
</tr>
<tr>
<td data-i18n="admin.stats.status.consumed">consumed</td>
<td class="mono">{{ stats.wraps_consumed }}</td>
<td class="mono">{{ stats.size_consumed_human }}</td>
<td class="mono"></td>
</tr>
<tr>
<td data-i18n="admin.stats.status.expired">expired</td>
<td class="mono">{{ stats.wraps_expired }}</td>
<td class="mono"></td>
<td class="mono"></td>
</tr>
<tr class="stats-total-row">
<td data-i18n="admin.stats.total">Total</td>
<td class="mono">{{ stats.wraps_total }}</td>
<td class="mono">{{ stats.size_total_human }}</td>
<td class="mono">{{ stats.items_total }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="stats-section">
<h3 data-i18n="admin.stats.extraSection">More</h3>
<ul class="stats-list">
<li>
<span data-i18n="admin.stats.withPassword">With password</span>
<strong class="mono">{{ stats.wraps_with_password }}</strong>
</li>
<li>
<span data-i18n="admin.stats.created24h">Created (24h)</span>
<strong class="mono">{{ stats.created_24h }}</strong>
</li>
<li>
<span data-i18n="admin.stats.created7d">Created (7d)</span>
<strong class="mono">{{ stats.created_7d }}</strong>
</li>
<li>
<span data-i18n="admin.stats.createsOk">Creates ok (audit)</span>
<strong class="mono">{{ stats.audit_creates_ok }}</strong>
</li>
<li>
<span data-i18n="admin.stats.createsFail">Creates fail (audit)</span>
<strong class="mono">{{ stats.audit_creates_fail }}</strong>
</li>
<li>
<span data-i18n="admin.stats.unwrapsFail">Unwrap fails (audit)</span>
<strong class="mono">{{ stats.audit_unwraps_fail }}</strong>
</li>
</ul>
</section>
</div>
</section>
{% endblock %}
{% block admin_scripts %}
<script>
(() => {
const el = document.getElementById("stats-generated");
if (!el) return;
const raw = el.getAttribute("data-ts");
if (!raw) return;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return;
const locale = window.WrappedI18n?.locale?.() || undefined;
el.textContent = new Intl.DateTimeFormat(locale, {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(d);
})();
</script>
{% endblock %}
+9 -5
View File
@@ -10,12 +10,8 @@
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
</div>
<button type="button" class="btn tiny ghost" id="lang-change" data-i18n="create.languageChange">
Change type
</button>
<input type="hidden" id="text-language" value="plaintext" />
</div>
<p id="lang-hint" class="lang-hint hidden"></p>
<label class="sr-only" for="payload-text" data-i18n="create.text">Text</label>
<div class="code-editor" id="code-editor">
@@ -59,7 +55,15 @@
<div id="success-panel" class="success-panel hidden">
<h2 data-i18n="create.successTitle">Token issued</h2>
<p class="warn" data-i18n="create.successWarn">One-time unwrap. After someone opens it, ciphertext is destroyed on the server.</p>
<div class="success-callout" role="status">
<span class="success-callout-icon" aria-hidden="true">
<i class="fa-solid fa-fire"></i>
</span>
<span class="success-callout-body">
<strong data-i18n="create.successWarnTitle">One-time unwrap</strong>
<span data-i18n="create.successWarnHint">After opening, ciphertext is deleted on the server.</span>
</span>
</div>
<label data-i18n="create.shareLink">Share link</label>
<div class="copy-row">
<input id="share-link" readonly />
+19 -2
View File
@@ -17,14 +17,31 @@
<input id="unwrap-password" type="password" autocomplete="current-password" />
</div>
<div id="captcha-slot" class="captcha-slot"></div>
<p id="form-error" class="error hidden"></p>
<div id="form-error" class="form-alert danger hidden" role="alert">
<span class="form-alert-icon" aria-hidden="true">
<i class="fa-solid fa-key"></i>
</span>
<span class="form-alert-body">
<strong id="form-error-title"></strong>
<span id="form-error-hint" class="hidden"></span>
<span id="form-error-attempts" class="form-alert-attempts hidden"></span>
</span>
</div>
<div class="actions actions-center">
<button type="button" id="unwrap-btn" class="btn primary" data-i18n="unwrap.submit">Unwrap</button>
</div>
</div>
<div id="result-panel" class="result-panel hidden">
<p class="warn" data-i18n="unwrap.destroyed">Server copy destroyed. Preview lives only in this browser session.</p>
<div class="success-callout" role="status">
<span class="success-callout-icon" aria-hidden="true">
<i class="fa-solid fa-fire"></i>
</span>
<span class="success-callout-body">
<strong data-i18n="unwrap.destroyedTitle">Server copy destroyed</strong>
<span data-i18n="unwrap.destroyedHint">Preview lives only in this browser session.</span>
</span>
</div>
<div id="items" class="items"></div>
</div>
</section>