Compare commits
29 Commits
a5b29ee81e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 81afba2362 | |||
| e80b2e1628 | |||
| d410b3c224 | |||
| eef8c4ec57 | |||
| 54749e5e12 | |||
| 4389c0cea4 | |||
| f35144ed28 | |||
| f728d74f34 | |||
| 806f2bdb1b | |||
| 49df683281 | |||
| 7c381b46cf | |||
| 5e73671bf4 | |||
| 1bc86d3bdd | |||
| 482621a23f | |||
| fc11454aac | |||
| 1481cc9d60 | |||
| d54af8c2df | |||
| d72eb8f32e | |||
| b20f12e52e | |||
| 2f30bd5bd3 | |||
| 8c04dbe0ea | |||
| 890fb52377 | |||
| 5353ef3869 | |||
| 680b704b47 | |||
| e0eceac76c | |||
| 6b57e2e621 | |||
| a881570938 | |||
| 8c8dbd2348 | |||
| 8f2d798e1a |
Vendored
+11
-17
@@ -5,12 +5,13 @@
|
||||
// 2) при успехе — trigger Deploy с wait:false и agent none → Builder-под умирает
|
||||
// 3) Deploy стартует отдельно уже без Builder
|
||||
//
|
||||
// Версия образа = VERSION из коммита. Bump: make bump-patch / make push.
|
||||
// Версия образа = VERSION из коммита (без auto-bump). Ручной bump: make bump-patch.
|
||||
//
|
||||
// Credentials (Global):
|
||||
// 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 {
|
||||
|
||||
@@ -32,7 +32,7 @@ help:
|
||||
@echo " make bump-minor VERSION +0.1.0"
|
||||
@echo " make release Multi-arch build & push $(FULL_IMAGE)"
|
||||
@echo " platforms: $(RELEASE_PLATFORMS)"
|
||||
@echo " make push bump-patch + git add/commit (prompt) + push"
|
||||
@echo " make push git add/commit (prompt) + push (без auto-bump)"
|
||||
@echo " make helm-lint Lint Helm chart"
|
||||
@echo " make helm-package Package Helm chart"
|
||||
@echo " make clean Remove containers, volumes, local image"
|
||||
@@ -136,7 +136,7 @@ clean:
|
||||
-docker buildx rm $(BUILDX_BUILDER) 2>/dev/null || true
|
||||
rm -rf dist
|
||||
|
||||
# Same flow as proxmox_api_simulator / infra: bump SemVer, stage, multiline commit, push.
|
||||
# Ручной SemVer (не вызывается из make push).
|
||||
bump-patch:
|
||||
@PYTHONPATH=. python3 scripts/bump_version.py patch
|
||||
@echo "VERSION → $$(cat VERSION)"
|
||||
@@ -153,7 +153,7 @@ version-commit:
|
||||
git commit -m "Bump version to $$(cat VERSION)."; \
|
||||
fi
|
||||
|
||||
push: bump-patch
|
||||
push:
|
||||
@set -e; \
|
||||
git add .; \
|
||||
echo "=== staged ==="; \
|
||||
|
||||
@@ -338,16 +338,25 @@ services:
|
||||
|
||||
Ключ в `#fragment` не уходит на сервер в запросе страницы.
|
||||
|
||||
### UX (v0.1.3+)
|
||||
|
||||
- После создания: **QR** на share-link, иконки **скачать QR (PNG)** и **Web Share** (если есть `navigator.share`) под QR; отдельные кнопки Copy для ссылки / токена / пароля; пароль не советуется слать в той же переписке.
|
||||
- Create: счётчик размера `≈ used / max`; **число открытий** 1–3 (потолок в админке); опционально **«доступно с»** (дата + время); человеческие TTL (в т.ч. «до вечера»).
|
||||
- Unwrap: Enter в поле пароля; focus+select при ошибках пароля; zip all; trust-строка; экран **ещё недоступно** до `available_from`; при N>1 ciphertext остаётся до последнего открытия.
|
||||
- Картинки: lightbox. Тема: `prefers-color-scheme` при первом визите; haptic на Copy. PWA manifest.
|
||||
- Страница [`/verify`](/verify) — как проверить ZK-модель.
|
||||
- UI EN/RU. Статика с `?v=версия.mtime`.
|
||||
|
||||
---
|
||||
|
||||
## Админка
|
||||
|
||||
Логин: `/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 +441,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.
|
||||
|
||||
### Версионирование
|
||||
|
||||
@@ -441,8 +450,8 @@ docker buildx build \
|
||||
| Где | Поведение |
|
||||
|-----|-----------|
|
||||
| UI (модалка «?») | бейдж `vX.Y.Z` |
|
||||
| `make bump-patch` / `bump-minor` | ручной bump |
|
||||
| `make push` | автоматически `bump-patch`, затем commit/push |
|
||||
| `make bump-patch` / `bump-minor` | ручной bump (по желанию) |
|
||||
| `make push` | commit/push **без** auto-bump |
|
||||
| Jenkins (`Jenkinsfile`) | читает `VERSION` из коммита **без** доп. bump → те же теги в образе и на кластере |
|
||||
|
||||
Что в `VERSION` запушили — то и уйдёт в Harbor/Hub/деплой (и в футер модалки внутри образа).
|
||||
@@ -452,7 +461,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 |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""max_opens / available_from for wraps; max_opens_limit in settings
|
||||
|
||||
Revision ID: 003_opens_available_from
|
||||
Revises: 002_password_attempts
|
||||
Create Date: 2026-07-29
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "003_opens_available_from"
|
||||
down_revision: Union[str, None] = "002_password_attempts"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"app_settings",
|
||||
sa.Column(
|
||||
"max_opens_limit",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="3",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"wraps",
|
||||
sa.Column(
|
||||
"max_opens",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"wraps",
|
||||
sa.Column(
|
||||
"opens_used",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"wraps",
|
||||
sa.Column("available_from", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("wraps", "available_from")
|
||||
op.drop_column("wraps", "opens_used")
|
||||
op.drop_column("wraps", "max_opens")
|
||||
op.drop_column("app_settings", "max_opens_limit")
|
||||
@@ -210,6 +210,9 @@ async def settings_save(
|
||||
row.password_max_attempts = min(
|
||||
50, max(1, as_int("password_max_attempts", row.password_max_attempts or 3))
|
||||
)
|
||||
row.max_opens_limit = min(
|
||||
10, max(1, as_int("max_opens_limit", getattr(row, "max_opens_limit", None) or 3))
|
||||
)
|
||||
row.turnstile_site_key = str(form.get("turnstile_site_key") or "").strip()
|
||||
row.hcaptcha_site_key = str(form.get("hcaptcha_site_key") or "").strip()
|
||||
|
||||
@@ -408,6 +411,7 @@ async def admin_settings_api(
|
||||
"captcha_provider": row.captcha_provider.value,
|
||||
"password_mode": row.password_mode.value,
|
||||
"password_max_attempts": row.password_max_attempts,
|
||||
"max_opens_limit": getattr(row, "max_opens_limit", None) or 3,
|
||||
"audit_retention_days": row.audit_retention_days,
|
||||
"rate_limit_create_per_minute": row.rate_limit_create_per_minute,
|
||||
"rate_limit_unwrap_per_minute": row.rate_limit_unwrap_per_minute,
|
||||
|
||||
+76
-8
@@ -84,6 +84,11 @@ async def create_wrap(
|
||||
if body.ttl_seconds > settings.max_ttl_seconds:
|
||||
raise HTTPException(status_code=400, detail="ttl_too_large")
|
||||
|
||||
opens_limit = max(1, min(10, int(getattr(settings, "max_opens_limit", None) or 3)))
|
||||
max_opens = int(body.max_opens or 1)
|
||||
if max_opens < 1 or max_opens > opens_limit:
|
||||
raise HTTPException(status_code=400, detail="max_opens_invalid")
|
||||
|
||||
try:
|
||||
ciphertext = base64.b64decode(body.ciphertext_b64, validate=True)
|
||||
except Exception as exc:
|
||||
@@ -124,6 +129,18 @@ async def create_wrap(
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(seconds=body.ttl_seconds)
|
||||
|
||||
available_from = body.available_from
|
||||
if available_from is not None:
|
||||
if available_from.tzinfo is None:
|
||||
available_from = available_from.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
available_from = available_from.astimezone(timezone.utc)
|
||||
# Allow ~2 minutes of clock skew into the past
|
||||
if available_from < now - timedelta(minutes=2):
|
||||
raise HTTPException(status_code=400, detail="available_from_past")
|
||||
if available_from >= expires_at:
|
||||
raise HTTPException(status_code=400, detail="available_from_after_expiry")
|
||||
|
||||
await storage.put_bytes(object_key, ciphertext)
|
||||
|
||||
wrap = Wrap(
|
||||
@@ -137,6 +154,9 @@ async def create_wrap(
|
||||
password_hash=password_hash,
|
||||
password_mode=settings.password_mode,
|
||||
expires_at=expires_at,
|
||||
available_from=available_from,
|
||||
max_opens=max_opens,
|
||||
opens_used=0,
|
||||
creator_ip=ip,
|
||||
creator_ua=(meta["user_agent"] or "")[:512] or None,
|
||||
)
|
||||
@@ -156,6 +176,8 @@ async def create_wrap(
|
||||
"ttl_seconds": body.ttl_seconds,
|
||||
"has_password": body.has_password,
|
||||
"password_mode": settings.password_mode.value,
|
||||
"max_opens": max_opens,
|
||||
"available_from": available_from.isoformat() if available_from else None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -164,6 +186,8 @@ async def create_wrap(
|
||||
expires_at=expires_at,
|
||||
password_mode=settings.password_mode.value,
|
||||
share_path=f"/w/{wrap_id}",
|
||||
max_opens=max_opens,
|
||||
available_from=available_from,
|
||||
)
|
||||
|
||||
|
||||
@@ -237,7 +261,8 @@ async def unwrap(
|
||||
fail("unavailable")
|
||||
|
||||
assert wrap is not None
|
||||
if wrap.expires_at <= datetime.now(timezone.utc):
|
||||
now = datetime.now(timezone.utc)
|
||||
if wrap.expires_at <= now:
|
||||
wrap.status = WrapStatus.expired
|
||||
await delete_wrap_object(db, wrap)
|
||||
await db.commit()
|
||||
@@ -251,6 +276,26 @@ async def unwrap(
|
||||
)
|
||||
fail("unavailable")
|
||||
|
||||
if wrap.available_from is not None and wrap.available_from > now:
|
||||
await write_audit(
|
||||
db,
|
||||
event_type="wrap.unwrap",
|
||||
success=False,
|
||||
wrap_id=wrap_id,
|
||||
**meta,
|
||||
details={
|
||||
"reason": "not_yet_available",
|
||||
"available_from": wrap.available_from.isoformat(),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"code": "not_yet_available",
|
||||
"available_from": wrap.available_from.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
if wrap.has_password and wrap.password_hash:
|
||||
max_attempts = max(1, int(settings.password_max_attempts or 3))
|
||||
# Empty password: ask to enter it, do not burn an attempt.
|
||||
@@ -331,23 +376,25 @@ async def unwrap(
|
||||
},
|
||||
)
|
||||
|
||||
# Atomic consume
|
||||
# Atomic open: increment opens_used while still under max_opens
|
||||
from sqlalchemy import update
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
max_opens = max(1, int(wrap.max_opens or 1))
|
||||
upd = await db.execute(
|
||||
update(Wrap)
|
||||
.where(
|
||||
Wrap.id == wrap_id,
|
||||
Wrap.status == WrapStatus.pending,
|
||||
Wrap.expires_at > now,
|
||||
Wrap.opens_used < Wrap.max_opens,
|
||||
)
|
||||
.values(status=WrapStatus.consumed, consumed_at=now)
|
||||
.returning(Wrap.id)
|
||||
.values(opens_used=Wrap.opens_used + 1)
|
||||
.returning(Wrap.id, Wrap.opens_used, Wrap.max_opens)
|
||||
)
|
||||
consumed = upd.scalar_one_or_none()
|
||||
row = upd.one_or_none()
|
||||
await db.commit()
|
||||
if not consumed:
|
||||
if not row:
|
||||
await write_audit(
|
||||
db,
|
||||
event_type="wrap.unwrap",
|
||||
@@ -358,6 +405,11 @@ async def unwrap(
|
||||
)
|
||||
fail("unavailable")
|
||||
|
||||
opens_used = int(row.opens_used)
|
||||
max_opens = max(1, int(row.max_opens or 1))
|
||||
opens_remaining = max(0, max_opens - opens_used)
|
||||
destroyed = opens_remaining == 0
|
||||
|
||||
try:
|
||||
data = await storage.get_bytes(wrap.object_key)
|
||||
except Exception:
|
||||
@@ -370,8 +422,17 @@ async def unwrap(
|
||||
details={"reason": "storage_error"},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="storage_error") from None
|
||||
finally:
|
||||
await delete_wrap_object(db, wrap)
|
||||
|
||||
if destroyed:
|
||||
result = await db.execute(select(Wrap).where(Wrap.id == wrap_id))
|
||||
wrap_row = result.scalar_one_or_none()
|
||||
if wrap_row and wrap_row.status == WrapStatus.pending:
|
||||
wrap_row.status = WrapStatus.consumed
|
||||
wrap_row.consumed_at = now
|
||||
await delete_wrap_object(db, wrap_row)
|
||||
await db.commit()
|
||||
else:
|
||||
await delete_wrap_object(db, wrap)
|
||||
|
||||
await write_audit(
|
||||
db,
|
||||
@@ -383,6 +444,9 @@ async def unwrap(
|
||||
"size_bytes": wrap.size_bytes,
|
||||
"item_count": wrap.item_count,
|
||||
"content_types": wrap.content_types,
|
||||
"opens_used": opens_used,
|
||||
"max_opens": max_opens,
|
||||
"destroyed": destroyed,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -393,4 +457,8 @@ async def unwrap(
|
||||
has_password=wrap.has_password,
|
||||
password_mode=wrap.password_mode.value,
|
||||
size_bytes=wrap.size_bytes,
|
||||
max_opens=max_opens,
|
||||
opens_used=opens_used,
|
||||
opens_remaining=opens_remaining,
|
||||
destroyed=destroyed,
|
||||
)
|
||||
|
||||
+11
@@ -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():
|
||||
@@ -137,6 +140,14 @@ def create_app() -> FastAPI:
|
||||
{"title": "Unwrap", "page": "unwrap", "wrap_id": ""},
|
||||
)
|
||||
|
||||
@app.get("/verify", include_in_schema=False)
|
||||
async def verify_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"verify.html",
|
||||
{"title": "Verify", "page": "verify"},
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
if (
|
||||
|
||||
@@ -65,6 +65,8 @@ class AppSettings(Base):
|
||||
)
|
||||
# Wrong unwrap passwords allowed before the wrap is burned (default 3).
|
||||
password_max_attempts: Mapped[int] = mapped_column(Integer, default=3)
|
||||
# Max allowed max_opens on create (UI shows 1…min(3, limit)).
|
||||
max_opens_limit: Mapped[int] = mapped_column(Integer, default=3)
|
||||
audit_retention_days: Mapped[int] = mapped_column(Integer, default=90)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
@@ -96,6 +98,9 @@ class Wrap(Base):
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
available_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
max_opens: Mapped[int] = mapped_column(Integer, default=1)
|
||||
opens_used: Mapped[int] = mapped_column(Integer, default=0)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
creator_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
creator_ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
@@ -138,6 +143,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",
|
||||
|
||||
@@ -17,6 +17,7 @@ class PublicSettingsOut(BaseModel):
|
||||
password_mode: str
|
||||
password_mode_description: dict[str, str]
|
||||
password_max_attempts: int
|
||||
max_opens_limit: int
|
||||
|
||||
|
||||
class WrapCreateRequest(BaseModel):
|
||||
@@ -27,6 +28,8 @@ class WrapCreateRequest(BaseModel):
|
||||
has_password: bool = False
|
||||
password: str | None = None
|
||||
captcha_token: str | None = None
|
||||
max_opens: int = Field(default=1, ge=1, le=10)
|
||||
available_from: datetime | None = None
|
||||
|
||||
|
||||
class WrapCreateResponse(BaseModel):
|
||||
@@ -34,6 +37,8 @@ class WrapCreateResponse(BaseModel):
|
||||
expires_at: datetime
|
||||
password_mode: str
|
||||
share_path: str
|
||||
max_opens: int = 1
|
||||
available_from: datetime | None = None
|
||||
|
||||
|
||||
class UnwrapRequest(BaseModel):
|
||||
@@ -48,6 +53,10 @@ class UnwrapResponse(BaseModel):
|
||||
has_password: bool
|
||||
password_mode: str
|
||||
size_bytes: int
|
||||
max_opens: int = 1
|
||||
opens_used: int = 1
|
||||
opens_remaining: int = 0
|
||||
destroyed: bool = True
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
@@ -68,6 +77,7 @@ class AdminSettingsUpdate(BaseModel):
|
||||
hcaptcha_site_key: str | None = None
|
||||
password_mode: str | None = None
|
||||
password_max_attempts: int | None = Field(default=None, ge=1, le=50)
|
||||
max_opens_limit: int | None = Field(default=None, ge=1, le=10)
|
||||
audit_retention_days: int | None = None
|
||||
|
||||
|
||||
|
||||
@@ -70,4 +70,5 @@ def public_settings_payload(row: AppSettings) -> dict:
|
||||
"password_mode": row.password_mode.value,
|
||||
"password_mode_description": PASSWORD_MODE_HELP,
|
||||
"password_max_attempts": max(1, int(row.password_max_attempts or 3)),
|
||||
"max_opens_limit": max(1, min(10, int(getattr(row, "max_opens_limit", None) or 3))),
|
||||
}
|
||||
|
||||
+54
-1
@@ -117,6 +117,51 @@ 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)
|
||||
# One expression for SELECT + GROUP BY (PG rejects duplicate binds as unequal)
|
||||
reason_key = func.coalesce(reason_col, "unknown")
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
reason_key,
|
||||
func.count(AuditEvent.id),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by(reason_key)
|
||||
.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 +173,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 +183,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 +192,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)),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.1 KiB |
+556
-12
@@ -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%;
|
||||
@@ -177,7 +178,8 @@ html[data-theme="light"] body {
|
||||
50% { filter: saturate(1.2); transform: scale(1.04); }
|
||||
}
|
||||
|
||||
.top-actions { display: flex; gap: 0.5rem; }
|
||||
.top-actions { display: flex; gap: 0.5rem; overflow: visible; position: relative; z-index: 5; }
|
||||
.topbar { overflow: visible; }
|
||||
|
||||
.icon-btn {
|
||||
min-width: 42px;
|
||||
@@ -251,7 +253,56 @@ html[data-theme="dark"] .icon-btn:hover {
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.lede { color: var(--muted); margin: 0 0 1.5rem; max-width: 42rem; }
|
||||
.verify-sections {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
.verify-block h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.verify-block p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.datetime-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.datetime-split .input-with-action > input[type="date"],
|
||||
.datetime-split .input-with-action > input[type="time"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-right: 2.75rem;
|
||||
color-scheme: dark;
|
||||
}
|
||||
html[data-theme="light"] .datetime-split .input-with-action > input[type="date"],
|
||||
html[data-theme="light"] .datetime-split .input-with-action > input[type="time"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
.datetime-split .input-with-action > input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
.datetime-split .input-with-action > input[type="time"]::-webkit-calendar-picker-indicator {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 2.5rem;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.datetime-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.composer, .success-panel, .result-panel {
|
||||
display: grid;
|
||||
@@ -262,6 +313,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 +332,165 @@ 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;
|
||||
}
|
||||
.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;
|
||||
overflow: visible;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.success-qr-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.qr-action-btn {
|
||||
position: relative;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.qr-action-btn:hover,
|
||||
.qr-action-btn:focus-visible {
|
||||
color: var(--accent-2);
|
||||
background: rgba(75, 134, 240, 0.1);
|
||||
border-color: rgba(75, 134, 240, 0.35);
|
||||
outline: none;
|
||||
}
|
||||
.qr-action-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.qr-action-btn .ui-tooltip {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qr-action-btn .ui-tooltip::after {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.qr-action-btn:hover .ui-tooltip,
|
||||
.qr-action-btn:focus-visible .ui-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.success-panel > .expires-meta {
|
||||
width: 100%;
|
||||
justify-self: stretch;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
@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 +659,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; }
|
||||
@@ -620,6 +854,7 @@ body.busy-open { overflow: hidden; }
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
@@ -631,6 +866,22 @@ body.busy-open { overflow: hidden; }
|
||||
color: var(--danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
.success-meta-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.success-meta-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(110, 168, 255, 0.28);
|
||||
background: rgba(110, 168, 255, 0.1);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem; }
|
||||
@@ -862,6 +1113,63 @@ html[data-theme="light"] .about-version {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.about-verify-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.28);
|
||||
background: linear-gradient(135deg, rgba(61, 214, 198, 0.1), rgba(110, 168, 255, 0.08));
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.15s;
|
||||
}
|
||||
.about-verify-link:hover,
|
||||
.about-verify-link:focus-visible {
|
||||
border-color: rgba(110, 168, 255, 0.45);
|
||||
background: linear-gradient(135deg, rgba(61, 214, 198, 0.14), rgba(110, 168, 255, 0.14));
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.about-verify-icon {
|
||||
flex-shrink: 0;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: rgba(61, 214, 198, 0.14);
|
||||
color: var(--accent);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.about-verify-text {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.about-verify-text strong {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
}
|
||||
.about-verify-text span {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.about-verify-arrow {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-2);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
html[data-theme="light"] .about-verify-link {
|
||||
border-color: rgba(47, 111, 237, 0.22);
|
||||
background: linear-gradient(135deg, rgba(14, 160, 140, 0.08), rgba(47, 111, 237, 0.08));
|
||||
}
|
||||
.about-modal-panel .modal-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -947,6 +1255,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 +1412,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 +1481,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 +1515,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 +1562,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: 1015px) {
|
||||
.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: 1016px) {
|
||||
.admin-top > .brand {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.admin-nav {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
.danger-page {
|
||||
max-width: 520px;
|
||||
}
|
||||
@@ -1262,12 +1789,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 +2167,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 +2188,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 +2199,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; }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -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: 1015px)");
|
||||
|
||||
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();
|
||||
})();
|
||||
+253
-11
@@ -1,5 +1,6 @@
|
||||
(() => {
|
||||
const t = (k, vars) => window.WrappedI18n.t(k, vars);
|
||||
/** @type {File[]} */
|
||||
const files = [];
|
||||
let settings = null;
|
||||
let captchaWidgetId = null;
|
||||
@@ -14,6 +15,11 @@
|
||||
highlight: document.getElementById("code-highlight"),
|
||||
editor: document.getElementById("code-editor"),
|
||||
ttl: document.getElementById("ttl-seconds"),
|
||||
maxOpens: document.getElementById("max-opens"),
|
||||
availableFromDate: document.getElementById("available-from-date"),
|
||||
availableFromTime: document.getElementById("available-from-time"),
|
||||
availableFromDateBtn: document.getElementById("available-from-date-btn"),
|
||||
availableFromTimeBtn: document.getElementById("available-from-time-btn"),
|
||||
password: document.getElementById("password"),
|
||||
generatePassword: document.getElementById("generate-password"),
|
||||
dropzone: document.getElementById("dropzone"),
|
||||
@@ -25,8 +31,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"),
|
||||
successMetaBadges: document.getElementById("success-meta-badges"),
|
||||
shareQr: document.getElementById("share-qr"),
|
||||
expiresMeta: document.getElementById("expires-meta"),
|
||||
captchaSlot: document.getElementById("captcha-slot"),
|
||||
sizeMeter: document.getElementById("size-meter"),
|
||||
shareNative: document.getElementById("share-native"),
|
||||
downloadQr: document.getElementById("download-qr"),
|
||||
};
|
||||
|
||||
function showError(msg) {
|
||||
@@ -100,11 +113,39 @@
|
||||
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) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<span>${f.name} <small>(${f.type || "file"} · ${window.WrappedUI.formatBytes(f.size)})</small></span>`;
|
||||
li.innerHTML = `<span>${escapeHtml(f.name)} <small>(${escapeHtml(f.type || "file")} · ${window.WrappedUI.formatBytes(f.size)})</small></span>`;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = "×";
|
||||
@@ -115,6 +156,15 @@
|
||||
li.appendChild(btn);
|
||||
els.fileList.appendChild(li);
|
||||
});
|
||||
refreshSizeMeter();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function addFiles(list) {
|
||||
@@ -129,15 +179,26 @@
|
||||
}
|
||||
|
||||
let lastExpiresAt = null;
|
||||
let lastAvailableFrom = null;
|
||||
let lastMaxOpens = 1;
|
||||
|
||||
function secondsUntilEvening() {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(20, 0, 0, 0);
|
||||
if (target <= now) target.setDate(target.getDate() + 1);
|
||||
return Math.max(60, Math.round((target.getTime() - now.getTime()) / 1000));
|
||||
}
|
||||
|
||||
function fillTtl() {
|
||||
if (!settings || !els.ttl) return;
|
||||
const max = settings.max_ttl_seconds;
|
||||
const def = settings.default_ttl_seconds;
|
||||
const selected = els.ttl.value ? Number(els.ttl.value) : def;
|
||||
const evening = secondsUntilEvening();
|
||||
const options = [
|
||||
{ key: "create.ttl.1h", value: 3600 },
|
||||
{ key: "create.ttl.6h", value: 6 * 3600 },
|
||||
{ key: "create.ttl.evening", value: evening },
|
||||
{ key: "create.ttl.24h", value: 24 * 3600 },
|
||||
{ key: "create.ttl.3d", value: 3 * 24 * 3600 },
|
||||
{ key: "create.ttl.7d", value: 7 * 24 * 3600 },
|
||||
@@ -157,6 +218,58 @@
|
||||
.join("");
|
||||
}
|
||||
|
||||
function fillMaxOpens() {
|
||||
if (!els.maxOpens || !settings) return;
|
||||
const limit = Math.max(1, Math.min(10, Number(settings.max_opens_limit) || 3));
|
||||
const uiMax = Math.min(3, limit);
|
||||
const selected = els.maxOpens.value ? Number(els.maxOpens.value) : 1;
|
||||
const pick = selected >= 1 && selected <= uiMax ? selected : 1;
|
||||
els.maxOpens.innerHTML = Array.from({ length: uiMax }, (_, i) => i + 1)
|
||||
.map(
|
||||
(n) =>
|
||||
`<option value="${n}" ${n === pick ? "selected" : ""}>${t("create.maxOpensOption", { n })}</option>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function availableFromToIso() {
|
||||
const date = (els.availableFromDate?.value || "").trim();
|
||||
if (!date) return null;
|
||||
const time = (els.availableFromTime?.value || "").trim() || "00:00";
|
||||
const d = new Date(`${date}T${time}`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function formatLocalDateTime(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return String(iso);
|
||||
return new Intl.DateTimeFormat(window.WrappedI18n.locale?.() || undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function refreshSuccessBadges() {
|
||||
if (!els.successMetaBadges) return;
|
||||
els.successMetaBadges.innerHTML = "";
|
||||
if (lastMaxOpens > 1) {
|
||||
const b = document.createElement("span");
|
||||
b.className = "success-meta-badge";
|
||||
b.textContent = t("create.opensBadge", { n: lastMaxOpens });
|
||||
els.successMetaBadges.appendChild(b);
|
||||
}
|
||||
if (lastAvailableFrom) {
|
||||
const b = document.createElement("span");
|
||||
b.className = "success-meta-badge";
|
||||
b.textContent = t("create.availableFromBadge", {
|
||||
datetime: formatLocalDateTime(lastAvailableFrom),
|
||||
});
|
||||
els.successMetaBadges.appendChild(b);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshExpiresMeta() {
|
||||
if (lastExpiresAt && els.expiresMeta) {
|
||||
els.expiresMeta.innerHTML = window.WrappedI18n.formatExpiresHtml
|
||||
@@ -204,9 +317,12 @@
|
||||
const resp = await fetch("/api/v1/settings");
|
||||
settings = await resp.json();
|
||||
fillTtl();
|
||||
fillMaxOpens();
|
||||
loadCaptcha();
|
||||
setLanguage("plaintext", { silent: true });
|
||||
refreshHighlight();
|
||||
refreshSizeMeter();
|
||||
syncShareControls();
|
||||
}
|
||||
|
||||
els.dropzone.addEventListener("click", () => els.fileInput.click());
|
||||
@@ -243,6 +359,7 @@
|
||||
|
||||
els.text.addEventListener("input", () => {
|
||||
refreshHighlight();
|
||||
refreshSizeMeter();
|
||||
});
|
||||
els.text.addEventListener("scroll", () => {
|
||||
const pre = els.highlight.parentElement;
|
||||
@@ -260,19 +377,105 @@
|
||||
}
|
||||
});
|
||||
|
||||
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, availableFrom, maxOpens }) {
|
||||
els.shareLink.value = link;
|
||||
els.shareToken.value = token;
|
||||
lastExpiresAt = expiresAt;
|
||||
lastAvailableFrom = availableFrom || null;
|
||||
lastMaxOpens = maxOpens || 1;
|
||||
refreshExpiresMeta();
|
||||
refreshSuccessBadges();
|
||||
if (password) {
|
||||
els.sharePassword.value = password;
|
||||
els.passwordBlock.classList.remove("hidden");
|
||||
} else {
|
||||
els.sharePassword.value = "";
|
||||
els.passwordBlock.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();
|
||||
});
|
||||
@@ -309,6 +512,8 @@
|
||||
}
|
||||
|
||||
const password = els.password.value || "";
|
||||
const maxOpens = Number(els.maxOpens?.value || 1);
|
||||
const availableFromIso = availableFromToIso();
|
||||
els.wrapBtn.disabled = true;
|
||||
window.WrappedUI.showBusy(t("create.working"));
|
||||
try {
|
||||
@@ -329,9 +534,10 @@
|
||||
content_types: contentTypes,
|
||||
item_count: items.length,
|
||||
has_password: Boolean(password),
|
||||
// Always send password when set so server can gate wrong guesses.
|
||||
password: password || null,
|
||||
captcha_token: captchaToken() || null,
|
||||
max_opens: maxOpens,
|
||||
available_from: availableFromIso,
|
||||
};
|
||||
const resp = await fetch("/api/v1/wraps", {
|
||||
method: "POST",
|
||||
@@ -340,18 +546,33 @@
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showError(err.detail || t("common.error"));
|
||||
const detail = err.detail;
|
||||
if (detail === "available_from_past") {
|
||||
showError(t("create.availableFromPast"));
|
||||
return;
|
||||
}
|
||||
if (detail === "available_from_after_expiry") {
|
||||
showError(t("create.availableFromAfterExpiry"));
|
||||
return;
|
||||
}
|
||||
if (detail === "max_opens_invalid") {
|
||||
showError(t("create.maxOpensInvalid"));
|
||||
return;
|
||||
}
|
||||
showError(typeof detail === "string" ? detail : t("common.error"));
|
||||
return;
|
||||
}
|
||||
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,
|
||||
availableFrom: data.available_from,
|
||||
maxOpens: data.max_opens || maxOpens,
|
||||
});
|
||||
} catch {
|
||||
showError(t("common.error"));
|
||||
} finally {
|
||||
@@ -376,10 +597,31 @@
|
||||
els.password.select();
|
||||
});
|
||||
|
||||
function openPicker(input) {
|
||||
if (!input) return;
|
||||
try {
|
||||
if (typeof input.showPicker === "function") {
|
||||
input.showPicker();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
input.focus();
|
||||
input.click();
|
||||
}
|
||||
|
||||
els.availableFromDateBtn?.addEventListener("click", () => openPicker(els.availableFromDate));
|
||||
els.availableFromTimeBtn?.addEventListener("click", () => openPicker(els.availableFromTime));
|
||||
|
||||
if (window.WrappedI18n.onChange) {
|
||||
window.WrappedI18n.onChange(() => {
|
||||
fillTtl();
|
||||
fillMaxOpens();
|
||||
refreshExpiresMeta();
|
||||
refreshSuccessBadges();
|
||||
refreshSizeMeter();
|
||||
syncShareControls();
|
||||
setLanguage(els.lang.value, { silent: true });
|
||||
refreshHighlight();
|
||||
if (els.langChipText) els.langChipText.textContent = langLabel(els.lang.value);
|
||||
|
||||
@@ -176,14 +176,17 @@
|
||||
return b64decode(b64);
|
||||
}
|
||||
|
||||
async function fileToItem(file) {
|
||||
async function fileToItem(file, label) {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
return {
|
||||
const item = {
|
||||
type: "file",
|
||||
name: file.name || "file",
|
||||
mime: file.type || "application/octet-stream",
|
||||
data_b64: b64encode(buf),
|
||||
};
|
||||
const trimmed = (label || "").trim();
|
||||
if (trimmed) item.label = trimmed.slice(0, 120);
|
||||
return item;
|
||||
}
|
||||
|
||||
window.WrappedCrypto = {
|
||||
|
||||
+178
-6
@@ -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",
|
||||
@@ -52,6 +66,50 @@
|
||||
"create.ttl.24h": "24 hours",
|
||||
"create.ttl.3d": "3 days",
|
||||
"create.ttl.7d": "7 days",
|
||||
"create.ttl.evening": "Until evening (20:00)",
|
||||
"create.maxOpens": "Opens",
|
||||
"create.maxOpensOption": "{n}",
|
||||
"create.maxOpensInvalid": "Invalid number of opens.",
|
||||
"create.availableFrom": "Available from (optional)",
|
||||
"create.availableFromHint": "Empty = available immediately",
|
||||
"create.availableFromDate": "Pick date",
|
||||
"create.availableFromTime": "Pick time",
|
||||
"create.availableFromPast": "Available-from must not be in the past.",
|
||||
"create.availableFromAfterExpiry": "Available-from must be before expiry.",
|
||||
"create.opensBadge": "{n} opens",
|
||||
"create.availableFromBadge": "Available from {datetime}",
|
||||
"create.itemLabel": "Label (optional)",
|
||||
"create.itemLabelPlaceholder": "e.g. Instructions",
|
||||
"create.tpl.access.name": "Access / password",
|
||||
"create.tpl.access.body": "Access details\n\nURL:\nUsername:\nPassword:\n\nNotes:\n",
|
||||
"create.tpl.code.name": "Code + notes",
|
||||
"create.tpl.code.body": "One-time code / snippet\n\n```\n\n```\n\nNotes:\n",
|
||||
"create.tpl.fileNote.name": "File + note",
|
||||
"create.tpl.fileNote.body": "Attached file(s) — see below.\n\nWhat this is for:\nHow to use:\n",
|
||||
"unwrap.notYetTitle": "Not available yet",
|
||||
"unwrap.notYetHint": "This wrap opens at {datetime}.",
|
||||
"unwrap.notYetHintGeneric": "This wrap is not available yet.",
|
||||
"unwrap.stillOnServerTitle": "Still on the server",
|
||||
"unwrap.stillOnServerHint": "{n} open(s) remaining — ciphertext stays until the last unwrap.",
|
||||
"unwrap.opensRemaining": "{n} open(s) remaining after this session.",
|
||||
"verify.eyebrow": "Verify",
|
||||
"verify.title": "How to verify Wrapped",
|
||||
"verify.lede": "What leaves your browser, what stays on the server, and how the key in #fragment works.",
|
||||
"verify.s1.title": "Encryption in the browser",
|
||||
"verify.s1.body": "Text and files are packed and encrypted with Web Crypto (AES-GCM) before upload. The server receives only ciphertext plus metadata (TTL, MIME, size, optional password hash).",
|
||||
"verify.s2.title": "Key in the URL fragment",
|
||||
"verify.s2.body": "The share link looks like /w/<id>#<key>. The part after # never reaches the server in the page request. Without that fragment (or the full wrapped token), ciphertext cannot be decrypted.",
|
||||
"verify.s3.title": "Opens and destruction",
|
||||
"verify.s3.body": "By default a wrap can be opened once; then ciphertext is deleted. If the sender chose 2–3 opens, the server keeps ciphertext until the last successful unwrap. Expiry and password lockout still destroy the package.",
|
||||
"verify.s4.title": "Optional password",
|
||||
"verify.s4.body": "When a password is set, the server checks an Argon2 hash before releasing ciphertext. Wrong guesses are limited; empty password does not burn an attempt.",
|
||||
"verify.s5.title": "Available from",
|
||||
"verify.s5.body": "If \"available from\" is set, unwrap is rejected until that time. After that, normal open/expiry rules apply.",
|
||||
"verify.createCta": "Create a wrap",
|
||||
"about.verifyLink": "How to verify",
|
||||
"about.verifyHint": "What the server sees and how the #key works",
|
||||
"admin.limits.maxOpens": "Max opens per wrap",
|
||||
"admin.limits.maxOpensHint": "Ceiling for create UI (1–10). Default: 3.",
|
||||
"create.ttl.default": "Default ({seconds} s)",
|
||||
"lang.plaintext": "Plain text",
|
||||
"lang.markdown": "Markdown",
|
||||
@@ -78,17 +136,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 +177,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 +186,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 +343,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": "Создать ещё",
|
||||
@@ -270,6 +370,50 @@
|
||||
"create.ttl.24h": "24 часа",
|
||||
"create.ttl.3d": "3 дня",
|
||||
"create.ttl.7d": "7 дней",
|
||||
"create.ttl.evening": "До вечера (20:00)",
|
||||
"create.maxOpens": "Открытий",
|
||||
"create.maxOpensOption": "{n}",
|
||||
"create.maxOpensInvalid": "Недопустимое число открытий.",
|
||||
"create.availableFrom": "Доступно с (необязательно)",
|
||||
"create.availableFromHint": "Пусто = доступно сразу",
|
||||
"create.availableFromDate": "Выбрать дату",
|
||||
"create.availableFromTime": "Выбрать время",
|
||||
"create.availableFromPast": "«Доступно с» не может быть в прошлом.",
|
||||
"create.availableFromAfterExpiry": "«Доступно с» должно быть раньше срока истечения.",
|
||||
"create.opensBadge": "{n} открытий",
|
||||
"create.availableFromBadge": "Доступно с {datetime}",
|
||||
"create.itemLabel": "Подпись (необязательно)",
|
||||
"create.itemLabelPlaceholder": "напр. Инструкция",
|
||||
"create.tpl.access.name": "Доступ / пароль",
|
||||
"create.tpl.access.body": "Данные доступа\n\nURL:\nЛогин:\nПароль:\n\nЗаметки:\n",
|
||||
"create.tpl.code.name": "Код + заметка",
|
||||
"create.tpl.code.body": "Одноразовый код / фрагмент\n\n```\n\n```\n\nЗаметки:\n",
|
||||
"create.tpl.fileNote.name": "Файл + заметка",
|
||||
"create.tpl.fileNote.body": "Вложения — см. ниже.\n\nДля чего:\nКак пользоваться:\n",
|
||||
"unwrap.notYetTitle": "Ещё недоступно",
|
||||
"unwrap.notYetHint": "Этот wrap откроется {datetime}.",
|
||||
"unwrap.notYetHintGeneric": "Этот wrap ещё недоступен.",
|
||||
"unwrap.stillOnServerTitle": "Ещё на сервере",
|
||||
"unwrap.stillOnServerHint": "Осталось открытий: {n} — ciphertext хранится до последнего unwrap.",
|
||||
"unwrap.opensRemaining": "После этой сессии останется открытий: {n}.",
|
||||
"verify.eyebrow": "Проверка",
|
||||
"verify.title": "Как проверить Wrapped",
|
||||
"verify.lede": "Что уходит из браузера, что лежит на сервере и как работает ключ в #fragment.",
|
||||
"verify.s1.title": "Шифрование в браузере",
|
||||
"verify.s1.body": "Текст и файлы упаковываются и шифруются Web Crypto (AES-GCM) до загрузки. На сервер уходит только ciphertext и метаданные (TTL, MIME, размер, опциональный хеш пароля).",
|
||||
"verify.s2.title": "Ключ во фрагменте URL",
|
||||
"verify.s2.body": "Ссылка вида /w/<id>#<key>. Часть после # не уходит на сервер в запросе страницы. Без фрагмента (или полного токена) ciphertext не расшифровать.",
|
||||
"verify.s3.title": "Открытия и уничтожение",
|
||||
"verify.s3.body": "По умолчанию wrap открывается один раз — затем ciphertext удаляется. Если отправитель выбрал 2–3 открытия, сервер хранит ciphertext до последнего успешного unwrap. Срок и блокировка пароля по-прежнему уничтожают пакет.",
|
||||
"verify.s4.title": "Опциональный пароль",
|
||||
"verify.s4.body": "При пароле сервер проверяет Argon2-хеш до выдачи ciphertext. Число попыток ограничено; пустой пароль попытку не тратит.",
|
||||
"verify.s5.title": "Доступно с",
|
||||
"verify.s5.body": "Если задано «доступно с», unwrap отклоняется до этого момента. Дальше действуют обычные правила открытий и срока.",
|
||||
"verify.createCta": "Создать wrap",
|
||||
"about.verifyLink": "Как проверить",
|
||||
"about.verifyHint": "Что видит сервер и как работает ключ в #…",
|
||||
"admin.limits.maxOpens": "Макс. открытий на wrap",
|
||||
"admin.limits.maxOpensHint": "Потолок для UI создания (1–10). По умолчанию: 3.",
|
||||
"create.ttl.default": "По умолчанию ({seconds} с)",
|
||||
"lang.plaintext": "Обычный текст",
|
||||
"lang.markdown": "Markdown",
|
||||
@@ -296,17 +440,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 +481,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 +490,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": "Размер",
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
})();
|
||||
|
||||
+302
-25
@@ -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,28 @@
|
||||
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"),
|
||||
resultCalloutTitle: document.getElementById("result-callout-title"),
|
||||
resultCalloutHint: document.getElementById("result-callout-hint"),
|
||||
resultCalloutFa: document.getElementById("result-callout-fa"),
|
||||
resultOpensHint: document.getElementById("result-opens-hint"),
|
||||
};
|
||||
|
||||
let lastPack = null;
|
||||
let lastWrapId = null;
|
||||
|
||||
function showError(title, hint, attempts) {
|
||||
if (!els.error) return;
|
||||
if (els.errorTitle) els.errorTitle.textContent = title || "";
|
||||
@@ -37,7 +55,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 +73,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 +91,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 +168,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 +184,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 +199,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,17 +215,113 @@
|
||||
return btn;
|
||||
}
|
||||
|
||||
function renderPackage(pack) {
|
||||
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, meta = {}) {
|
||||
revokeAllUrls();
|
||||
lastPack = pack;
|
||||
els.items.innerHTML = "";
|
||||
const destroyed = meta.destroyed !== false && !(Number(meta.opens_remaining) > 0);
|
||||
const opensRemaining = Number(meta.opens_remaining) || 0;
|
||||
if (els.resultCalloutTitle && els.resultCalloutHint) {
|
||||
if (destroyed) {
|
||||
els.resultCalloutTitle.textContent = t("unwrap.destroyedTitle");
|
||||
els.resultCalloutHint.textContent = t("unwrap.destroyedHint");
|
||||
if (els.resultCalloutFa) els.resultCalloutFa.className = "fa-solid fa-fire";
|
||||
} else {
|
||||
els.resultCalloutTitle.textContent = t("unwrap.stillOnServerTitle");
|
||||
els.resultCalloutHint.textContent = t("unwrap.stillOnServerHint", { n: opensRemaining });
|
||||
if (els.resultCalloutFa) els.resultCalloutFa.className = "fa-solid fa-clock";
|
||||
}
|
||||
}
|
||||
if (els.resultOpensHint) {
|
||||
if (!destroyed && opensRemaining > 0) {
|
||||
els.resultOpensHint.textContent = t("unwrap.opensRemaining", { n: opensRemaining });
|
||||
els.resultOpensHint.classList.remove("hidden");
|
||||
} else {
|
||||
els.resultOpensHint.textContent = "";
|
||||
els.resultOpensHint.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
for (const item of pack.items || []) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "item-card";
|
||||
const label = (item.label || "").trim();
|
||||
if (item.type === "text") {
|
||||
const lang = item.language || "plaintext";
|
||||
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 metaEl = document.createElement("div");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = label || "text";
|
||||
metaEl.appendChild(strong);
|
||||
metaEl.appendChild(document.createTextNode(" · "));
|
||||
const langEl = document.createElement("span");
|
||||
langEl.className = "mono";
|
||||
langEl.textContent = lang;
|
||||
metaEl.appendChild(langEl);
|
||||
head.appendChild(metaEl);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "item-card-actions";
|
||||
actions.appendChild(makeCopyBtn(() => text));
|
||||
@@ -170,28 +340,90 @@
|
||||
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 metaEl = document.createElement("div");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = label || name;
|
||||
metaEl.appendChild(strong);
|
||||
if (label) {
|
||||
metaEl.appendChild(document.createTextNode(" · "));
|
||||
metaEl.appendChild(document.createTextNode(name));
|
||||
}
|
||||
metaEl.appendChild(document.createTextNode(" · "));
|
||||
const mimeEl = document.createElement("span");
|
||||
mimeEl.className = "mono";
|
||||
mimeEl.textContent = mime;
|
||||
metaEl.appendChild(mimeEl);
|
||||
metaEl.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`));
|
||||
head.appendChild(metaEl);
|
||||
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 = label || name;
|
||||
img.src = url;
|
||||
img.className = "item-preview-img";
|
||||
img.loading = "lazy";
|
||||
img.addEventListener("click", () => openLightbox(url, label || 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 +433,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 +448,27 @@
|
||||
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 === "not_yet_available") {
|
||||
const when = detail.available_from
|
||||
? new Intl.DateTimeFormat(window.WrappedI18n.locale?.() || undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(detail.available_from))
|
||||
: "";
|
||||
showState({
|
||||
title: t("unwrap.notYetTitle"),
|
||||
hint: when
|
||||
? t("unwrap.notYetHint", { datetime: when })
|
||||
: t("unwrap.notYetHintGeneric"),
|
||||
icon: "fa-clock",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (detail.code === "password_required") {
|
||||
@@ -228,7 +477,7 @@
|
||||
t("unwrap.passwordRequiredHint"),
|
||||
attemptsLabel(detail)
|
||||
);
|
||||
els.password?.focus();
|
||||
focusPassword({ select: true });
|
||||
return;
|
||||
}
|
||||
if (detail.code === "bad_password") {
|
||||
@@ -237,19 +486,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 +512,27 @@
|
||||
} 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;
|
||||
}
|
||||
renderPackage(pack);
|
||||
lastWrapId = parsed.wrapId;
|
||||
renderPackage(pack, {
|
||||
destroyed: data.destroyed !== false && !(Number(data.opens_remaining) > 0),
|
||||
opens_remaining: data.opens_remaining,
|
||||
max_opens: data.max_opens,
|
||||
opens_used: data.opens_used,
|
||||
});
|
||||
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 +543,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 +573,3 @@
|
||||
|
||||
init().catch(() => showError(t("common.error")));
|
||||
})();
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}"
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
<span data-i18n="admin.limits.auditRetention">Audit retention (days)</span>
|
||||
<input name="audit_retention_days" type="number" value="{{ settings.audit_retention_days }}" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="admin.limits.maxOpens">Max opens per wrap</span>
|
||||
<input name="max_opens_limit" type="number" min="1" max="10" step="1"
|
||||
value="{{ settings.max_opens_limit or 3 }}" />
|
||||
<small class="hint" data-i18n="admin.limits.maxOpensHint">
|
||||
Ceiling for create UI (1–10). Default: 3.
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -150,5 +158,5 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/admin-tabs.js"></script>
|
||||
<script src="{{ static_url('js/admin-tabs.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
+21
-9
@@ -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 />
|
||||
@@ -63,6 +65,16 @@
|
||||
<p data-i18n="about.p2">Можно отправить заметку или код, а также вложения: документы, архивы, скриншоты (drag-and-drop, выбор с диска или вставка из буфера). И текст, и файлы шифруются в браузере до загрузки — на сервер уходит только ciphertext.</p>
|
||||
<p data-i18n="about.p3">Сервер никогда не видит plaintext: зашифрованные данные лежат на сервере до тех пор, пока получатель не откроет ссылку с ключом и не расшифрует пакет.</p>
|
||||
<p data-i18n="about.p4">После успешной расшифровки копия на сервере уничтожается. Ключ шифрования живёт во фрагменте URL (#…) и не уходит на сервер вместе с запросом страницы. При желании wrap можно дополнительно защитить паролем.</p>
|
||||
<a class="about-verify-link" href="/verify">
|
||||
<span class="about-verify-icon" aria-hidden="true">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
</span>
|
||||
<span class="about-verify-text">
|
||||
<strong data-i18n="about.verifyLink">Как проверить</strong>
|
||||
<span data-i18n="about.verifyHint">Что видит сервер и как работает ключ в #…</span>
|
||||
</span>
|
||||
<i class="fa-solid fa-arrow-right about-verify-arrow" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn primary" data-about-close data-i18n="about.close">Понятно</button>
|
||||
@@ -71,11 +83,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>
|
||||
|
||||
+83
-11
@@ -24,12 +24,41 @@
|
||||
<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">
|
||||
<label for="ttl-seconds" data-i18n="create.ttl">Time to live</label>
|
||||
<select id="ttl-seconds"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="max-opens" data-i18n="create.maxOpens">Opens</label>
|
||||
<select id="max-opens"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<span class="field-label" data-i18n="create.availableFrom">Available from (optional)</span>
|
||||
<div class="datetime-split">
|
||||
<div class="input-with-action">
|
||||
<input id="available-from-date" type="date" aria-label="Date" />
|
||||
<button type="button" class="field-icon-btn" id="available-from-date-btn" aria-describedby="available-from-date-tip">
|
||||
<i class="fa-regular fa-calendar" aria-hidden="true"></i>
|
||||
<span class="sr-only" data-i18n="create.availableFromDate">Pick date</span>
|
||||
<span class="ui-tooltip" id="available-from-date-tip" role="tooltip" data-i18n="create.availableFromDate">Pick date</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-with-action">
|
||||
<input id="available-from-time" type="time" step="60" aria-label="Time" />
|
||||
<button type="button" class="field-icon-btn" id="available-from-time-btn" aria-describedby="available-from-time-tip">
|
||||
<i class="fa-regular fa-clock" aria-hidden="true"></i>
|
||||
<span class="sr-only" data-i18n="create.availableFromTime">Pick time</span>
|
||||
<span class="ui-tooltip" id="available-from-time-tip" role="tooltip" data-i18n="create.availableFromTime">Pick time</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" data-i18n="create.availableFromHint">Empty = available immediately</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password" data-i18n="create.password">Password (optional)</label>
|
||||
<div class="input-with-action">
|
||||
@@ -64,17 +93,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>
|
||||
</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 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>
|
||||
|
||||
<div id="success-meta-badges" class="success-meta-badges"></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>
|
||||
|
||||
<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 class="success-qr-actions">
|
||||
<button type="button" class="qr-action-btn hidden" id="share-native" aria-describedby="share-native-tip">
|
||||
<i class="fa-solid fa-share-nodes" aria-hidden="true"></i>
|
||||
<span class="sr-only" data-i18n="create.share">Share</span>
|
||||
<span class="ui-tooltip" id="share-native-tip" role="tooltip" data-i18n="create.share">Share</span>
|
||||
</button>
|
||||
<button type="button" class="qr-action-btn" id="download-qr" aria-describedby="download-qr-tip">
|
||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||
<span class="sr-only" data-i18n="create.downloadQr">Download QR</span>
|
||||
<span class="ui-tooltip" id="download-qr-tip" role="tooltip" data-i18n="create.downloadQr">Download QR</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 +163,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 %}
|
||||
|
||||
@@ -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,21 +32,46 @@
|
||||
</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">
|
||||
<div class="success-callout" role="status" id="result-callout">
|
||||
<span class="success-callout-icon" aria-hidden="true">
|
||||
<i class="fa-solid fa-fire"></i>
|
||||
<i class="fa-solid fa-fire" id="result-callout-fa"></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>
|
||||
<strong id="result-callout-title" data-i18n="unwrap.destroyedTitle">Server copy destroyed</strong>
|
||||
<span id="result-callout-hint" data-i18n="unwrap.destroyedHint">Preview lives only in this browser session.</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="hint success-trust" id="result-opens-hint"></p>
|
||||
<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 %}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }} · Wrapped{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero-panel verify-panel">
|
||||
<div class="panel-head">
|
||||
<p class="eyebrow" data-i18n="verify.eyebrow">Verify</p>
|
||||
<h1 data-i18n="verify.title">How to verify Wrapped</h1>
|
||||
<p class="lede" data-i18n="verify.lede">What leaves your browser, what stays on the server, and how the key in #fragment works.</p>
|
||||
</div>
|
||||
|
||||
<div class="verify-sections">
|
||||
<article class="verify-block">
|
||||
<h2 data-i18n="verify.s1.title">Encryption in the browser</h2>
|
||||
<p data-i18n="verify.s1.body">Text and files are packed and encrypted with Web Crypto (AES-GCM) before upload. The server receives only ciphertext plus metadata (TTL, MIME, size, optional password hash).</p>
|
||||
</article>
|
||||
<article class="verify-block">
|
||||
<h2 data-i18n="verify.s2.title">Key in the URL fragment</h2>
|
||||
<p data-i18n="verify.s2.body">The share link looks like /w/<id>#<key>. The part after # never reaches the server in the page request. Without that fragment (or the full wrapped token), ciphertext cannot be decrypted.</p>
|
||||
</article>
|
||||
<article class="verify-block">
|
||||
<h2 data-i18n="verify.s3.title">Opens and destruction</h2>
|
||||
<p data-i18n="verify.s3.body">By default a wrap can be opened once; then ciphertext is deleted. If the sender chose 2–3 opens, the server keeps ciphertext until the last successful unwrap. Expiry and password lockout still destroy the package.</p>
|
||||
</article>
|
||||
<article class="verify-block">
|
||||
<h2 data-i18n="verify.s4.title">Optional password</h2>
|
||||
<p data-i18n="verify.s4.body">When a password is set, the server checks an Argon2 hash before releasing ciphertext. Wrong guesses are limited; empty password does not burn an attempt.</p>
|
||||
</article>
|
||||
<article class="verify-block">
|
||||
<h2 data-i18n="verify.s5.title">Available from</h2>
|
||||
<p data-i18n="verify.s5.body">If “available from” is set, unwrap is rejected until that time. After that, normal open/expiry rules apply.</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="actions actions-center">
|
||||
<a class="btn primary" href="/" data-i18n="verify.createCta">Create a wrap</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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"
|
||||
|
||||
@@ -2,7 +2,7 @@ replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: inecs/wrapped
|
||||
tag: "0.1.2"
|
||||
tag: "0.1.4"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user