Добавить статистику и очистку аудита; доработать UI расшифровки.
- Страница /admin/stats: MinIO, pending/consumed, загрузки, audit-метрики - Кнопка «Очистить» в аудите с подтверждением - Красивые callout’ы и ошибки пароля; пустой пароль не тратит попытку - Убрать автоопределение языка; «Расшифруй» в RU UI
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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")));
|
||||
})();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user