Compare commits

..

22 Commits

Author SHA1 Message Date
inecs f728d74f34 Убраны short-sha теги образа: в Hub/Harbor публикуются только SemVer и latest. 2026-07-29 13:41:17 +03:00
inecs 806f2bdb1b Выпущен 0.1.3: UX convenience pack, unwrap/admin polish и cache-bust статики.
devops-tools/wrapped/wrapped-build/pipeline/head There was a failure building this commit
Добавлены Share/QR PNG, zip all, size meter, PWA-manifest, системная тема и haptic на copy; улучшены success/unwrap и статистика админки (EN/RU).
2026-07-29 11:33:21 +03:00
inecs 49df683281 Исправлен Version: IMAGE_VERSION/RELEASE_TAG без git в DinD (dubious ownership). 2026-07-27 23:35:46 +03:00
inecs 7c381b46cf Исправлен RELEASE_TAG: выставляется после checkout, а не в env при agent none. 2026-07-27 23:32:58 +03:00
inecs 5e73671bf4 Расширена модалка «О проекте» на 20%. 2026-07-27 23:28:09 +03:00
inecs 1bc86d3bdd Разведены Builder и Deploy: сборка освобождает DinD до старта деплоя. 2026-07-27 23:16:06 +03:00
inecs 482621a23f Добавлена модалка «О проекте», версия 0.1.2; Jenkins берёт VERSION из коммита. 2026-07-27 23:11:27 +03:00
inecs fc11454aac Обновлены Jenkins credentials под ssh-gitea-key и остальные Global ID. 2026-07-27 22:54:05 +03:00
inecs 1481cc9d60 Добавлено автоверсионирование SemVer и версия v0.1.1 в футере. 2026-07-27 22:49:36 +03:00
inecs d54af8c2df fix(ci): use Jenkins fullName for deploy job trigger
Display name «Devops Tools/Wrapped Deploy/main» does not resolve;
trigger devops-tools/wrapped/wrapped-deploy/main instead.
2026-07-27 22:33:41 +03:00
inecs d72eb8f32e Добавлена связка Build → Deploy: после push образа триггерится Helm upgrade. 2026-07-27 15:17:12 +03:00
inecs b20f12e52e Добавлен Jenkinsfile.deploy для Helm upgrade wrapped в k3s; обновлён README. 2026-07-27 15:02:57 +03:00
inecs 2f30bd5bd3 Добавлен Jenkinsfile для multi-arch push в Harbor и Docker Hub; обновлён README. 2026-07-27 14:37:13 +03:00
inecs 8c04dbe0ea Исправлена прокрутка длинных строк в превью unwrap на десктопе и мобильном. 2026-07-27 12:52:42 +03:00
inecs 890fb52377 Исключён каталог .cursor из репозитория через .gitignore. 2026-07-23 13:25:58 +03:00
inecs 5353ef3869 Исправлен push multi-arch релиза; обновлена документация с разделом «Что нового». 2026-07-23 13:15:31 +03:00
inecs 680b704b47 Перевести make release на multi-arch сборку через Buildx (amd64/arm64). 2026-07-23 12:57:17 +03:00
inecs e0eceac76c Добавить копирование текста и двухосевую прокрутку в превью unwrap. 2026-07-23 12:25:29 +03:00
inecs 6b57e2e621 Обновить README под статистику, аудит и парольный UX. 2026-07-19 00:13:45 +03:00
inecs a881570938 Добавить статистику и очистку аудита; доработать UI расшифровки.
- Страница /admin/stats: MinIO, pending/consumed, загрузки, audit-метрики
- Кнопка «Очистить» в аудите с подтверждением
- Красивые callout’ы и ошибки пароля; пустой пароль не тратит попытку
- Убрать автоопределение языка; «Расшифруй» в RU UI
2026-07-18 23:50:44 +03:00
inecs 8c8dbd2348 Улучшить unwrap, UI и админку; обновить документацию.
- Неверный пароль не сжигает wrap сразу: Argon2-гейт до consume и лимит попыток (по умолчанию 3) в настройках
- Подсветка синтаксиса, автоопределение языка, спиннеры, размеры файлов, пагинация audit
- make push (git, Ctrl-D) и актуальный README
2026-07-18 10:17:05 +03:00
inecs 8f2d798e1a Init 2026-07-17 15:57:36 +03:00
26 changed files with 1327 additions and 153 deletions
Vendored
+10 -16
View File
@@ -11,6 +11,7 @@
// harbor-devops-tools-push-pull-access — Harbor devops-tools (robot)
// docker-hub — Docker Hub (inecs)
// ssh-gitea-key — SCM checkout Gitea
// gitea-jenkins-token — Gitea PAT; для статусов коммита: write:repository
// k3s-kubeconfig — в job Deploy (Jenkinsfile.deploy)
//
// Deploy job: devops-tools/wrapped/wrapped-deploy/main
@@ -29,7 +30,6 @@ pipeline {
HARBOR_REGISTRY = 'hub.antropoff.ru'
HARBOR_IMAGE = 'hub.antropoff.ru/devops-tools/wrapped'
DOCKERHUB_IMAGE = 'inecs/wrapped'
// RELEASE_TAG — после checkout (с agent none GIT_COMMIT ещё нет → валидация env падает)
BUILDX_BUILDER = "jenkins-wrapped-${env.BUILD_NUMBER}"
DEPLOY_JOB = 'devops-tools/wrapped/wrapped-deploy/main'
TZ = 'Europe/Moscow'
@@ -59,12 +59,7 @@ pipeline {
if (!env.IMAGE_VERSION) {
error('VERSION file is empty')
}
if (!env.GIT_COMMIT) {
error('GIT_COMMIT is empty after checkout')
}
env.RELEASE_TAG = env.GIT_COMMIT.take(7)
echo "IMAGE_VERSION from VERSION → ${env.IMAGE_VERSION}"
echo "RELEASE_TAG → ${env.RELEASE_TAG}"
}
}
}
@@ -88,7 +83,7 @@ pipeline {
set -eux
test -n "${IMAGE_VERSION}"
echo "Building tags: ${IMAGE_VERSION}, ${RELEASE_TAG}, latest"
echo "Building tags: ${IMAGE_VERSION}, latest (no short-sha)"
echo "$HARBOR_PASS" | docker login "$HARBOR_REGISTRY" -u "$HARBOR_USER" --password-stdin
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USER" --password-stdin
@@ -97,27 +92,26 @@ pipeline {
docker buildx create --name "$BUILDX_BUILDER" --driver docker-container --use
docker buildx inspect --bootstrap >/dev/null
# Arch-слои только в Harbor (промежуточные теги), не в Docker Hub
docker buildx build \
--platform linux/amd64 \
--provenance=false --sbom=false --push \
-t "${HARBOR_IMAGE}:${RELEASE_TAG}-amd64" \
-t "${DOCKERHUB_IMAGE}:${RELEASE_TAG}-amd64" \
-t "${HARBOR_IMAGE}:${IMAGE_VERSION}-amd64" \
-f Dockerfile .
docker buildx build \
--platform linux/arm64 \
--provenance=false --sbom=false --push \
-t "${HARBOR_IMAGE}:${RELEASE_TAG}-arm64" \
-t "${DOCKERHUB_IMAGE}:${RELEASE_TAG}-arm64" \
-t "${HARBOR_IMAGE}:${IMAGE_VERSION}-arm64" \
-f Dockerfile .
# Публичные теги: только SemVer + latest
for IMAGE in "$HARBOR_IMAGE" "$DOCKERHUB_IMAGE"; do
docker buildx imagetools create \
-t "${IMAGE}:${RELEASE_TAG}" \
-t "${IMAGE}:${IMAGE_VERSION}" \
-t "${IMAGE}:latest" \
"${IMAGE}:${RELEASE_TAG}-amd64" \
"${IMAGE}:${RELEASE_TAG}-arm64"
"${HARBOR_IMAGE}:${IMAGE_VERSION}-amd64" \
"${HARBOR_IMAGE}:${IMAGE_VERSION}-arm64"
done
echo "--- Harbor ---"
@@ -145,7 +139,7 @@ pipeline {
}
}
success {
echo "✓ Build OK: ${DOCKERHUB_IMAGE}:{${RELEASE_TAG},${IMAGE_VERSION},latest}"
echo "✓ Build OK: ${DOCKERHUB_IMAGE}:{${IMAGE_VERSION},latest}"
}
failure {
echo "✗ Build/push не удались — Deploy не запускается"
@@ -184,7 +178,7 @@ pipeline {
post {
success {
echo "✓ Version: ${IMAGE_VERSION}"
echo "✓ Image: ${DOCKERHUB_IMAGE}:{${RELEASE_TAG},${IMAGE_VERSION},latest}"
echo "✓ Image: ${DOCKERHUB_IMAGE}:{${IMAGE_VERSION},latest}"
echo "✓ Deploy: ${DEPLOY_JOB} triggered (async)"
}
failure {
+16 -5
View File
@@ -338,16 +338,27 @@ services:
Ключ в `#fragment` не уходит на сервер в запросе страницы.
### UX (v0.1.3+)
- После создания: **QR** на share-link, **скачать QR (PNG)**, **Web Share** (если есть `navigator.share`), чеклист и trust-строка про `#key`; отдельные кнопки Copy для ссылки / токена / пароля; пароль не советуется слать в той же переписке.
- Create: счётчик размера `≈ used / max` и предупреждение near-limit (сверх лимита — `create.tooLarge`).
- Unwrap: Enter в поле пароля отправляет форму; после `password_required` / `bad_password` — focus+select; при ≥2 элементах — **скачать всё** (zip через JSZip); trust-строка на результате; спокойный экран «ссылка недоступна» для already used / expired (anti-enumeration); отдельные состояния для `password_locked`, rate limit, CAPTCHA, ошибки расшифровки.
- Картинки после unwrap: inline-превью и **lightbox** (тап/клик).
- Тема при первом визите следует `prefers-color-scheme` (пока нет выбора в `localStorage`); лёгкий haptic после успешного Copy.
- Минимальный **PWA**: `manifest.webmanifest` (без Service Worker для API).
- UI строки EN/RU через `i18n.js`.
- Статика (`/static/...`) отдаётся с `?v=версия.mtime` (cache-bust после деплоя).
---
## Админка
Логин: `/admin/login`. После входа — **Статистика** (`/admin/stats`).
Логин: `/admin/login`. После входа — **Статистика** (`/admin/stats`). На мобиле меню — через гамбургер.
| Раздел | URL | Что делает |
|--------|-----|------------|
| **Статистика** | `/admin/stats` | MinIO: объём и число объектов нерасшифрованного ciphertext; расшифровано / pending; загрузки и items за всё время; таблица по статусам wraps; с паролем; создано за 24ч/7д; счётчики audit (create/unwrap ok/fail) |
| **Настройки** | `/admin/settings` | Limits (upload, TTL, retention audit), rate limits, MIME allowlist, пароль (режим + лимит попыток), CAPTCHA |
| **Статистика** | `/admin/stats` | MinIO pending ciphertext; успешные unwrap vs сожжения паролем; pending / uploads; таблица статусов (count/size/items); with password; 24h/7d creates; audit create/unwrap; разбивка fail-причин unwrap (all-time и 24h) |
| **Настройки** | `/admin/settings` | Limits (upload, TTL, retention audit), rate limits, MIME allowlist (в т.ч. YAML / `application/octet-stream` для kubeconfig и неизвестных типов), пароль, CAPTCHA |
| **Аудит** | `/admin/audit` | Фильтры, пагинация (10/25/50/100), номера страниц, кнопка **Очистить** (с подтверждением; пишется событие `admin.audit_clear`) |
| **Опасная зона** | `/admin/danger` | Полная очистка wraps и объектов в MinIO (`PURGE`) |
@@ -432,7 +443,7 @@ docker buildx build \
| Harbor | `hub.antropoff.ru/devops-tools/wrapped` |
| Docker Hub | `inecs/wrapped` |
Теги на каждый реестр: `:<semver>` (ровно из `VERSION` в коммите), `:<short-sha>`, `:latest`.
Публичные теги на каждый реестр: `:<semver>` (из `VERSION` в коммите) и `:latest`. Short-sha больше не пушится. Промежуточные `:<semver>-amd64` / `-arm64` остаются только в Harbor для сборки multi-arch manifest.
### Версионирование
@@ -452,7 +463,7 @@ Credentials (Global, как в job’ах):
| ID | Тип | Назначение |
|----|-----|------------|
| `ssh-gitea-key` | SSH Username with private key | SCM checkout |
| `gitea-jenkins-token` | Secret text / token | API Gitea (webhook/API при необходимости) |
| `gitea-jenkins-token` | Secret text (Gitea PAT) | SCM/API Gitea. Для commit status из Multibranch нужен scope **`write:repository`** (сейчас при read-only в логе: `Could not send notifications` / 403/405). Checkout достаточно `read:repository` |
| `harbor-devops-tools-push-pull-access` | Username/password | Harbor `devops-tools` (robot) |
| `docker-hub` | Username/password | Docker Hub `inecs` |
| `k3s-kubeconfig` | Secret file | Helm/kubectl deploy в K3S |
+1 -1
View File
@@ -1 +1 @@
0.1.2
0.1.4
+3
View File
@@ -14,6 +14,7 @@ from app.config import get_settings
from app.db import SessionLocal
from app.services.settings_service import get_or_create_settings
from app.services.storage import storage
from app.static_url import static_url
from app.version import get_app_version, get_app_version_label
OPENAPI_TAGS = [
@@ -102,8 +103,10 @@ def create_app() -> FastAPI:
# Callables so Jinja re-reads VERSION (dev mount / after bump) on each render
templates.env.globals["app_version"] = get_app_version
templates.env.globals["app_version_label"] = get_app_version_label
templates.env.globals["static_url"] = static_url
admin.templates.env.globals["app_version"] = get_app_version
admin.templates.env.globals["app_version_label"] = get_app_version_label
admin.templates.env.globals["static_url"] = static_url
@app.get("/health", tags=["System"], summary="Health check", include_in_schema=docs_on)
async def health():
+4
View File
@@ -138,6 +138,10 @@ DEFAULT_MIME_ALLOWLIST = [
"text/javascript",
"application/json",
"application/xml",
"application/yaml",
"application/x-yaml",
"text/yaml",
"application/octet-stream",
"application/pdf",
"application/zip",
"application/x-zip-compressed",
+52 -1
View File
@@ -117,6 +117,49 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]:
else:
bucket["fail"] += int(count or 0)
reason_col = AuditEvent.details.op("->>")("reason")
password_burns = int(
(
await db.execute(
select(func.count())
.select_from(AuditEvent)
.where(
AuditEvent.event_type == "wrap.unwrap",
AuditEvent.success.is_(False),
reason_col == "password_attempts_exceeded",
)
)
).scalar_one()
or 0
)
async def unwrap_fail_reasons(since: datetime | None = None) -> list[dict[str, Any]]:
filters = [
AuditEvent.event_type == "wrap.unwrap",
AuditEvent.success.is_(False),
]
if since is not None:
filters.append(AuditEvent.created_at >= since)
rows = (
await db.execute(
select(
func.coalesce(reason_col, "unknown"),
func.count(AuditEvent.id),
)
.where(*filters)
.group_by(func.coalesce(reason_col, "unknown"))
.order_by(func.count(AuditEvent.id).desc())
)
).all()
return [
{"reason": str(reason or "unknown"), "count": int(count or 0)}
for reason, count in rows
]
unwrap_fail_reasons_all = await unwrap_fail_reasons()
unwrap_fail_reasons_24h = await unwrap_fail_reasons(day_ago)
# MinIO live objects under wraps/
try:
minio = await storage.prefix_stats("wraps/")
@@ -128,6 +171,7 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]:
pending = by_status["pending"]
consumed = by_status["consumed"]
expired = by_status["expired"]
audit_unwraps_ok = audit.get("wrap.unwrap", {}).get("ok", 0)
return {
"wraps_total": int(total_wraps or 0),
@@ -137,6 +181,8 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]:
"wraps_with_password": int(with_password or 0),
"items_total": int(total_items or 0),
"items_pending": pending["items"],
"items_consumed": consumed["items"],
"items_expired": expired["items"],
"size_total_bytes": int(total_size or 0),
"size_pending_bytes": pending["size_bytes"],
"size_consumed_bytes": consumed["size_bytes"],
@@ -144,13 +190,18 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]:
"size_total_human": format_bytes(int(total_size or 0)),
"size_pending_human": format_bytes(pending["size_bytes"]),
"size_consumed_human": format_bytes(consumed["size_bytes"]),
"size_expired_human": format_bytes(expired["size_bytes"]),
"created_24h": created_24h,
"created_7d": created_7d,
"consumed_24h": consumed_24h,
"audit_creates_ok": audit.get("wrap.create", {}).get("ok", 0),
"audit_creates_fail": audit.get("wrap.create", {}).get("fail", 0),
"audit_unwraps_ok": audit.get("wrap.unwrap", {}).get("ok", 0),
"audit_unwraps_ok": audit_unwraps_ok,
"audit_unwraps_fail": audit.get("wrap.unwrap", {}).get("fail", 0),
"unwraps_success": audit_unwraps_ok,
"password_burns": password_burns,
"unwrap_fail_reasons": unwrap_fail_reasons_all,
"unwrap_fail_reasons_24h": unwrap_fail_reasons_24h,
"minio_objects": int(minio.get("objects") or 0),
"minio_bytes": int(minio.get("bytes") or 0),
"minio_human": format_bytes(int(minio.get("bytes") or 0)),
+414 -10
View File
@@ -113,6 +113,7 @@ html[data-theme="light"] body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 0.75rem;
max-width: none;
width: 100%;
@@ -262,6 +263,14 @@ html[data-theme="dark"] .icon-btn:hover {
.result-panel > .success-callout {
justify-self: stretch;
}
.result-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.result-panel > .success-trust {
margin: 0;
}
.success-panel {
justify-items: stretch;
text-align: left;
@@ -273,6 +282,149 @@ html[data-theme="dark"] .icon-btn:hover {
.success-panel > .btn.ghost {
justify-self: center;
}
.success-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1.25rem 1.5rem;
align-items: start;
min-width: 0;
}
.success-main {
display: grid;
gap: 0.55rem;
min-width: 0;
}
.success-main > label {
margin-top: 0.35rem;
}
.success-password-block {
display: grid;
gap: 0.45rem;
margin-bottom: 0.35rem;
}
.success-password-badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
width: fit-content;
max-width: 100%;
margin: 0;
padding: 0.4rem 0.7rem;
border-radius: 10px;
border: 1px solid rgba(61, 214, 198, 0.35);
background: rgba(61, 214, 198, 0.1);
color: var(--accent-2);
font-size: 0.82rem;
font-weight: 650;
}
.success-password-hint {
margin: 0;
font-size: 0.84rem;
line-height: 1.4;
}
.success-checklist {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.success-checklist li {
position: relative;
padding-left: 1.35rem;
color: var(--muted);
font-size: 0.88rem;
line-height: 1.4;
}
.success-checklist li::before {
content: "";
position: absolute;
left: 0;
top: 0.35rem;
width: 0.55rem;
height: 0.55rem;
border-radius: 2px;
border: 1.5px solid var(--accent-2);
opacity: 0.85;
}
.success-trust {
margin: 0;
font-size: 0.84rem;
line-height: 1.45;
}
.success-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.35rem;
}
.success-actions .btn {
flex: 1 1 auto;
min-width: 8rem;
}
.size-meter {
margin: 0;
font-variant-numeric: tabular-nums;
}
.size-meter.is-warn {
color: #e8a838;
}
.size-meter.is-over {
color: var(--danger);
}
.success-qr {
display: grid;
justify-items: center;
gap: 0.45rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
min-width: 0;
}
.share-qr {
width: 180px;
height: 180px;
display: grid;
place-items: center;
background: #fff;
border-radius: 12px;
padding: 0.5rem;
box-sizing: border-box;
}
.share-qr img,
.share-qr canvas {
display: block;
max-width: 100%;
height: auto;
}
.success-qr-hint {
margin: 0;
text-align: center;
font-size: 0.78rem;
max-width: 11rem;
line-height: 1.35;
}
@media (max-width: 860px) {
.success-layout {
grid-template-columns: 1fr;
}
.success-qr {
order: -1;
justify-self: center;
width: min(100%, 280px);
}
.share-qr {
width: min(220px, 70vw);
height: min(220px, 70vw);
}
.copy-row {
grid-template-columns: 1fr;
}
.copy-row .btn {
width: 100%;
}
}
.success-callout {
display: flex;
align-items: center;
@@ -441,12 +593,28 @@ input, select, textarea, .code-input {
}
.lang-modal-panel {
width: min(480px, 100%);
display: flex;
flex-direction: column;
overflow: hidden;
}
.lang-modal-panel .modal-title,
.lang-modal-panel .modal-message,
.lang-modal-panel .modal-actions {
flex-shrink: 0;
}
.lang-modal-panel .modal-message {
margin-bottom: 0.75rem;
}
.lang-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
margin-bottom: 1rem;
margin-bottom: 0.85rem;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
@media (max-width: 520px) {
.lang-grid { grid-template-columns: 1fr; }
@@ -947,6 +1115,110 @@ html[data-theme="light"] .about-version {
transform: none;
}
.item-card img,
.item-preview-img {
display: block;
max-width: 100%;
width: auto;
height: auto;
border-radius: 10px;
margin-top: 0.65rem;
cursor: zoom-in;
}
.item-preview-hint {
margin: 0.4rem 0 0;
font-size: 0.78rem;
}
.unwrap-state {
display: grid;
justify-items: center;
gap: 0.75rem;
text-align: center;
padding: 1.5rem 0.5rem 0.5rem;
min-width: 0;
}
.unwrap-state.hidden {
display: none;
}
.unwrap-state-icon {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: 18px;
border: 1px solid rgba(110, 168, 255, 0.25);
background: linear-gradient(160deg, rgba(110, 168, 255, 0.14), rgba(13, 20, 32, 0.35));
color: var(--accent-2);
font-size: 1.45rem;
}
html[data-theme="light"] .unwrap-state-icon {
background: linear-gradient(160deg, rgba(47, 111, 237, 0.12), rgba(255, 255, 255, 0.9));
}
.unwrap-state h2 {
margin: 0;
font-size: 1.35rem;
font-weight: 650;
max-width: 22rem;
line-height: 1.25;
}
.unwrap-state .lede {
margin: 0;
max-width: 28rem;
}
.unwrap-state .btn {
margin-top: 0.35rem;
}
.image-lightbox {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
grid-template-rows: auto 1fr auto;
align-items: center;
justify-items: center;
gap: 0.75rem;
padding: max(0.75rem, env(safe-area-inset-top, 0px))
max(0.75rem, env(safe-area-inset-right, 0px))
max(0.75rem, env(safe-area-inset-bottom, 0px))
max(0.75rem, env(safe-area-inset-left, 0px));
background: rgba(6, 10, 18, 0.88);
backdrop-filter: blur(6px);
}
.image-lightbox.hidden {
display: none;
}
.image-lightbox-close {
justify-self: end;
width: 42px;
height: 42px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(20, 28, 42, 0.7);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.image-lightbox img {
max-width: min(100%, 960px);
max-height: min(78dvh, 78vh);
width: auto;
height: auto;
object-fit: contain;
border-radius: 12px;
cursor: default;
}
.image-lightbox-caption {
margin: 0;
color: rgba(255, 255, 255, 0.85);
font-size: 0.9rem;
text-align: center;
max-width: 90vw;
word-break: break-word;
}
.items {
display: grid;
gap: 1rem;
@@ -1000,11 +1272,6 @@ html[data-theme="light"] .item-card pre,
html[data-theme="light"] .item-card .item-text-pre {
background: rgba(16, 32, 56, 0.05);
}
.item-card img {
max-width: 100%;
border-radius: 8px;
margin-top: 0.6rem;
}
/* Admin */
.admin-body { min-height: 100vh; }
@@ -1074,6 +1341,15 @@ html[data-theme="light"] .item-card .item-text-pre {
margin: 0;
font-size: 0.82rem;
}
.admin-top-tools {
display: flex;
align-items: center;
gap: 0.45rem;
flex-shrink: 0;
}
.admin-nav-toggle {
display: none;
}
.admin-top-right {
display: flex;
align-items: center;
@@ -1099,6 +1375,10 @@ html[data-theme="light"] .item-card .item-text-pre {
backdrop-filter: blur(12px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
}
.admin-nav-logout-form {
display: inline;
margin: 0;
}
.admin-nav-link {
display: inline-flex;
align-items: center;
@@ -1142,6 +1422,113 @@ html[data-theme="light"] .item-card .item-text-pre {
html[data-theme="light"] .admin-nav {
box-shadow: 0 8px 24px rgba(20, 40, 70, 0.08);
}
@media (max-width: 860px) {
.admin-top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
"brand tools"
"nav nav";
align-items: center;
gap: 0.55rem 0.65rem;
padding: 0.7rem 0.65rem;
}
.admin-top > .brand {
grid-area: brand;
min-width: 0;
}
.admin-top-tools {
grid-area: tools;
}
.admin-top .brand-tagline {
max-width: 42vw;
}
.admin-nav-toggle {
display: grid;
}
.admin-nav {
grid-area: nav;
display: none;
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
width: 100%;
max-height: min(70dvh, 28rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 0.4rem;
gap: 0.2rem;
}
.admin-top.is-nav-open .admin-nav {
display: flex;
}
.admin-nav-link {
width: 100%;
justify-content: flex-start;
padding: 0.7rem 0.85rem;
font-size: 0.92rem;
}
.admin-nav-logout-form {
display: block;
width: 100%;
}
.admin-nav-logout-form .admin-nav-link {
width: 100%;
}
.admin-shell {
padding: 0 0.65rem 2rem;
}
.admin-shell > h1 {
font-size: 1.35rem;
margin-bottom: 0.75rem;
}
.admin-tablist {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
.admin-tab {
flex: 0 0 auto;
}
.admin-tabpanel {
padding: 0.9rem;
}
.audit-filters {
display: grid;
grid-template-columns: 1fr;
align-items: stretch;
}
.audit-field,
.audit-field-grow {
min-width: 0;
width: 100%;
}
.audit-filter-btn {
width: 100%;
height: auto;
min-height: 42px;
}
.audit-table {
min-width: 720px;
}
.admin-login-toggles {
top: max(0.75rem, env(safe-area-inset-top, 0px));
right: max(0.75rem, env(safe-area-inset-right, 0px));
}
}
@media (min-width: 861px) {
.admin-top > .brand {
flex: 0 0 auto;
}
.admin-nav {
margin-left: auto;
}
}
.danger-page {
max-width: 520px;
}
@@ -1262,12 +1649,9 @@ html[data-theme="light"] .admin-nav {
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
@media (max-width: 1100px) {
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 620px) {
.stats-grid { grid-template-columns: 1fr; }
}
@@ -1643,6 +2027,11 @@ body.modal-open {
display: grid;
place-items: center;
padding: 1.25rem;
/* iOS: безопасные отступы от notch / home indicator */
padding: max(0.75rem, env(safe-area-inset-top, 0px))
max(0.75rem, env(safe-area-inset-right, 0px))
max(0.75rem, env(safe-area-inset-bottom, 0px))
max(0.75rem, env(safe-area-inset-left, 0px));
}
.modal-root.hidden {
display: none;
@@ -1659,6 +2048,10 @@ html[data-theme="light"] .modal-backdrop {
.modal-panel {
position: relative;
width: min(420px, 100%);
max-height: calc(100vh - 1.5rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
@@ -1666,6 +2059,17 @@ html[data-theme="light"] .modal-backdrop {
padding: 1.35rem 1.4rem 1.25rem;
animation: modal-in 0.18s ease-out;
}
@supports (height: 100dvh) {
.modal-panel {
max-height: calc(100dvh - 1.5rem);
}
}
/* Список языков: скролл внутри сетки, заголовок и Cancel остаются на экране */
.modal-panel.lang-modal-panel {
overflow: hidden;
display: flex;
flex-direction: column;
}
@keyframes modal-in {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: none; }
+52
View File
@@ -0,0 +1,52 @@
(() => {
const top = document.getElementById("admin-top");
const toggle = document.getElementById("admin-nav-toggle");
const nav = document.getElementById("admin-nav");
if (!top || !toggle || !nav) return;
const mq = window.matchMedia("(max-width: 860px)");
const syncLabel = () => {
const open = top.classList.contains("is-nav-open");
const key = open ? "admin.nav.closeMenu" : "admin.nav.openMenu";
const label = window.WrappedI18n?.t(key) || (open ? "Close menu" : "Menu");
toggle.setAttribute("aria-label", label);
toggle.setAttribute("title", label);
};
const setOpen = (open) => {
top.classList.toggle("is-nav-open", open);
toggle.setAttribute("aria-expanded", open ? "true" : "false");
const icon = toggle.querySelector("i");
if (icon) {
icon.className = open ? "fa-solid fa-xmark" : "fa-solid fa-bars";
}
syncLabel();
};
toggle.addEventListener("click", () => {
setOpen(!top.classList.contains("is-nav-open"));
});
nav.addEventListener("click", (e) => {
if (!mq.matches) return;
if (e.target.closest("a.admin-nav-link")) setOpen(false);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && top.classList.contains("is-nav-open")) {
setOpen(false);
toggle.focus();
}
});
mq.addEventListener("change", () => {
if (!mq.matches) setOpen(false);
else syncLabel();
});
if (window.WrappedI18n?.onChange) {
window.WrappedI18n.onChange(syncLabel);
}
syncLabel();
})();
+133 -7
View File
@@ -25,8 +25,15 @@
composer: document.querySelector(".composer"),
shareLink: document.getElementById("share-link"),
shareToken: document.getElementById("share-token"),
sharePassword: document.getElementById("share-password"),
passwordBlock: document.getElementById("success-password-block"),
shareQr: document.getElementById("share-qr"),
expiresMeta: document.getElementById("expires-meta"),
captchaSlot: document.getElementById("captcha-slot"),
sizeMeter: document.getElementById("size-meter"),
checkPasswordItem: document.getElementById("check-password-item"),
shareNative: document.getElementById("share-native"),
downloadQr: document.getElementById("download-qr"),
};
function showError(msg) {
@@ -100,6 +107,34 @@
document.body.classList.remove("modal-open");
}
function estimatePayloadBytes() {
const textBytes = new TextEncoder().encode(els.text?.value || "").length;
const fileBytes = files.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
return textBytes + fileBytes;
}
function refreshSizeMeter() {
if (!els.sizeMeter || !settings) return;
const used = estimatePayloadBytes();
const max = Number(settings.max_upload_bytes) || 0;
if (!max) {
els.sizeMeter.textContent = "";
els.sizeMeter.classList.remove("is-warn", "is-over");
return;
}
const fmt = window.WrappedUI.formatBytes;
let text = t("create.sizeMeter", {
used: fmt(used),
max: fmt(max),
});
const near = used > max * 0.85 && used <= max;
const over = used > max;
if (near) text = `${text} · ${t("create.sizeNearLimit")}`;
els.sizeMeter.textContent = text;
els.sizeMeter.classList.toggle("is-over", over);
els.sizeMeter.classList.toggle("is-warn", near);
}
function renderFiles() {
els.fileList.innerHTML = "";
files.forEach((f, idx) => {
@@ -115,6 +150,7 @@
li.appendChild(btn);
els.fileList.appendChild(li);
});
refreshSizeMeter();
}
function addFiles(list) {
@@ -207,6 +243,8 @@
loadCaptcha();
setLanguage("plaintext", { silent: true });
refreshHighlight();
refreshSizeMeter();
syncShareControls();
}
els.dropzone.addEventListener("click", () => els.fileInput.click());
@@ -243,6 +281,7 @@
els.text.addEventListener("input", () => {
refreshHighlight();
refreshSizeMeter();
});
els.text.addEventListener("scroll", () => {
const pre = els.highlight.parentElement;
@@ -260,19 +299,104 @@
}
});
function renderShareQr(link) {
if (!els.shareQr) return;
els.shareQr.innerHTML = "";
if (!link || typeof QRCode === "undefined") return;
const size = window.matchMedia("(max-width: 860px)").matches ? 200 : 164;
// eslint-disable-next-line no-new
new QRCode(els.shareQr, {
text: link,
width: size,
height: size,
colorDark: "#0d1420",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M,
});
}
function syncShareControls() {
if (els.shareNative) {
const canShare = typeof navigator.share === "function";
els.shareNative.classList.toggle("hidden", !canShare);
els.shareNative.setAttribute("aria-label", t("create.share"));
}
if (els.downloadQr) {
els.downloadQr.setAttribute("aria-label", t("create.downloadQr"));
}
}
function showSuccess({ link, token, password, expiresAt }) {
els.shareLink.value = link;
els.shareToken.value = token;
lastExpiresAt = expiresAt;
refreshExpiresMeta();
if (password) {
els.sharePassword.value = password;
els.passwordBlock.classList.remove("hidden");
els.checkPasswordItem?.classList.remove("hidden");
} else {
els.sharePassword.value = "";
els.passwordBlock.classList.add("hidden");
els.checkPasswordItem?.classList.add("hidden");
}
renderShareQr(link);
syncShareControls();
els.composer.classList.add("hidden");
els.success.classList.remove("hidden");
els.success.scrollIntoView({ behavior: "smooth", block: "start" });
}
async function copyFrom(input, btn) {
await navigator.clipboard.writeText(input.value);
try {
await window.WrappedUI.copyText(input.value);
} catch {
await navigator.clipboard.writeText(input.value);
}
const old = btn.textContent;
btn.textContent = t("common.copied");
setTimeout(() => (btn.textContent = old), 1200);
}
function downloadQrPng() {
if (!els.shareQr) return;
const canvas = els.shareQr.querySelector("canvas");
const img = els.shareQr.querySelector("img");
let href = "";
if (canvas && canvas.toDataURL) {
href = canvas.toDataURL("image/png");
} else if (img?.src) {
href = img.src;
}
if (!href) return;
const a = document.createElement("a");
a.href = href;
a.download = "wrapped-qr.png";
a.click();
}
document.getElementById("copy-link").addEventListener("click", () => {
copyFrom(els.shareLink, document.getElementById("copy-link"));
});
document.getElementById("copy-token").addEventListener("click", () => {
copyFrom(els.shareToken, document.getElementById("copy-token"));
});
document.getElementById("copy-password")?.addEventListener("click", () => {
copyFrom(els.sharePassword, document.getElementById("copy-password"));
});
els.shareNative?.addEventListener("click", async () => {
if (typeof navigator.share !== "function" || !els.shareLink.value) return;
try {
await navigator.share({
title: t("create.shareTitle"),
text: t("create.shareText"),
url: els.shareLink.value,
});
} catch {
/* user cancelled or unsupported */
}
});
els.downloadQr?.addEventListener("click", downloadQrPng);
document.getElementById("create-another").addEventListener("click", () => {
location.reload();
});
@@ -346,12 +470,12 @@
const data = await resp.json();
const token = window.WrappedCrypto.buildToken(data.wrap_id, keyB64url);
const link = `${location.origin}${data.share_path}#${keyB64url}`;
els.shareLink.value = link;
els.shareToken.value = token;
lastExpiresAt = data.expires_at;
refreshExpiresMeta();
els.composer.classList.add("hidden");
els.success.classList.remove("hidden");
showSuccess({
link,
token,
password,
expiresAt: data.expires_at,
});
} catch {
showError(t("common.error"));
} finally {
@@ -380,6 +504,8 @@
window.WrappedI18n.onChange(() => {
fillTtl();
refreshExpiresMeta();
refreshSizeMeter();
syncShareControls();
setLanguage(els.lang.value, { silent: true });
refreshHighlight();
if (els.langChipText) els.langChipText.textContent = langLabel(els.lang.value);
+90 -6
View File
@@ -39,6 +39,20 @@
"create.successTitle": "Token issued",
"create.successWarnTitle": "One-time unwrap",
"create.successWarnHint": "After opening, ciphertext is deleted on the server.",
"create.passwordProtected": "Password protected",
"create.sharePassword": "Password",
"create.passwordSeparateHint": "Do not send the password in the same chat as the link.",
"create.qrHint": "Scan to open the share link",
"create.share": "Share",
"create.shareTitle": "Wrapped link",
"create.shareText": "One-time secure link from Wrapped",
"create.downloadQr": "Download QR",
"create.checkLink": "Copy the share link",
"create.checkPassword": "Send the password in a separate message",
"create.checkExpires": "Note the expiry time",
"create.trustKey": "The encryption key lives only in the link #fragment — the server never sees it.",
"create.sizeMeter": "≈ {used} / {max}",
"create.sizeNearLimit": "Approaching size limit",
"create.shareLink": "Share link",
"create.token": "Wrapped token",
"create.another": "Create another",
@@ -78,17 +92,36 @@
"unwrap.password": "Password (if set)",
"unwrap.submit": "Unwrap",
"unwrap.working": "Decrypting…",
"unwrap.workingFetch": "Downloading…",
"unwrap.workingDecrypt": "Decrypting…",
"unwrap.destroyedTitle": "Server copy destroyed",
"unwrap.destroyedHint": "Preview lives only in this browser session.",
"unwrap.needKey": "Missing encryption key. Open the full share link (with #key) or paste the full wrapped token.",
"unwrap.badToken": "Invalid token",
"unwrap.badTokenHint": "Paste a full wrapped token or open a valid share link.",
"unwrap.badPassword": "Wrong password",
"unwrap.badPasswordHint": "Check the password and try again.",
"unwrap.passwordRequired": "Password required",
"unwrap.passwordRequiredHint": "This wrap is password-protected.",
"unwrap.attemptsLeft": "{n} of {max} attempts left",
"unwrap.passwordLocked": "Too many wrong passwords",
"unwrap.passwordLockedHint": "This wrap has been destroyed.",
"unwrap.unavailable": "Unavailable (already used, expired, or invalid).",
"unwrap.passwordLockedHint": "This wrap has been destroyed after too many failed attempts.",
"unwrap.unavailable": "Unavailable",
"unwrap.unavailableHint": "Already used, expired, or invalid.",
"unwrap.goneTitle": "This link is no longer available",
"unwrap.goneHint": "Wrapped links are one-time and can expire. The ciphertext is gone from the server.",
"unwrap.rateLimited": "Too many requests",
"unwrap.rateLimitedHint": "Wait a minute and try again.",
"unwrap.captchaFailed": "CAPTCHA failed",
"unwrap.captchaFailedHint": "Complete the CAPTCHA and try again.",
"unwrap.storageError": "Temporary storage error",
"unwrap.storageErrorHint": "Try again in a moment.",
"unwrap.decryptFailed": "Could not decrypt",
"unwrap.decryptFailedHint": "The key in the link may be wrong. Ciphertext was already removed from the server.",
"unwrap.createOwn": "Create your own wrap",
"unwrap.tapPreview": "Tap image to enlarge",
"unwrap.downloadAll": "Download all",
"unwrap.trustKey": "The key was only in the link #fragment and was never sent to the server.",
"common.copy": "Copy",
"common.copied": "Copied",
"common.download": "Download",
@@ -100,6 +133,8 @@
"admin.nav.danger": "Danger",
"admin.nav.site": "Site",
"admin.nav.logout": "Logout",
"admin.nav.openMenu": "Menu",
"admin.nav.closeMenu": "Close menu",
"admin.brand.tagline": "Admin console",
"admin.settings.title": "Settings",
"admin.stats.title": "Statistics",
@@ -107,14 +142,21 @@
"admin.stats.minioTitle": "MinIO (pending ciphertext)",
"admin.stats.objects": "object(s)",
"admin.stats.dbPending": "DB pending",
"admin.stats.unwrappedTitle": "Unwrapped (all time)",
"admin.stats.unwrappedTitle": "Successful unwraps",
"admin.stats.auditOk": "audit ok",
"admin.stats.dbConsumed": "DB consumed",
"admin.stats.last24h": "24h",
"admin.stats.pendingTitle": "Not unwrapped",
"admin.stats.items": "item(s)",
"admin.stats.uploadedTitle": "Uploaded (all time)",
"admin.stats.passwordBurnsTitle": "Burned by password",
"admin.stats.passwordBurnsHint": "Destroyed after too many wrong passwords",
"admin.stats.wrapsSection": "Wraps by status",
"admin.stats.extraSection": "More",
"admin.stats.failReasonsSection": "Unwrap fail reasons (all time)",
"admin.stats.failReasons24hSection": "Unwrap fail reasons (24h)",
"admin.stats.col.reason": "Reason",
"admin.stats.noFailReasons": "No failed unwraps yet.",
"admin.stats.col.status": "Status",
"admin.stats.col.count": "Count",
"admin.stats.col.size": "Size",
@@ -257,6 +299,20 @@
"create.successTitle": "Токен выдан",
"create.successWarnTitle": "Расшифруй один раз",
"create.successWarnHint": "После открытия ciphertext удаляется на сервере.",
"create.passwordProtected": "Защищено паролем",
"create.sharePassword": "Пароль",
"create.passwordSeparateHint": "Не отправляйте пароль в той же переписке, что и ссылку.",
"create.qrHint": "Отсканируйте, чтобы открыть ссылку",
"create.share": "Поделиться",
"create.shareTitle": "Ссылка Wrapped",
"create.shareText": "Одноразовая защищённая ссылка из Wrapped",
"create.downloadQr": "Скачать QR",
"create.checkLink": "Скопируйте ссылку",
"create.checkPassword": "Отправьте пароль отдельным сообщением",
"create.checkExpires": "Учтите срок действия",
"create.trustKey": "Ключ шифрования только во фрагменте ссылки #… — сервер его не видит.",
"create.sizeMeter": "≈ {used} / {max}",
"create.sizeNearLimit": "Близко к лимиту размера",
"create.shareLink": "Ссылка",
"create.token": "Wrapped-токен",
"create.another": "Создать ещё",
@@ -296,17 +352,36 @@
"unwrap.password": "Пароль (если задан)",
"unwrap.submit": "Расшифровать",
"unwrap.working": "Расшифровка…",
"unwrap.workingFetch": "Скачивание…",
"unwrap.workingDecrypt": "Расшифровка…",
"unwrap.destroyedTitle": "Копия на сервере уничтожена",
"unwrap.destroyedHint": "Превью только в этой сессии браузера.",
"unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.",
"unwrap.badToken": "Неверный токен",
"unwrap.badTokenHint": "Вставьте полный wrapped-токен или откройте корректную ссылку.",
"unwrap.badPassword": "Неверный пароль",
"unwrap.badPasswordHint": "Проверьте пароль и попробуйте снова.",
"unwrap.passwordRequired": "Нужен пароль",
"unwrap.passwordRequiredHint": "Этот wrap защищён паролем.",
"unwrap.attemptsLeft": "Осталось попыток: {n} из {max}",
"unwrap.passwordLocked": "Слишком много неверных паролей",
"unwrap.passwordLockedHint": "Этот wrap уничтожен.",
"unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).",
"unwrap.passwordLockedHint": "Wrap уничтожен после исчерпания попыток.",
"unwrap.unavailable": "Недоступно",
"unwrap.unavailableHint": "Уже использовано, истекло или неверно.",
"unwrap.goneTitle": "Ссылка больше недоступна",
"unwrap.goneHint": "Wrapped-ссылки одноразовые и могут истекать. Ciphertext уже удалён с сервера.",
"unwrap.rateLimited": "Слишком много запросов",
"unwrap.rateLimitedHint": "Подождите минуту и попробуйте снова.",
"unwrap.captchaFailed": "CAPTCHA не пройдена",
"unwrap.captchaFailedHint": "Пройдите CAPTCHA и попробуйте снова.",
"unwrap.storageError": "Временная ошибка хранилища",
"unwrap.storageErrorHint": "Попробуйте чуть позже.",
"unwrap.decryptFailed": "Не удалось расшифровать",
"unwrap.decryptFailedHint": "Ключ в ссылке может быть неверным. Ciphertext уже удалён с сервера.",
"unwrap.createOwn": "Создать свой wrap",
"unwrap.tapPreview": "Нажмите на изображение, чтобы увеличить",
"unwrap.downloadAll": "Скачать всё",
"unwrap.trustKey": "Ключ был только во фрагменте ссылки #… и не уходил на сервер.",
"common.copy": "Копировать",
"common.copied": "Скопировано",
"common.download": "Скачать",
@@ -318,6 +393,8 @@
"admin.nav.danger": "Опасная зона",
"admin.nav.site": "Сайт",
"admin.nav.logout": "Выйти",
"admin.nav.openMenu": "Меню",
"admin.nav.closeMenu": "Закрыть меню",
"admin.brand.tagline": "Консоль администратора",
"admin.settings.title": "Настройки",
"admin.stats.title": "Статистика",
@@ -325,14 +402,21 @@
"admin.stats.minioTitle": "MinIO (нерасшифрованный ciphertext)",
"admin.stats.objects": "объект(ов)",
"admin.stats.dbPending": "в БД pending",
"admin.stats.unwrappedTitle": "Расшифровано (за всё время)",
"admin.stats.unwrappedTitle": "Успешные unwrap",
"admin.stats.auditOk": "audit ok",
"admin.stats.dbConsumed": "в БД consumed",
"admin.stats.last24h": "за 24ч",
"admin.stats.pendingTitle": "Не расшифровано",
"admin.stats.items": "элемент(ов)",
"admin.stats.uploadedTitle": "Загружено (за всё время)",
"admin.stats.passwordBurnsTitle": "Сожжено паролем",
"admin.stats.passwordBurnsHint": "Уничтожено после слишком многих неверных паролей",
"admin.stats.wrapsSection": "Wraps по статусу",
"admin.stats.extraSection": "Ещё",
"admin.stats.failReasonsSection": "Причины ошибок unwrap (всё время)",
"admin.stats.failReasons24hSection": "Причины ошибок unwrap (24ч)",
"admin.stats.col.reason": "Причина",
"admin.stats.noFailReasons": "Пока нет неудачных unwrap.",
"admin.stats.col.status": "Статус",
"admin.stats.col.count": "Кол-во",
"admin.stats.col.size": "Размер",
+9 -2
View File
@@ -1,8 +1,14 @@
(() => {
const KEY = "wrapped.theme";
function systemTheme() {
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
function preferred() {
return localStorage.getItem(KEY) || "dark";
const stored = localStorage.getItem(KEY);
if (stored === "light" || stored === "dark") return stored;
return systemTheme();
}
function apply(theme) {
@@ -16,7 +22,8 @@
}
function toggle() {
const next = preferred() === "dark" ? "light" : "dark";
const current = document.documentElement.getAttribute("data-theme") || preferred();
const next = current === "dark" ? "light" : "dark";
localStorage.setItem(KEY, next);
apply(next);
}
+18 -2
View File
@@ -8,10 +8,13 @@
busyEl.className = "busy-overlay hidden";
busyEl.setAttribute("aria-live", "polite");
busyEl.setAttribute("aria-busy", "true");
const favicon =
document.querySelector('link[rel="icon"][type="image/svg+xml"]')?.href ||
"/static/favicon.svg";
busyEl.innerHTML = `
<div class="busy-card">
<span class="busy-logo" aria-hidden="true">
<img src="/static/favicon.svg" alt="" width="48" height="48" />
<img src="${favicon}" alt="" width="48" height="48" />
</span>
<p class="busy-text" data-busy-label></p>
</div>
@@ -48,5 +51,18 @@
return `${gb.toFixed(2)} GB`;
}
window.WrappedUI = { showBusy, hideBusy, formatBytes };
function hapticLight() {
try {
navigator.vibrate?.(12);
} catch {
/* ignore */
}
}
async function copyText(text) {
await navigator.clipboard.writeText(text || "");
hapticLight();
}
window.WrappedUI = { showBusy, hideBusy, formatBytes, hapticLight, copyText };
})();
+248 -23
View File
@@ -1,6 +1,7 @@
(() => {
const t = (k, vars) => window.WrappedI18n.t(k, vars);
let settings = null;
const objectUrls = [];
const els = {
token: document.getElementById("token-input"),
@@ -11,11 +12,24 @@
errorHint: document.getElementById("form-error-hint"),
errorAttempts: document.getElementById("form-error-attempts"),
form: document.getElementById("unwrap-form"),
head: document.getElementById("unwrap-head"),
state: document.getElementById("unwrap-state"),
stateTitle: document.getElementById("unwrap-state-title"),
stateHint: document.getElementById("unwrap-state-hint"),
stateFa: document.getElementById("unwrap-state-fa"),
result: document.getElementById("result-panel"),
items: document.getElementById("items"),
captchaSlot: document.getElementById("captcha-slot"),
lightbox: document.getElementById("image-lightbox"),
lightboxImg: document.getElementById("lightbox-img"),
lightboxCaption: document.getElementById("lightbox-caption"),
lightboxClose: document.getElementById("lightbox-close"),
downloadAll: document.getElementById("download-all"),
};
let lastPack = null;
let lastWrapId = null;
function showError(title, hint, attempts) {
if (!els.error) return;
if (els.errorTitle) els.errorTitle.textContent = title || "";
@@ -37,7 +51,6 @@
els.errorAttempts.classList.add("hidden");
}
}
// Fallback if structured nodes missing
if (!els.errorTitle) els.error.textContent = [title, hint, attempts].filter(Boolean).join(" ");
els.error.classList.remove("hidden");
}
@@ -56,6 +69,17 @@
}
}
function showState({ title, hint, icon = "fa-link-slash" }) {
clearError();
els.form?.classList.add("hidden");
els.head?.classList.add("hidden");
els.result?.classList.add("hidden");
if (els.stateFa) els.stateFa.className = `fa-solid ${icon}`;
if (els.stateTitle) els.stateTitle.textContent = title || "";
if (els.stateHint) els.stateHint.textContent = hint || "";
els.state?.classList.remove("hidden");
}
function attemptsLabel(detail) {
const n = Number(detail?.attempts_remaining);
const max = Number(detail?.attempts_max);
@@ -63,6 +87,41 @@
return t("unwrap.attemptsLeft", { n, max });
}
function trackUrl(url) {
objectUrls.push(url);
return url;
}
function revokeAllUrls() {
while (objectUrls.length) {
try {
URL.revokeObjectURL(objectUrls.pop());
} catch {
/* ignore */
}
}
}
function closeLightbox() {
if (!els.lightbox) return;
els.lightbox.classList.add("hidden");
document.body.classList.remove("modal-open");
if (els.lightboxImg) {
els.lightboxImg.removeAttribute("src");
els.lightboxImg.alt = "";
}
if (els.lightboxCaption) els.lightboxCaption.textContent = "";
}
function openLightbox(src, caption) {
if (!els.lightbox || !els.lightboxImg) return;
els.lightboxImg.src = src;
els.lightboxImg.alt = caption || "";
if (els.lightboxCaption) els.lightboxCaption.textContent = caption || "";
els.lightbox.classList.remove("hidden");
document.body.classList.add("modal-open");
}
function loadCaptcha() {
els.captchaSlot.innerHTML = "";
const provider = settings.captcha_provider;
@@ -105,10 +164,11 @@
function downloadBlob(name, blob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
const url = URL.createObjectURL(blob);
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
function makeDownloadBtn(onClick) {
@@ -120,6 +180,12 @@
return btn;
}
function focusPassword({ select = false } = {}) {
if (!els.password) return;
els.password.focus();
if (select) els.password.select?.();
}
function makeCopyBtn(getText) {
const btn = document.createElement("button");
btn.className = "btn copy-btn";
@@ -129,7 +195,11 @@
btn.innerHTML = label();
btn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(getText() || "");
if (window.WrappedUI?.copyText) {
await window.WrappedUI.copyText(getText() || "");
} else {
await navigator.clipboard.writeText(getText() || "");
}
btn.innerHTML = `<i class="fa-solid fa-check" aria-hidden="true"></i><span>${t("common.copied")}</span>`;
setTimeout(() => {
btn.innerHTML = label();
@@ -141,7 +211,71 @@
return btn;
}
function itemToBlob(item) {
if (item.type === "text") {
const lang = item.language || "plaintext";
return {
name: `wrapped-${lang}.txt`,
blob: new Blob([item.content || ""], { type: "text/plain" }),
};
}
if (item.type === "file") {
const bytes = window.WrappedCrypto.base64ToBytes(item.data_b64);
const mime = item.mime || "application/octet-stream";
return {
name: item.name || "file",
blob: new Blob([bytes], { type: mime }),
};
}
return null;
}
function uniqueZipName(name, used) {
let base = name || "file";
if (!used.has(base)) {
used.add(base);
return base;
}
const dot = base.lastIndexOf(".");
const stem = dot > 0 ? base.slice(0, dot) : base;
const ext = dot > 0 ? base.slice(dot) : "";
let i = 2;
let candidate = `${stem}-${i}${ext}`;
while (used.has(candidate)) {
i += 1;
candidate = `${stem}-${i}${ext}`;
}
used.add(candidate);
return candidate;
}
async function downloadAllZip() {
if (!lastPack || typeof JSZip === "undefined") return;
const items = lastPack.items || [];
if (items.length < 2) return;
const zip = new JSZip();
const used = new Set();
for (const item of items) {
const entry = itemToBlob(item);
if (!entry) continue;
zip.file(uniqueZipName(entry.name, used), entry.blob);
}
const blob = await zip.generateAsync({ type: "blob" });
const id = lastWrapId || "pack";
downloadBlob(`wrapped-${id}.zip`, blob);
}
function syncDownloadAll() {
if (!els.downloadAll) return;
const count = lastPack?.items?.length || 0;
const show = count >= 2 && typeof JSZip !== "undefined";
els.downloadAll.classList.toggle("hidden", !show);
els.downloadAll.setAttribute("aria-label", t("unwrap.downloadAll"));
}
function renderPackage(pack) {
revokeAllUrls();
lastPack = pack;
els.items.innerHTML = "";
for (const item of pack.items || []) {
const card = document.createElement("div");
@@ -151,7 +285,16 @@
const text = item.content || "";
const head = document.createElement("div");
head.className = "item-card-head";
head.innerHTML = `<div><strong>text</strong> · <span class="mono">${lang}</span></div>`;
const meta = document.createElement("div");
const strong = document.createElement("strong");
strong.textContent = "text";
meta.appendChild(strong);
meta.appendChild(document.createTextNode(" · "));
const langEl = document.createElement("span");
langEl.className = "mono";
langEl.textContent = lang;
meta.appendChild(langEl);
head.appendChild(meta);
const actions = document.createElement("div");
actions.className = "item-card-actions";
actions.appendChild(makeCopyBtn(() => text));
@@ -170,28 +313,86 @@
window.WrappedHighlight.highlightElement(code, text, lang);
} else if (item.type === "file") {
const bytes = window.WrappedCrypto.base64ToBytes(item.data_b64);
const blob = new Blob([bytes], { type: item.mime || "application/octet-stream" });
const mime = item.mime || "application/octet-stream";
const blob = new Blob([bytes], { type: mime });
const name = item.name || "file";
const head = document.createElement("div");
head.className = "item-card-head";
head.innerHTML = `<div><strong>${item.name}</strong> · <span class="mono">${item.mime}</span> · ${window.WrappedUI.formatBytes(bytes.length)}</div>`;
head.appendChild(makeDownloadBtn(() => downloadBlob(item.name || "file", blob)));
const meta = document.createElement("div");
const strong = document.createElement("strong");
strong.textContent = name;
meta.appendChild(strong);
meta.appendChild(document.createTextNode(" · "));
const mimeEl = document.createElement("span");
mimeEl.className = "mono";
mimeEl.textContent = mime;
meta.appendChild(mimeEl);
meta.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`));
head.appendChild(meta);
head.appendChild(makeDownloadBtn(() => downloadBlob(name, blob)));
card.appendChild(head);
if ((item.mime || "").startsWith("image/")) {
if (mime.startsWith("image/")) {
const url = trackUrl(URL.createObjectURL(blob));
const img = document.createElement("img");
img.alt = item.name;
img.src = URL.createObjectURL(blob);
img.alt = name;
img.src = url;
img.className = "item-preview-img";
img.loading = "lazy";
img.addEventListener("click", () => openLightbox(url, name));
card.appendChild(img);
const tip = document.createElement("p");
tip.className = "hint item-preview-hint";
tip.textContent = t("unwrap.tapPreview");
card.appendChild(tip);
}
}
els.items.appendChild(card);
}
syncDownloadAll();
}
function mapHttpError(resp, detail) {
if (resp.status === 429 || detail === "rate_limited") {
showError(t("unwrap.rateLimited"), t("unwrap.rateLimitedHint"));
return;
}
if (detail === "captcha_failed" || resp.status === 400) {
if (detail === "captcha_failed") {
showError(t("unwrap.captchaFailed"), t("unwrap.captchaFailedHint"));
return;
}
}
if (resp.status === 500 || detail === "storage_error") {
showError(t("unwrap.storageError"), t("unwrap.storageErrorHint"));
return;
}
if (resp.status === 410 || detail === "unavailable") {
showState({
title: t("unwrap.goneTitle"),
hint: t("unwrap.goneHint"),
icon: "fa-link-slash",
});
return;
}
showError(t("unwrap.unavailable"), t("unwrap.unavailableHint"));
}
els.lightboxClose?.addEventListener("click", closeLightbox);
els.lightbox?.addEventListener("click", (e) => {
if (e.target === els.lightbox) closeLightbox();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && els.lightbox && !els.lightbox.classList.contains("hidden")) {
closeLightbox();
}
});
window.addEventListener("pagehide", revokeAllUrls);
els.btn.addEventListener("click", async () => {
clearError();
const parsed = window.WrappedCrypto.parseToken(els.token.value);
if (!parsed) {
showError(t("unwrap.unavailable"));
showError(t("unwrap.badToken"), t("unwrap.badTokenHint"));
return;
}
const key = resolveKey(parsed);
@@ -201,7 +402,7 @@
}
els.btn.disabled = true;
window.WrappedUI.showBusy(t("unwrap.working"));
window.WrappedUI.showBusy(t("unwrap.workingFetch"));
try {
const resp = await fetch(`/api/v1/wraps/${encodeURIComponent(parsed.wrapId)}/unwrap`, {
method: "POST",
@@ -216,10 +417,11 @@
const detail = err.detail;
if (detail && typeof detail === "object") {
if (detail.code === "password_locked") {
showError(
t("unwrap.passwordLocked"),
t("unwrap.passwordLockedHint")
);
showState({
title: t("unwrap.passwordLocked"),
hint: t("unwrap.passwordLockedHint"),
icon: "fa-shield-halved",
});
return;
}
if (detail.code === "password_required") {
@@ -228,7 +430,7 @@
t("unwrap.passwordRequiredHint"),
attemptsLabel(detail)
);
els.password?.focus();
focusPassword({ select: true });
return;
}
if (detail.code === "bad_password") {
@@ -237,19 +439,21 @@
t("unwrap.badPasswordHint"),
attemptsLabel(detail)
);
els.password?.focus();
els.password?.select?.();
focusPassword({ select: true });
return;
}
}
showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint"));
focusPassword({ select: true });
return;
}
if (!resp.ok) {
showError(t("unwrap.unavailable"));
const err = await resp.json().catch(() => ({}));
mapHttpError(resp, err.detail);
return;
}
const data = await resp.json();
window.WrappedUI.showBusy(t("unwrap.workingDecrypt"));
const ciphertext = window.WrappedCrypto.base64ToBytes(data.ciphertext_b64);
let pack;
try {
@@ -261,17 +465,22 @@
} catch (err) {
if (err.message === "password_required") {
showError(t("unwrap.passwordRequired"), t("unwrap.passwordRequiredHint"));
focusPassword({ select: true });
return;
}
if (err.message === "bad_password") {
showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint"));
focusPassword({ select: true });
return;
}
showError(t("common.error"));
showError(t("unwrap.decryptFailed"), t("unwrap.decryptFailedHint"));
return;
}
lastWrapId = parsed.wrapId;
renderPackage(pack);
els.form.classList.add("hidden");
els.head?.classList.add("hidden");
els.state?.classList.add("hidden");
els.result.classList.remove("hidden");
history.replaceState(null, "", location.pathname);
} catch {
@@ -282,6 +491,23 @@
}
});
els.password?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
els.btn?.click();
}
});
els.downloadAll?.addEventListener("click", () => {
downloadAllZip().catch(() => {});
});
if (window.WrappedI18n.onChange) {
window.WrappedI18n.onChange(() => {
syncDownloadAll();
});
}
async function init() {
const resp = await fetch("/api/v1/settings");
settings = await resp.json();
@@ -295,4 +521,3 @@
init().catch(() => showError(t("common.error")));
})();
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Wrapped",
"short_name": "Wrapped",
"description": "Zero-knowledge one-time encrypted drop for text and files.",
"start_url": "/",
"display": "standalone",
"background_color": "#0b1020",
"theme_color": "#0b1020",
"icons": [
{
"src": "/static/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from pathlib import Path
from app.version import get_app_version
_STATIC_ROOT = Path(__file__).resolve().parent / "static"
def static_url(path: str) -> str:
"""URL for a file under app/static with cache-busting query (?v=...).
Stamp = app version + file mtime so CSS/JS updates apply even when VERSION
is unchanged (compose bind-mount / same-tag redeploy).
"""
rel = path.lstrip("/")
if rel.startswith("static/"):
rel = rel[len("static/") :]
stamp = get_app_version()
try:
mtime = int((_STATIC_ROOT / rel).stat().st_mtime)
stamp = f"{stamp}.{mtime}"
except OSError:
pass
return f"/static/{rel}?v={stamp}"
+1 -1
View File
@@ -140,5 +140,5 @@
</section>
{% endblock %}
{% block admin_scripts %}
<script src="/static/js/admin-audit.js"></script>
<script src="{{ static_url('js/admin-audit.js') }}"></script>
{% endblock %}
+51 -37
View File
@@ -7,15 +7,17 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="stylesheet" href="{{ static_url('css/app.css') }}" />
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml" />
<link rel="icon" href="{{ static_url('favicon.ico') }}" sizes="any" />
<link rel="apple-touch-icon" href="{{ static_url('apple-touch-icon.png') }}" />
<link rel="manifest" href="{{ static_url('manifest.webmanifest') }}" />
<meta name="theme-color" content="#0b1020" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body class="admin-body">
<div class="bg-grid" aria-hidden="true"></div>
<header class="admin-top">
<header class="admin-top" id="admin-top">
<a class="brand" href="/admin">
<span class="brand-mark" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i>
@@ -25,35 +27,46 @@
<span class="brand-tagline" data-i18n="admin.brand.tagline">Admin console</span>
</span>
</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>
</a>
<a href="/admin/audit" class="admin-nav-link {% if active == 'audit' %}active{% endif %}">
<i class="fa-solid fa-list" aria-hidden="true"></i>
<span data-i18n="admin.nav.audit">Audit</span>
</a>
<a href="/admin/danger" class="admin-nav-link admin-nav-danger {% if active == 'danger' %}active{% endif %}">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
<span data-i18n="admin.nav.danger">Danger</span>
</a>
<a href="/" class="admin-nav-link" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span data-i18n="admin.nav.site">Site</span>
</a>
<form method="post" action="/admin/logout" class="inline">
<button class="admin-nav-link admin-nav-logout" type="submit">
<i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i>
<span data-i18n="admin.nav.logout">Logout</span>
</button>
</form>
</nav>
<nav class="admin-nav" id="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>
</a>
<a href="/admin/audit" class="admin-nav-link {% if active == 'audit' %}active{% endif %}">
<i class="fa-solid fa-list" aria-hidden="true"></i>
<span data-i18n="admin.nav.audit">Audit</span>
</a>
<a href="/admin/danger" class="admin-nav-link admin-nav-danger {% if active == 'danger' %}active{% endif %}">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
<span data-i18n="admin.nav.danger">Danger</span>
</a>
<a href="/" class="admin-nav-link" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span data-i18n="admin.nav.site">Site</span>
</a>
<form method="post" action="/admin/logout" class="inline admin-nav-logout-form">
<button class="admin-nav-link admin-nav-logout" type="submit">
<i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i>
<span data-i18n="admin.nav.logout">Logout</span>
</button>
</form>
</nav>
<div class="admin-top-tools">
<button
type="button"
class="icon-btn admin-nav-toggle"
id="admin-nav-toggle"
aria-controls="admin-nav"
aria-expanded="false"
title="Menu"
aria-label="Menu"
>
<i class="fa-solid fa-bars" aria-hidden="true"></i>
</button>
<div class="top-actions">
<button type="button" class="icon-btn" id="lang-toggle" title="Language" aria-label="Language">
<span id="lang-label">RU</span>
@@ -69,9 +82,10 @@
<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>
<script src="/static/js/theme.js"></script>
<script src="/static/js/modal.js"></script>
<script src="{{ static_url('js/i18n.js') }}"></script>
<script src="{{ static_url('js/theme.js') }}"></script>
<script src="{{ static_url('js/modal.js') }}"></script>
<script src="{{ static_url('js/admin-nav.js') }}"></script>
{% block admin_scripts %}{% endblock %}
</body>
</html>
+8 -6
View File
@@ -7,10 +7,12 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="stylesheet" href="{{ static_url('css/app.css') }}" />
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml" />
<link rel="icon" href="{{ static_url('favicon.ico') }}" sizes="any" />
<link rel="apple-touch-icon" href="{{ static_url('apple-touch-icon.png') }}" />
<link rel="manifest" href="{{ static_url('manifest.webmanifest') }}" />
<meta name="theme-color" content="#0b1020" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body class="admin-body">
@@ -47,7 +49,7 @@
<button class="btn primary" type="submit" data-i18n="admin.login.submit">Sign in</button>
</form>
</main>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
<script src="{{ static_url('js/i18n.js') }}"></script>
<script src="{{ static_url('js/theme.js') }}"></script>
</body>
</html>
+1 -1
View File
@@ -150,5 +150,5 @@
</form>
</div>
<script src="/static/js/admin-tabs.js"></script>
<script src="{{ static_url('js/admin-tabs.js') }}"></script>
{% endblock %}
+76 -7
View File
@@ -28,18 +28,29 @@
<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>
<h2 data-i18n="admin.stats.unwrappedTitle">Successful unwraps</h2>
</div>
<p class="stat-value">{{ stats.wraps_consumed }}</p>
<p class="stat-value">{{ stats.unwraps_success }}</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.dbConsumed">DB consumed</span>
<span class="stat-num">{{ stats.wraps_consumed }}</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-shield-halved" aria-hidden="true"></i>
<h2 data-i18n="admin.stats.passwordBurnsTitle">Burned by password</h2>
</div>
<p class="stat-value">{{ stats.password_burns }}</p>
<p class="stat-sub" data-i18n="admin.stats.passwordBurnsHint">
Destroyed after too many wrong passwords
</p>
</article>
<article class="stat-card">
<div class="stat-card-head">
<i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
@@ -93,13 +104,13 @@
<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>
<td class="mono">{{ stats.items_consumed }}</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>
<td class="mono">{{ stats.size_expired_human }}</td>
<td class="mono">{{ stats.items_expired }}</td>
</tr>
<tr class="stats-total-row">
<td data-i18n="admin.stats.total">Total</td>
@@ -142,6 +153,64 @@
</ul>
</section>
</div>
<div class="stats-sections">
<section class="stats-section">
<h3 data-i18n="admin.stats.failReasonsSection">Unwrap fail reasons (all time)</h3>
<div class="stats-table-wrap">
<table class="stats-table">
<thead>
<tr>
<th data-i18n="admin.stats.col.reason">Reason</th>
<th data-i18n="admin.stats.col.count">Count</th>
</tr>
</thead>
<tbody>
{% if stats.unwrap_fail_reasons %}
{% for row in stats.unwrap_fail_reasons %}
<tr>
<td class="mono">{{ row.reason }}</td>
<td class="mono">{{ row.count }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="2" class="hint" data-i18n="admin.stats.noFailReasons">No failed unwraps yet.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</section>
<section class="stats-section">
<h3 data-i18n="admin.stats.failReasons24hSection">Unwrap fail reasons (24h)</h3>
<div class="stats-table-wrap">
<table class="stats-table">
<thead>
<tr>
<th data-i18n="admin.stats.col.reason">Reason</th>
<th data-i18n="admin.stats.col.count">Count</th>
</tr>
</thead>
<tbody>
{% if stats.unwrap_fail_reasons_24h %}
{% for row in stats.unwrap_fail_reasons_24h %}
<tr>
<td class="mono">{{ row.reason }}</td>
<td class="mono">{{ row.count }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="2" class="hint" data-i18n="admin.stats.noFailReasons">No failed unwraps yet.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</section>
</div>
</section>
{% endblock %}
{% block admin_scripts %}
+11 -9
View File
@@ -7,10 +7,12 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="stylesheet" href="{{ static_url('css/app.css') }}" />
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml" />
<link rel="icon" href="{{ static_url('favicon.ico') }}" sizes="any" />
<link rel="apple-touch-icon" href="{{ static_url('apple-touch-icon.png') }}" />
<link rel="manifest" href="{{ static_url('manifest.webmanifest') }}" />
<meta name="theme-color" content="#0b1020" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link id="hljs-theme-dark" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css" />
<link id="hljs-theme-light" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css" disabled />
@@ -71,11 +73,11 @@
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/ui.js"></script>
<script src="/static/js/about.js"></script>
<script src="/static/js/highlight-ui.js"></script>
<script src="{{ static_url('js/i18n.js') }}"></script>
<script src="{{ static_url('js/theme.js') }}"></script>
<script src="{{ static_url('js/ui.js') }}"></script>
<script src="{{ static_url('js/about.js') }}"></script>
<script src="{{ static_url('js/highlight-ui.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+56 -12
View File
@@ -24,6 +24,7 @@
<input id="file-input" type="file" multiple hidden />
</div>
<ul id="file-list" class="file-list"></ul>
<p id="size-meter" class="size-meter hint" aria-live="polite"></p>
<div class="grid-2">
<div class="field">
@@ -64,17 +65,59 @@
<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 />
<button type="button" class="btn" id="copy-link" data-i18n="common.copy">Copy</button>
<ul class="success-checklist" id="success-checklist">
<li data-i18n="create.checkLink">Copy the share link</li>
<li id="check-password-item" class="hidden" data-i18n="create.checkPassword">Send the password in a separate message</li>
<li data-i18n="create.checkExpires">Note the expiry time</li>
</ul>
<p class="hint success-trust" data-i18n="create.trustKey">
The encryption key lives only in the link #fragment — the server never sees it.
</p>
<div class="success-layout">
<div class="success-main">
<div id="success-password-block" class="success-password-block hidden">
<div class="success-password-badge" role="status">
<i class="fa-solid fa-lock" aria-hidden="true"></i>
<span data-i18n="create.passwordProtected">Password protected</span>
</div>
<label for="share-password" data-i18n="create.sharePassword">Password</label>
<div class="copy-row">
<input id="share-password" class="mono" readonly autocomplete="off" />
<button type="button" class="btn" id="copy-password" data-i18n="common.copy">Copy</button>
</div>
<p class="hint success-password-hint" data-i18n="create.passwordSeparateHint">
Do not send the password in the same chat as the link.
</p>
</div>
<label for="share-link" data-i18n="create.shareLink">Share link</label>
<div class="copy-row">
<input id="share-link" readonly />
<button type="button" class="btn" id="copy-link" data-i18n="common.copy">Copy</button>
</div>
<label for="share-token" data-i18n="create.token">Wrapped token</label>
<div class="copy-row">
<input id="share-token" class="mono" readonly />
<button type="button" class="btn" id="copy-token" data-i18n="common.copy">Copy</button>
</div>
<div class="success-actions">
<button type="button" class="btn hidden" id="share-native" data-i18n="create.share">Share</button>
<button type="button" class="btn" id="download-qr" data-i18n="create.downloadQr">Download QR</button>
</div>
<p class="expires-meta" id="expires-meta"></p>
</div>
<div class="success-qr" aria-label="QR code">
<div id="share-qr" class="share-qr"></div>
<p class="hint success-qr-hint" data-i18n="create.qrHint">Scan to open the share link</p>
</div>
</div>
<label data-i18n="create.token">Wrapped token</label>
<div class="copy-row">
<input id="share-token" class="mono" readonly />
<button type="button" class="btn" id="copy-token" data-i18n="common.copy">Copy</button>
</div>
<p class="expires-meta" id="expires-meta"></p>
<button type="button" class="btn ghost" id="create-another" data-i18n="create.another">Create another</button>
</div>
</section>
@@ -92,6 +135,7 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/crypto.js"></script>
<script src="/static/js/create.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="{{ static_url('js/crypto.js') }}"></script>
<script src="{{ static_url('js/create.js') }}"></script>
{% endblock %}
+27 -3
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block content %}
<section class="hero-panel unwrap-panel">
<div class="panel-head">
<div class="panel-head" id="unwrap-head">
<p class="eyebrow" data-i18n="unwrap.eyebrow">Unwrap</p>
<h1 data-i18n="unwrap.title">Reveal once</h1>
<p class="lede" data-i18n="unwrap.lede">Paste a wrapped token or open a share link. Ciphertext is fetched once and deleted from the server.</p>
@@ -32,6 +32,15 @@
</div>
</div>
<div id="unwrap-state" class="unwrap-state hidden" role="status">
<div class="unwrap-state-icon" aria-hidden="true">
<i class="fa-solid fa-link-slash" id="unwrap-state-fa"></i>
</div>
<h2 id="unwrap-state-title"></h2>
<p class="lede" id="unwrap-state-hint"></p>
<a class="btn primary" href="/" data-i18n="unwrap.createOwn">Create your own wrap</a>
</div>
<div id="result-panel" class="result-panel hidden">
<div class="success-callout" role="status">
<span class="success-callout-icon" aria-hidden="true">
@@ -42,11 +51,26 @@
<span data-i18n="unwrap.destroyedHint">Preview lives only in this browser session.</span>
</span>
</div>
<p class="hint success-trust" data-i18n="unwrap.trustKey">
The key was only in the link #fragment and was never sent to the server.
</p>
<div class="result-actions">
<button type="button" class="btn hidden" id="download-all" data-i18n="unwrap.downloadAll">Download all</button>
</div>
<div id="items" class="items"></div>
</div>
</section>
<div id="image-lightbox" class="image-lightbox hidden" role="dialog" aria-modal="true" aria-label="Image preview">
<button type="button" class="image-lightbox-close" id="lightbox-close" aria-label="Close">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
</button>
<img id="lightbox-img" alt="" />
<p class="image-lightbox-caption" id="lightbox-caption"></p>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/crypto.js"></script>
<script src="/static/js/unwrap.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="{{ static_url('js/crypto.js') }}"></script>
<script src="{{ static_url('js/unwrap.js') }}"></script>
{% endblock %}
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: wrapped
description: Zero-knowledge one-time encrypted drop (Wrapped)
type: application
version: 0.1.2
appVersion: "0.1.2"
version: 0.1.4
appVersion: "0.1.4"
+1 -1
View File
@@ -2,7 +2,7 @@ replicaCount: 1
image:
repository: inecs/wrapped
tag: "0.1.2"
tag: "0.1.4"
pullPolicy: IfNotPresent
service:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "wrapped"
version = "0.1.2"
version = "0.1.4"
description = "Zero-knowledge one-time encrypted drop service"
readme = "README.md"
requires-python = ">=3.12"