Улучшить unwrap, UI и админку; обновить документацию.

- Неверный пароль не сжигает wrap сразу: Argon2-гейт до consume и лимит попыток (по умолчанию 3) в настройках
- Подсветка синтаксиса, автоопределение языка, спиннеры, размеры файлов, пагинация audit
- make push (git, Ctrl-D) и актуальный README
This commit is contained in:
Sergey Antropoff
2026-07-18 10:17:05 +03:00
parent e4240a7be5
commit 91719740b9
21 changed files with 1170 additions and 178 deletions
+21 -1
View File
@@ -1,4 +1,4 @@
.PHONY: help env build up down restart logs shell migrate revision release helm-package helm-lint clean ps
.PHONY: help env build up down restart logs shell migrate revision release helm-package helm-lint clean ps push
COMPOSE ?= docker compose
IMAGE_NAME ?= wrapped
@@ -24,6 +24,7 @@ help:
@echo " make migrate Run alembic upgrade head"
@echo " make revision m=\"msg\" Create alembic revision"
@echo " make release Build, tag & push $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)"
@echo " make push git add ., commit (prompt), push origin"
@echo " make helm-lint Lint Helm chart"
@echo " make helm-package Package Helm chart"
@echo " make clean Remove containers, volumes, local image"
@@ -103,3 +104,22 @@ clean:
$(COMPOSE) down -v --remove-orphans
-docker rmi $(IMAGE_NAME):$(IMAGE_TAG) $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) 2>/dev/null || true
rm -rf dist
# Same flow as proxmox_api_simulator: stage all, multiline commit via Ctrl-D, push origin.
push:
@set -e; \
git add .; \
echo "=== staged ==="; \
git status --short; \
echo; \
if git diff --cached --quiet; then \
echo "Nothing to commit — pushing current branch."; \
else \
echo "Enter commit message (multiline OK), then Ctrl-D:"; \
msg=$$(cat </dev/tty); \
if [ -z "$$msg" ]; then echo "Empty commit message, aborting." >&2; exit 1; fi; \
printf '%s\n' "$$msg" | git commit -F -; \
fi; \
echo "Pushing to both remotes:"; \
git remote get-url --push --all origin | sed 's/^/ - /'; \
git push origin HEAD
+87 -66
View File
@@ -2,14 +2,17 @@
Анонимный сервис одноразовой передачи зашифрованных данных (текст, изображения, файлы). Шифрование выполняется в браузере (Web Crypto), на сервере хранится только ciphertext. После первого успешного открытия пакет удаляется из хранилища.
Возможности:
**Возможности**
- zero-knowledge шифрование на клиенте
- одноразовое открытие (unwrap)
- опциональный пароль (`client_only` или `server_gate`)
- zero-knowledge шифрование на клиенте (AES-GCM)
- одноразовое открытие (unwrap) — ciphertext удаляется с сервера
- опциональный пароль с лимитом попыток (по умолчанию 3, настраивается в админке)
- подсветка синтаксиса (highlight.js), автоопределение языка текста
- вложения: drag-and-drop, выбор файлов, вставка скриншота из буфера
- RU / EN, светлая и тёмная тема
- CAPTCHA: Cloudflare Turnstile и/или hCaptcha
- админка с лимитами, TTL, allowlist MIME, audit-логом
- Docker Compose и Helm
- админка: лимиты, TTL, MIME, пароль, CAPTCHA, audit с пагинацией, purge
- Docker Compose и Helm; образ на Docker Hub: [`inecs/wrapped`](https://hub.docker.com/r/inecs/wrapped)
Ссылка для получателя: `/w/<id>#<key>` или токен `wrapped_v1.<id>.<key>`. Ключ шифрования в URL-фрагменте (`#...`) на сервер не уходит.
@@ -25,7 +28,7 @@
### Создание с текстом и файлами
Текст с подсветкой синтаксиса, вложения (изображения, документы), TTL и опциональный пароль.
Текст с подсветкой синтаксиса, вложения, TTL и опциональный пароль.
| Светлая тема | Тёмная тема |
|:------------:|:-----------:|
@@ -69,20 +72,21 @@ make up # app + Postgres + MinIO + nginx
make logs # логи app
make down # остановить
make clean # остановить и удалить volumes
make migrate # alembic upgrade head (в контейнере app)
```
Локальный `docker-compose.yml` **собирает** образ из Dockerfile и монтирует код с `--reload` это режим разработки. Для сервера используйте готовый образ с Docker Hub (см. ниже).
Локальный `docker-compose.yml` **собирает** образ из Dockerfile и монтирует код с `--reload` — режим разработки. Для сервера используйте готовый образ с Docker Hub (см. ниже).
При старте контейнер app сам выполняет `alembic upgrade head` (миграции, в т.ч. лимит попыток пароля).
---
## Запуск на сервере (Docker Hub + Compose)
Образ приложения публикуется на [Docker Hub](https://hub.docker.com/). На сервере его **не нужно собирать** — достаточно `docker compose pull` / `up`.
Образ: [`inecs/wrapped`](https://hub.docker.com/r/inecs/wrapped). На сервере **не нужно собирать** — достаточно `docker compose pull` / `up`.
### 1. Подготовка
Создайте каталог и файлы:
```bash
mkdir -p /opt/wrapped && cd /opt/wrapped
```
@@ -105,7 +109,7 @@ ADMIN_PASSWORD=замените-пароль
# БД и S3 задаются в compose (см. ниже). Для внешнего Postgres/MinIO
# переопределите DATABASE_URL и S3_* здесь или в environment сервиса app.
# CAPTCHA (опционально; site keys также можно задать в админке)
# CAPTCHA (опционально; site keys также в админке)
TURNSTILE_SECRET_KEY=
HCAPTCHA_SECRET_KEY=
@@ -117,8 +121,6 @@ APP_PORT=8000
### 2. `docker-compose.yml` для продакшена
Образ на Docker Hub: `inecs/wrapped` (тег задайте нужный, например `0.1.0`).
```yaml
services:
proxy:
@@ -133,7 +135,6 @@ services:
- app
app:
# Образ с Docker Hub — не build:
image: inecs/wrapped:0.1.0
container_name: wrapped-app
restart: unless-stopped
@@ -160,8 +161,6 @@ services:
condition: service_started
minio-init:
condition: service_completed_successfully
# Миграции и старт уже в CMD образа:
# alembic upgrade head && uvicorn ...
postgres:
image: postgres:16-alpine
@@ -213,7 +212,7 @@ volumes:
wrapped_minio_data:
```
Рядом положите `nginx.conf` (прокси на app):
Рядом `nginx.conf` (пример также в репозитории: `deploy/nginx.conf`):
```nginx
server {
@@ -235,30 +234,25 @@ server {
}
```
Готовый пример конфига также есть в репозитории: `deploy/nginx.conf`.
### 3. Запуск
### 3. Запуск и обновление
```bash
docker compose pull
docker compose up -d
docker compose ps
curl -fsS http://127.0.0.1:8000/health
```
Обновление на новую версию образа:
```bash
# в .env / compose поменяйте тег, например inecs/wrapped:0.2.0
# новая версия образа, например 0.2.0
docker compose pull app
docker compose up -d app
```
HTTPS лучше завернуть снаружи (Caddy, Traefik, nginx на хосте, Cloudflare Tunnel) и проксировать на `${APP_PORT}`. В `APP_BASE_URL` укажите публичный `https://...` URL.
HTTPS лучше завернуть снаружи (Caddy, Traefik, nginx на хосте, Cloudflare Tunnel). В `APP_BASE_URL` укажите публичный `https://...`.
### Внешний Postgres и S3/MinIO
Если БД и объектное хранилище уже есть, уберите сервисы `postgres`, `minio`, `minio-init` из compose и задайте:
Уберите сервисы `postgres`, `minio`, `minio-init` и задайте:
| Переменная | Пример |
|------------|--------|
@@ -267,10 +261,10 @@ HTTPS лучше завернуть снаружи (Caddy, Traefik, nginx на
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | ключи |
| `S3_BUCKET` | `wrapped` |
| `S3_USE_SSL` | `true` |
| `S3_CREATE_BUCKET` | `false` (если бакет уже создан) |
| `S3_CREATE_BUCKET` | `false` (бакет уже есть) |
| `S3_REGION` | `us-east-1` |
Минимальный compose в этом случае — только `app` (+ опционально `proxy`):
Минимальный compose — только `app` (+ опционально `proxy`):
```yaml
services:
@@ -294,16 +288,15 @@ services:
|------------|----------|--------------|
| `APP_NAME` | Имя сервиса | `Wrapped` |
| `APP_ENV` | `development` / `production` | `development` |
| `APP_SECRET_KEY` | Секрет сессий/подписей | — смените |
| `APP_BASE_URL` | Публичный URL (ссылки, редиректы) | `http://localhost:8000` |
| `DOCS_ENABLED` | `/docs`, `/redoc`, `/openapi.json` | в prod лучше `false` |
| `APP_SECRET_KEY` | Секрет сессий | — смените |
| `APP_BASE_URL` | Публичный URL | `http://localhost:8000` |
| `DOCS_ENABLED` | `/docs`, `/redoc`, `/openapi.json` | в prod `false` |
| `LOG_LEVEL` | Уровень логов | `INFO` |
| `ADMIN_USERNAME` | Логин админки | `admin` |
| `ADMIN_PASSWORD` | Пароль админки | — смените |
| `DATABASE_URL` | Postgres (`asyncpg`) | — |
| `S3_ENDPOINT_URL` | Endpoint MinIO/S3 | — |
| `S3_ACCESS_KEY` | Access key | — |
| `S3_SECRET_KEY` | Secret key | — |
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | Ключи S3 | — |
| `S3_BUCKET` | Имя бакета | `wrapped` |
| `S3_REGION` | Регион | `us-east-1` |
| `S3_USE_SSL` | TLS к S3 | `false` |
@@ -312,70 +305,99 @@ services:
| `HCAPTCHA_SECRET_KEY` | Секрет hCaptcha | пусто |
| `TRUSTED_PROXIES` | IP/CIDR прокси для `X-Forwarded-*` | loopback + RFC1918 |
Полный шаблон: `.env.example`.
Лимиты загрузки, TTL, MIME, CAPTCHA, режим пароля и **число попыток пароля** задаются в админке (таблица `app_settings`), не через env. Полный шаблон env: `.env.example`.
---
## Модель безопасности
- **Zero-knowledge.** Шифрование AES-GCM в браузере. Сервер видит только ciphertext и метаданные (TTL, MIME, размер).
- **Одноразовое открытие.** После успешного unwrap объект удаляется из S3/MinIO, статус wrap`consumed`.
- **Пароль.** Режим `client_only` — пароль участвует в ключе на клиенте; `server_gate` — сервер проверяет хеш (Argon2) до выдачи ciphertext.
- **Одноразовое открытие.** После успешного unwrap объект удаляется из S3/MinIO, статус → `consumed`.
- **Пароль.** При создании с паролем сервер сохраняет Argon2-хеш и проверяет его *до* выдачи ciphertext — опечатка не сжигает пакет сразу. Лимит неверных попыток: `password_max_attempts` в админке (по умолчанию **3**). При исчерпании wrap уничтожается; UI показывает, сколько попыток осталось. В режиме `client_only` пароль также участвует в шифровании на клиенте.
- **CAPTCHA.** Включается в админке; секреты — через env, site keys — в UI.
- **IP в audit.** Реальный IP берётся из заголовков только от `TRUSTED_PROXIES`.
- **IP в audit.** Реальный IP — только из заголовков от `TRUSTED_PROXIES`.
Ключ в `#fragment` не отправляется на сервер в HTTP-запросе страницы.
Ключ в `#fragment` не уходит на сервер в запросе страницы.
---
## Админка
- UI: `/admin` (логин `/admin/login`)
- Настройки: лимиты загрузки, TTL, rate limit, MIME allowlist, CAPTCHA, режим пароля, retention audit
- Audit-лог и опасные операции (purge) — в соответствующих разделах UI
- JSON Admin API: Basic Auth (`ADMIN_USERNAME` / `ADMIN_PASSWORD`), тег OpenAPI `Admin`
- **Limits** — размер upload, TTL, retention audit
- **Rate limits** — create / unwrap / admin login
- **MIME** — allowlist
- **Password** — режим (`client_only` / `server_gate`) и лимит неверных вводов при unwrap
- **CAPTCHA** — провайдер и site keys
- **Audit** — фильтры, пагинация (10/25/50/100 на страницу), номера страниц
- **Danger** — полная очистка wraps и объектов в MinIO
- JSON Admin API: HTTP Basic (`ADMIN_USERNAME` / `ADMIN_PASSWORD`)
---
## API (автоматизация)
## API
Тот же ZK-протокол, что и в UI: сначала шифруете локально, затем:
Тот же ZK-протокол, что в UI: шифруете локально, затем:
| Метод | Путь | Назначение |
|-------|------|------------|
| `GET` | `/api/v1/settings` | Публичные лимиты, CAPTCHA, режим пароля |
| `GET` | `/api/v1/settings` | Публичные лимиты, CAPTCHA, режим пароля, `password_max_attempts` |
| `POST` | `/api/v1/wraps` | Загрузить ciphertext + метаданные |
| `POST` | `/api/v1/wraps/{id}/unwrap` | Одноразово получить ciphertext |
| `GET` | `/health` | Healthcheck |
Серверного endpoint «зашифруй за меня» нет.
Серверного «зашифруй за меня» нет.
При `DOCS_ENABLED=true` доступны Swagger `/docs` и OpenAPI `/openapi.json`.
При неверном пароле unwrap возвращает `403` с телом вида:
```json
{
"detail": {
"code": "bad_password",
"attempts_remaining": 2,
"attempts_max": 3
}
}
```
После исчерпания попыток: `"code": "password_locked"`, wrap уничтожен.
При `DOCS_ENABLED=true` — Swagger `/docs` и OpenAPI `/openapi.json`.
---
## Сборка и публикация образа
## Сборка образа и git
Нужен логин в Docker Hub: `docker login` (пользователь `inecs`).
### Docker Hub (`make release`)
```bash
# Сборка + push на hub.docker.com как inecs/wrapped:0.1.0
make release IMAGE_TAG=0.1.0
docker login # пользователь inecs
# Только собрать и затегать локально (без push)
make release IMAGE_TAG=0.1.0 PUSH=0
make release IMAGE_TAG=0.1.0 # build + push inecs/wrapped:0.1.0
make release IMAGE_TAG=0.1.0 PUSH=0 # только локальный тег
```
По умолчанию: `RELEASE_REGISTRY=inecs`, `IMAGE_TAG=0.1.0` → образ `inecs/wrapped:0.1.0`.
Эквивалент вручную:
Эквивалент:
```bash
docker build -t inecs/wrapped:0.1.0 .
docker push inecs/wrapped:0.1.0
```
Образ при старте сам выполняет `alembic upgrade head` и поднимает uvicorn на порту `8000`. Healthcheck: `GET /health`.
Образ при старте: `alembic upgrade head` + uvicorn `:8000`. Healthcheck: `GET /health`.
### Git (`make push`)
Как в proxmox_api_simulator:
```bash
make push
```
1. `git add .`
2. ввод сообщения коммита (**можно несколько строк**)
3. **Ctrl-D** — конец ввода
4. `git push origin HEAD`
---
@@ -386,8 +408,6 @@ helm upgrade --install wrapped ./helm/wrapped \
-f my-values.yaml
```
В `values.yaml` задайте образ с Hub, внешние DB/S3 и секреты:
```yaml
image:
repository: inecs/wrapped
@@ -417,26 +437,27 @@ external:
---
## Make-цели (разработка)
## Make-цели
| Цель | Описание |
|------|----------|
| `make env` | `.env` из `.env.example` |
| `make up` | Поднять dev-стек |
| `make down` / `make clean` | Остановить / с volumes |
| `make logs` / `make ps` | Логи / статус |
| `make up` / `make down` / `make clean` | Dev-стек |
| `make logs` / `make ps` / `make restart` | Логи, статус, рестарт app |
| `make migrate` | `alembic upgrade head` |
| `make release` | Сборка образа (+ push) и Helm package |
| `make revision m="msg"` | Новая alembic-ревизия |
| `make release` | Образ на Docker Hub (+ Helm package) |
| `make push` | git add → многострочный commit (Ctrl-D) → push |
| `make helm-lint` / `make helm-package` | Helm |
---
## Требования
- Python 3.12+ (для запуска без Docker)
- Python 3.12+ (без Docker)
- PostgreSQL 16+
- S3-совместимое хранилище (MinIO и т.п.)
- Docker Compose v2 — для деплоя как выше
- Docker Compose v2
---
+42
View File
@@ -0,0 +1,42 @@
"""password max attempts setting and per-wrap fail counter
Revision ID: 002_password_attempts
Revises: 001_initial
Create Date: 2026-07-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "002_password_attempts"
down_revision: Union[str, None] = "001_initial"
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(
"password_max_attempts",
sa.Integer(),
nullable=False,
server_default="3",
),
)
op.add_column(
"wraps",
sa.Column(
"password_fail_count",
sa.Integer(),
nullable=False,
server_default="0",
),
)
def downgrade() -> None:
op.drop_column("wraps", "password_fail_count")
op.drop_column("app_settings", "password_max_attempts")
+31 -1
View File
@@ -187,6 +187,9 @@ async def settings_save(
"rate_limit_admin_login_per_minute", row.rate_limit_admin_login_per_minute
)
row.audit_retention_days = as_int("audit_retention_days", row.audit_retention_days)
row.password_max_attempts = min(
50, max(1, as_int("password_max_attempts", row.password_max_attempts 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()
@@ -274,6 +277,25 @@ async def danger_page(
)
def _audit_page_window(page: int, pages: int, radius: int = 2) -> list[int | None]:
"""Page numbers with None as ellipsis gaps, e.g. [1, None, 4, 5, 6, None, 20]."""
if pages <= 1:
return [1] if pages == 1 else []
keep = {1, pages, page}
for i in range(page - radius, page + radius + 1):
if 1 <= i <= pages:
keep.add(i)
ordered = sorted(keep)
window: list[int | None] = []
prev = 0
for n in ordered:
if prev and n - prev > 1:
window.append(None)
window.append(n)
prev = n
return window
@router.get("/audit")
async def audit_page(
request: Request,
@@ -284,7 +306,11 @@ async def audit_page(
wrap_id = request.query_params.get("wrap_id") or None
if event_type == "":
event_type = None
per_page = 50
try:
per_page = int(request.query_params.get("per_page") or "25")
except ValueError:
per_page = 25
per_page = min(100, max(10, per_page))
try:
page = max(1, int(request.query_params.get("page") or "1"))
except ValueError:
@@ -317,11 +343,14 @@ async def audit_page(
"page": page,
"pages": pages,
"per_page": per_page,
"per_page_options": [10, 25, 50, 100],
"page_window": _audit_page_window(page, pages),
"total": total,
"from_idx": from_idx,
"to_idx": to_idx,
"has_prev": page > 1,
"has_next": page < pages,
"show_pagination": total > 0 and pages > 1,
},
)
@@ -340,6 +369,7 @@ async def admin_settings_api(
"mime_allowlist": row.mime_allowlist,
"captcha_provider": row.captcha_provider.value,
"password_mode": row.password_mode.value,
"password_max_attempts": row.password_max_attempts,
"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,
+53 -12
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.models import PasswordMode, Wrap, WrapStatus
from app.models import Wrap, WrapStatus
from app.schemas import (
PublicSettingsOut,
UnwrapRequest,
@@ -111,12 +111,13 @@ async def create_wrap(
)
raise HTTPException(status_code=400, detail="mime_not_allowed")
# Always store Argon2 hash when a password is set so wrong guesses
# are rejected *before* the one-time ciphertext is consumed.
password_hash = None
if body.has_password:
if settings.password_mode == PasswordMode.server_gate:
if not body.password:
raise HTTPException(status_code=400, detail="password_required")
password_hash = hash_password(body.password)
if not body.password:
raise HTTPException(status_code=400, detail="password_required")
password_hash = hash_password(body.password)
wrap_id = new_wrap_id()
object_key = f"wraps/{wrap_id}.bin"
@@ -250,21 +251,61 @@ async def unwrap(
)
fail("unavailable")
if (
wrap.has_password
and wrap.password_mode == PasswordMode.server_gate
and wrap.password_hash
):
if wrap.has_password and wrap.password_hash:
max_attempts = max(1, int(settings.password_max_attempts or 3))
if not body.password or not verify_password(wrap.password_hash, body.password):
wrap.password_fail_count = int(wrap.password_fail_count or 0) + 1
remaining = max(0, max_attempts - wrap.password_fail_count)
now_fail = datetime.now(timezone.utc)
if wrap.password_fail_count >= max_attempts:
wrap.status = WrapStatus.consumed
wrap.consumed_at = now_fail
await delete_wrap_object(db, wrap)
await db.commit()
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={
"reason": "password_attempts_exceeded",
"attempts": wrap.password_fail_count,
"attempts_max": max_attempts,
},
)
raise HTTPException(
status_code=403,
detail={
"code": "password_locked",
"attempts_remaining": 0,
"attempts_max": max_attempts,
},
)
await db.commit()
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "bad_password"},
details={
"reason": "bad_password",
"attempts": wrap.password_fail_count,
"attempts_remaining": remaining,
"attempts_max": max_attempts,
},
)
raise HTTPException(
status_code=403,
detail={
"code": "bad_password",
"attempts_remaining": remaining,
"attempts_max": max_attempts,
},
)
raise HTTPException(status_code=403, detail="bad_password")
# Atomic consume
from sqlalchemy import update
+3
View File
@@ -63,6 +63,8 @@ class AppSettings(Base):
Enum(PasswordMode, name="password_mode"),
default=PasswordMode.client_only,
)
# Wrong unwrap passwords allowed before the wrap is burned (default 3).
password_max_attempts: 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()
@@ -85,6 +87,7 @@ class Wrap(Base):
item_count: Mapped[int] = mapped_column(Integer, default=1)
has_password: Mapped[bool] = mapped_column(Boolean, default=False)
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
password_fail_count: Mapped[int] = mapped_column(Integer, default=0)
password_mode: Mapped[PasswordMode] = mapped_column(
Enum(PasswordMode, name="password_mode", create_constraint=False),
default=PasswordMode.client_only,
+2
View File
@@ -16,6 +16,7 @@ class PublicSettingsOut(BaseModel):
hcaptcha_site_key: str
password_mode: str
password_mode_description: dict[str, str]
password_max_attempts: int
class WrapCreateRequest(BaseModel):
@@ -66,6 +67,7 @@ class AdminSettingsUpdate(BaseModel):
turnstile_site_key: str | None = None
hcaptcha_site_key: str | None = None
password_mode: str | None = None
password_max_attempts: int | None = Field(default=None, ge=1, le=50)
audit_retention_days: int | None = None
+6 -5
View File
@@ -13,14 +13,14 @@ from app.models import (
PASSWORD_MODE_HELP = {
"client_only": (
"Password is verified only in the browser after ciphertext download. "
"Maximum zero-knowledge: the server never checks the password. "
"Best when you trust link secrecy and want strongest ZK."
"Password also wraps ciphertext in the browser (PBKDF2 + AES-GCM). "
"Server still stores an Argon2id hash and rejects wrong passwords before "
"the one-time unwrap, so a mistyped password does not burn the package."
),
"server_gate": (
"Server stores an Argon2id hash and releases ciphertext only after a correct password. "
"Slightly weaker ZK metadata (server knows password success/fail) but stops offline "
"bruteforce if someone steals the share link without the password."
"Ciphertext is still encrypted client-side with the same password. "
"Stops offline bruteforce if someone steals the share link without the password."
),
}
@@ -69,4 +69,5 @@ def public_settings_payload(row: AppSettings) -> dict:
"hcaptcha_site_key": row.hcaptcha_site_key or "",
"password_mode": row.password_mode.value,
"password_mode_description": PASSWORD_MODE_HELP,
"password_max_attempts": max(1, int(row.password_max_attempts or 3)),
}
+313 -5
View File
@@ -243,6 +243,17 @@ html[data-theme="dark"] .icon-btn:hover {
.lede { color: var(--muted); margin: 0 0 1.5rem; max-width: 42rem; }
.composer, .success-panel, .result-panel { display: grid; gap: 0.9rem; }
.success-panel {
justify-items: stretch;
text-align: left;
}
.success-panel > h2,
.success-panel > .warn {
text-align: center;
}
.success-panel > .btn.ghost {
justify-self: center;
}
label { display: block; font-size: 0.86rem; color: var(--muted); margin-bottom: 0.35rem; }
@@ -261,6 +272,250 @@ input, select, textarea, .code-input {
font-size: 0.9rem;
}
.code-editor {
position: relative;
min-height: 220px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--input);
overflow: hidden;
}
.code-highlight {
margin: 0;
min-height: 220px;
padding: 0.75rem 0.9rem;
overflow: auto;
pointer-events: none;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.45;
tab-size: 2;
font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--text);
}
.code-highlight code {
font: inherit;
background: transparent !important;
padding: 0 !important;
display: block;
white-space: inherit;
word-break: inherit;
color: inherit;
}
.code-editor .code-input {
position: absolute;
inset: 0;
min-height: 220px;
resize: vertical;
line-height: 1.45;
tab-size: 2;
background: transparent;
color: transparent;
caret-color: var(--text);
border: 0;
border-radius: 12px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
z-index: 1;
}
.code-editor .code-input::placeholder {
color: var(--muted);
opacity: 0.85;
}
/* Show highlighted tokens under transparent textarea */
.code-highlight .hljs,
.code-highlight code.hljs {
background: transparent !important;
color: var(--text);
}
.lang-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.55rem 0.75rem;
}
.lang-current {
display: grid;
gap: 0.35rem;
}
.lang-label {
font-size: 0.86rem;
color: var(--muted);
}
.lang-chip {
appearance: none;
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid var(--line);
background: var(--input);
color: var(--text);
border-radius: 999px;
padding: 0.4rem 0.75rem;
font: inherit;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.lang-chip:hover {
border-color: var(--accent-2);
}
.lang-chip i {
font-size: 0.7rem;
color: var(--muted);
}
.lang-hint {
margin: -0.35rem 0 0;
font-size: 0.8rem;
color: var(--warn);
}
.lang-modal-panel {
width: min(480px, 100%);
}
.lang-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
margin-bottom: 1rem;
}
@media (max-width: 520px) {
.lang-grid { grid-template-columns: 1fr; }
}
.lang-option {
appearance: none;
border: 1px solid var(--line);
background: var(--input);
color: var(--text);
border-radius: 10px;
padding: 0.55rem 0.7rem;
font: inherit;
font-size: 0.9rem;
font-weight: 550;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
}
.lang-option:hover {
border-color: var(--accent-2);
background: rgba(59, 126, 240, 0.08);
}
.lang-option.active {
border-color: var(--accent);
background: rgba(61, 214, 198, 0.12);
}
.btn.tiny {
padding: 0.35rem 0.65rem;
border-radius: 9px;
font-size: 0.8rem;
font-weight: 600;
}
.expires-meta {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
margin: 0.35rem 0 0.15rem;
padding: 0.85rem 1rem;
text-align: center;
border-radius: 14px;
border: 1px solid rgba(110, 168, 255, 0.22);
background: linear-gradient(160deg, rgba(110, 168, 255, 0.1), rgba(13, 20, 32, 0.28));
}
html[data-theme="light"] .expires-meta {
background: linear-gradient(160deg, rgba(47, 111, 237, 0.1), rgba(255, 255, 255, 0.85));
border-color: rgba(47, 111, 237, 0.2);
}
.expires-main {
font-size: 0.98rem;
font-weight: 650;
color: var(--text);
letter-spacing: 0.01em;
}
.expires-rel {
font-size: 0.82rem;
font-weight: 600;
color: var(--accent-2);
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: rgba(61, 214, 198, 0.12);
}
html[data-theme="light"] .expires-rel {
background: rgba(47, 111, 237, 0.1);
}
.busy-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
place-items: center;
background: rgba(8, 14, 24, 0.45);
backdrop-filter: blur(6px);
}
.busy-overlay.hidden { display: none; }
html[data-theme="light"] .busy-overlay {
background: rgba(20, 35, 58, 0.28);
}
body.busy-open { overflow: hidden; }
.busy-card {
display: grid;
justify-items: center;
gap: 0.85rem;
padding: 1.4rem 1.6rem;
border-radius: 18px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow);
min-width: min(260px, 90vw);
}
.busy-logo img {
display: block;
width: 48px;
height: 48px;
animation: busy-spin 1.1s linear infinite;
filter: drop-shadow(0 4px 12px rgba(61, 214, 198, 0.35));
}
@keyframes busy-spin {
to { transform: rotate(360deg); }
}
.busy-text {
margin: 0;
font-size: 0.92rem;
font-weight: 600;
color: var(--muted);
text-align: center;
}
.item-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.item-card-head > div {
min-width: 0;
font-size: 0.9rem;
}
.btn.download-btn {
flex-shrink: 0;
gap: 0.35rem;
padding: 0.35rem 0.65rem;
border-radius: 9px;
font-size: 0.78rem;
font-weight: 600;
}
.btn.download-btn i {
font-size: 0.72rem;
opacity: 0.9;
}
.code-input {
min-height: 220px;
resize: vertical;
@@ -782,6 +1037,16 @@ html[data-theme="light"] .admin-nav {
border-radius: 10px;
}
.radio-block small { display: block; color: var(--muted); margin-top: 0.25rem; }
.admin-field-spaced {
display: grid;
gap: 0.35rem;
margin-top: 1.15rem;
max-width: 280px;
}
.admin-field-spaced .hint {
margin: 0;
line-height: 1.35;
}
.helper { border: 1px solid var(--line); border-radius: 10px; padding: 0.6rem 0.8rem; }
.helper-body ol { padding-left: 1.2rem; color: var(--muted); }
.hint { color: var(--muted); font-size: 0.9rem; }
@@ -958,14 +1223,15 @@ html[data-theme="light"] .admin-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
gap: 0.65rem;
margin-top: 1.1rem;
flex-wrap: wrap;
}
.pagination .btn {
gap: 0.45rem;
padding: 0.55rem 0.9rem;
min-width: 7rem;
.pagination-nav {
gap: 0.4rem;
padding: 0.45rem 0.75rem;
min-width: 6.5rem;
font-size: 0.86rem;
}
.pagination .btn[aria-disabled="true"],
.pagination .btn[disabled] {
@@ -974,6 +1240,48 @@ html[data-theme="light"] .admin-nav {
transform: none;
box-shadow: none;
}
.pagination-pages {
display: inline-flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
justify-content: center;
}
.pagination-page {
min-width: 2.15rem;
height: 2.15rem;
padding: 0 0.45rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
border: 1px solid var(--line);
background: var(--input);
color: var(--text);
text-decoration: none;
font-size: 0.86rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.pagination-page:hover {
border-color: var(--accent-2);
background: rgba(59, 126, 240, 0.08);
}
.pagination-page.is-current {
border-color: transparent;
background: linear-gradient(135deg, var(--btn-primary-from), var(--btn-primary-mid) 45%, var(--btn-primary-to));
color: var(--btn-primary-fg);
box-shadow: 0 4px 12px var(--btn-primary-shadow);
pointer-events: none;
}
.pagination-ellipsis {
min-width: 1.4rem;
text-align: center;
color: var(--muted);
font-weight: 600;
user-select: none;
}
.pagination-status {
color: var(--muted);
font-size: 0.92rem;
+10 -3
View File
@@ -1,11 +1,18 @@
(() => {
const form = document.getElementById("audit-filter-form");
const typeSelect = document.getElementById("audit-event-type");
if (form && typeSelect) {
typeSelect.addEventListener("change", () => form.requestSubmit());
const perPage = document.getElementById("audit-per-page");
function resetToFirstPageAndSubmit() {
if (!form) return;
const pageInput = form.querySelector('input[name="page"]');
if (pageInput) pageInput.value = "1";
form.requestSubmit();
}
// Human-friendly local timestamps
typeSelect?.addEventListener("change", resetToFirstPageAndSubmit);
perPage?.addEventListener("change", resetToFirstPageAndSubmit);
document.querySelectorAll(".audit-time[data-ts]").forEach((el) => {
const raw = el.getAttribute("data-ts");
if (!raw) return;
+156 -15
View File
@@ -1,12 +1,22 @@
(() => {
const t = (k) => window.WrappedI18n.t(k);
const t = (k, vars) => window.WrappedI18n.t(k, vars);
const files = [];
let settings = null;
let captchaWidgetId = null;
let langManual = false;
let detectTimer = null;
const els = {
text: document.getElementById("payload-text"),
lang: document.getElementById("text-language"),
langChip: document.getElementById("lang-chip"),
langChipText: document.getElementById("lang-chip-text"),
langChange: document.getElementById("lang-change"),
langHint: document.getElementById("lang-hint"),
langModal: document.getElementById("lang-modal"),
langGrid: document.getElementById("lang-grid"),
highlight: document.getElementById("code-highlight"),
editor: document.getElementById("code-editor"),
ttl: document.getElementById("ttl-seconds"),
password: document.getElementById("password"),
generatePassword: document.getElementById("generate-password"),
@@ -43,11 +53,103 @@
});
}
function langLabel(id) {
return t(`lang.${id}`) || id;
}
function setLanguage(id, { manual = false, silent = false } = {}) {
const lang = window.WrappedHighlight.languages().includes(id) ? id : "plaintext";
els.lang.value = lang;
if (els.langChipText) els.langChipText.textContent = langLabel(lang);
if (manual) langManual = true;
if (!silent) refreshHighlight();
}
function refreshHighlight() {
window.WrappedHighlight.highlightElement(
els.highlight,
els.text.value,
els.lang.value || "plaintext"
);
syncEditorHeight();
}
function syncEditorHeight() {
if (!els.text || !els.highlight) return;
els.highlight.parentElement.style.height = "auto";
// Keep highlight pre matching textarea scroll height
const pre = els.highlight.parentElement;
pre.style.minHeight = `${Math.max(220, els.text.scrollHeight)}px`;
}
function updateLangHint(detection) {
if (!els.langHint) return;
if (langManual || !els.text.value.trim()) {
els.langHint.classList.add("hidden");
els.langHint.textContent = "";
return;
}
if (detection.confidence >= 0.6) {
els.langHint.classList.add("hidden");
els.langHint.textContent = "";
return;
}
const suggestions = (detection.suggestions || [])
.filter((l) => l !== detection.lang)
.slice(0, 3)
.map(langLabel);
els.langHint.textContent = suggestions.length
? t("create.languageUnsure", { suggestions: suggestions.join(", ") })
: t("create.languageUnsurePlain");
els.langHint.classList.remove("hidden");
}
function autoDetectLanguage() {
if (langManual) {
refreshHighlight();
return;
}
const detection = window.WrappedHighlight.detectLanguage(els.text.value);
setLanguage(detection.lang, { silent: true });
refreshHighlight();
updateLangHint(detection);
}
function scheduleDetect() {
clearTimeout(detectTimer);
detectTimer = setTimeout(autoDetectLanguage, 220);
}
function openLangModal() {
if (!els.langModal || !els.langGrid) return;
els.langGrid.innerHTML = "";
for (const id of window.WrappedHighlight.languages()) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "lang-option" + (id === els.lang.value ? " active" : "");
btn.textContent = langLabel(id);
btn.addEventListener("click", () => {
setLanguage(id, { manual: true });
updateLangHint({ confidence: 1, suggestions: [] });
closeLangModal();
});
els.langGrid.appendChild(btn);
}
els.langModal.classList.remove("hidden");
document.body.classList.add("modal-open");
}
function closeLangModal() {
if (!els.langModal) return;
els.langModal.classList.add("hidden");
document.body.classList.remove("modal-open");
}
function renderFiles() {
els.fileList.innerHTML = "";
files.forEach((f, idx) => {
const li = document.createElement("li");
li.innerHTML = `<span>${f.name} <small>(${f.type || "file"} · ${f.size} B)</small></span>`;
li.innerHTML = `<span>${f.name} <small>(${f.type || "file"} · ${window.WrappedUI.formatBytes(f.size)})</small></span>`;
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = "×";
@@ -102,7 +204,9 @@
function refreshExpiresMeta() {
if (lastExpiresAt && els.expiresMeta) {
els.expiresMeta.textContent = window.WrappedI18n.formatExpires(lastExpiresAt);
els.expiresMeta.innerHTML = window.WrappedI18n.formatExpiresHtml
? window.WrappedI18n.formatExpiresHtml(lastExpiresAt)
: window.WrappedI18n.formatExpires(lastExpiresAt);
}
}
@@ -146,6 +250,8 @@
settings = await resp.json();
fillTtl();
loadCaptcha();
setLanguage("plaintext", { silent: true });
refreshHighlight();
}
els.dropzone.addEventListener("click", () => els.fileInput.click());
@@ -177,6 +283,35 @@
if (pasted.length) {
e.preventDefault();
addFiles(pasted);
return;
}
// Text paste into textarea triggers input; detect after paste event
if (document.activeElement === els.text) {
setTimeout(() => {
langManual = false;
autoDetectLanguage();
}, 0);
}
});
els.text.addEventListener("input", () => {
refreshHighlight();
scheduleDetect();
});
els.text.addEventListener("scroll", () => {
const pre = els.highlight.parentElement;
pre.scrollTop = els.text.scrollTop;
pre.scrollLeft = els.text.scrollLeft;
});
els.langChip?.addEventListener("click", openLangModal);
els.langChange?.addEventListener("click", openLangModal);
els.langModal?.addEventListener("click", (e) => {
if (e.target.closest("[data-lang-close]")) closeLangModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && els.langModal && !els.langModal.classList.contains("hidden")) {
closeLangModal();
}
});
@@ -229,26 +364,28 @@
}
const password = els.password.value || "";
const pack = { version: 1, items };
const { ciphertext, keyB64url } = await window.WrappedCrypto.encryptPackage(
pack,
password || null
);
if (ciphertext.byteLength > settings.max_upload_bytes) {
showError(t("create.tooLarge"));
return;
}
els.wrapBtn.disabled = true;
window.WrappedUI.showBusy(t("create.working"));
try {
const pack = { version: 1, items };
const { ciphertext, keyB64url } = await window.WrappedCrypto.encryptPackage(
pack,
password || null
);
if (ciphertext.byteLength > settings.max_upload_bytes) {
showError(t("create.tooLarge"));
return;
}
const body = {
ciphertext_b64: window.WrappedCrypto.bytesToBase64(ciphertext),
ttl_seconds: Number(els.ttl.value),
content_types: contentTypes,
item_count: items.length,
has_password: Boolean(password),
password: settings.password_mode === "server_gate" && password ? password : null,
// Always send password when set so server can gate wrong guesses.
password: password || null,
captcha_token: captchaToken() || null,
};
const resp = await fetch("/api/v1/wraps", {
@@ -273,6 +410,7 @@
} catch {
showError(t("common.error"));
} finally {
window.WrappedUI.hideBusy();
els.wrapBtn.disabled = false;
}
});
@@ -297,6 +435,9 @@
window.WrappedI18n.onChange(() => {
fillTtl();
refreshExpiresMeta();
setLanguage(els.lang.value, { silent: true });
refreshHighlight();
if (els.langChipText) els.langChipText.textContent = langLabel(els.lang.value);
});
}
+198
View File
@@ -0,0 +1,198 @@
(() => {
const LANGUAGES = [
"plaintext",
"markdown",
"json",
"yaml",
"python",
"javascript",
"typescript",
"go",
"rust",
"sql",
"bash",
"html",
"css",
];
function tryParseJson(text) {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
}
/**
* @returns {{ lang: string, confidence: number, suggestions: string[] }}
*/
function detectLanguage(text) {
const raw = (text || "").trim();
if (!raw) {
return { lang: "plaintext", confidence: 0, suggestions: LANGUAGES };
}
const scores = Object.fromEntries(LANGUAGES.map((l) => [l, 0]));
if (
(raw.startsWith("{") || raw.startsWith("[")) &&
tryParseJson(raw)
) {
scores.json += 10;
} else if (/^\s*[{[]/.test(raw) && /["']?\w+["']?\s*:/.test(raw)) {
scores.json += 4;
}
if (
/^---\s*$/m.test(raw) ||
(/^[\w.-]+\s*:\s+\S+/m.test(raw) &&
!tryParseJson(raw) &&
!raw.startsWith("<"))
) {
scores.yaml += 5;
}
if (/^\s*-\s+\w+/m.test(raw) && /:\s/.test(raw)) scores.yaml += 2;
if (
/\b(def|class|import|from|async def)\b/.test(raw) ||
/^\s*@\w+/m.test(raw)
) {
scores.python += 5;
}
if (/print\(|__name__|self\./.test(raw)) scores.python += 2;
if (
/\b(function|const|let|var|=>|console\.)\b/.test(raw) ||
/\brequire\s*\(/.test(raw)
) {
scores.javascript += 4;
}
if (/\b(interface|type)\s+\w+|:\s*\w+(\[\])?\s*[=;]/.test(raw)) {
scores.typescript += 5;
}
if (/\bimport\s+type\b|\bas\s+const\b/.test(raw)) scores.typescript += 2;
if (/\b(package|func|fmt\.|:=)\b/.test(raw)) scores.go += 5;
if (/\b(fn |let mut|impl |pub fn|cargo)\b/.test(raw)) scores.rust += 5;
if (
/\b(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE|FROM|WHERE)\b/i.test(raw)
) {
scores.sql += 6;
}
if (/^\s*#!.*\b(bash|sh|zsh)\b/m.test(raw) || /^\s*(sudo |export |echo )/m.test(raw)) {
scores.bash += 4;
}
if (/<\/?[a-zA-Z][\w:-]*[\s>]/.test(raw) || raw.startsWith("<!DOCTYPE")) {
scores.html += 6;
}
if (/[{;]\s*$/m.test(raw) && /[.#]?[\w-]+\s*\{/.test(raw) && !tryParseJson(raw)) {
scores.css += 5;
}
if (/^#{1,6}\s+\S+/m.test(raw) || /\[[^\]]+\]\([^)]+\)/.test(raw)) {
scores.markdown += 4;
}
const ranked = Object.entries(scores)
.filter(([lang]) => lang !== "plaintext")
.sort((a, b) => b[1] - a[1]);
const best = ranked[0];
const second = ranked[1];
if (!best || best[1] < 3) {
return {
lang: "plaintext",
confidence: 0.2,
suggestions: ranked
.filter(([, s]) => s > 0)
.slice(0, 4)
.map(([l]) => l)
.concat(["plaintext"]),
};
}
const confidence =
best[1] >= 8
? 0.95
: best[1] >= 5
? 0.75
: second && best[1] - second[1] <= 1
? 0.45
: 0.6;
const suggestions = ranked
.filter(([, s]) => s > 0)
.slice(0, 5)
.map(([l]) => l);
if (!suggestions.includes("plaintext")) suggestions.push("plaintext");
return { lang: best[0], confidence, suggestions };
}
function hljsLang(lang) {
const map = {
plaintext: "plaintext",
markdown: "markdown",
json: "json",
yaml: "yaml",
python: "python",
javascript: "javascript",
typescript: "typescript",
go: "go",
rust: "rust",
sql: "sql",
bash: "bash",
html: "xml",
css: "css",
};
return map[lang] || "plaintext";
}
function highlightElement(codeEl, text, lang) {
if (!codeEl) return;
const value = text ?? "";
if (window.hljs) {
try {
const res = window.hljs.highlight(value, {
language: hljsLang(lang),
ignoreIllegals: true,
});
codeEl.className = `hljs language-${hljsLang(lang)}`;
codeEl.innerHTML = res.value;
return;
} catch {
/* fall through */
}
}
codeEl.className = "hljs";
codeEl.textContent = value;
}
function syncThemeStylesheet() {
const dark = document.getElementById("hljs-theme-dark");
const light = document.getElementById("hljs-theme-light");
if (!dark || !light) return;
const isLight = document.documentElement.getAttribute("data-theme") === "light";
dark.disabled = isLight;
light.disabled = !isLight;
}
function languages() {
return LANGUAGES.slice();
}
window.WrappedHighlight = {
detectLanguage,
highlightElement,
syncThemeStylesheet,
languages,
hljsLang,
};
document.addEventListener("DOMContentLoaded", syncThemeStylesheet);
const obs = new MutationObserver(syncThemeStylesheet);
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
})();
+60 -10
View File
@@ -22,6 +22,11 @@
"create.title": "Wrap. Send. Vanish.",
"create.lede": "Wrap text, images or files into a one-time token. Encryption happens in your browser — the server never sees plaintext.",
"create.language": "Highlight language",
"create.languageChange": "Change type",
"create.languagePickTitle": "Text type",
"create.languagePickHint": "Choose how the text should be highlighted.",
"create.languageUnsure": "Not sure — maybe: {suggestions}. Or change type.",
"create.languageUnsurePlain": "Could not detect type — change type if needed.",
"create.text": "Text",
"create.textPlaceholder": "Paste secrets, configs, notes…",
"create.drop": "Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)",
@@ -31,6 +36,7 @@
"create.passwordGenerate": "Generate password",
"create.passwordGenerateTip": "Generate a strong password",
"create.submit": "Create wrapped token",
"create.working": "Encrypting and uploading…",
"create.openToken": "Open a token",
"create.successTitle": "Token issued",
"create.successWarn": "One-time unwrap. After someone opens it, ciphertext is destroyed on the server.",
@@ -40,7 +46,8 @@
"create.needPayload": "Add text or at least one file.",
"create.tooLarge": "Package exceeds max size.",
"create.mimeDenied": "One or more MIME types are not allowed.",
"create.expires": "Expires {datetime} · {relative}",
"create.expires": "Expires {datetime}",
"create.expiresRelative": "{relative}",
"create.ttl.1h": "1 hour",
"create.ttl.6h": "6 hours",
"create.ttl.24h": "24 hours",
@@ -71,14 +78,18 @@
"unwrap.tokenPlaceholder": "wrapped_v1.… or wrap id",
"unwrap.password": "Password (if set)",
"unwrap.submit": "Unwrap",
"unwrap.working": "Decrypting…",
"unwrap.destroyed": "Server copy destroyed. Preview lives only in this browser session.",
"unwrap.needKey": "Missing encryption key. Open the full share link (with #key) or paste the full wrapped token.",
"unwrap.badPassword": "Wrong password.",
"unwrap.badPasswordRemaining": "Wrong password. Attempts left: {n} of {max}.",
"unwrap.passwordLocked": "Too many wrong passwords. This wrap has been destroyed.",
"unwrap.unavailable": "Unavailable (already used, expired, or invalid).",
"common.copy": "Copy",
"common.copied": "Copied",
"common.download": "Download",
"common.error": "Something went wrong.",
"common.working": "Working…",
"admin.nav.settings": "Settings",
"admin.nav.audit": "Audit",
"admin.nav.danger": "Danger",
@@ -117,8 +128,10 @@
"admin.mime.hint": "One pattern per line. Supports exact types and wildcards like image/*.",
"admin.mime.examples": "Examples / suggested defaults",
"admin.passwordMode.title": "Password mode",
"admin.passwordMode.client_only": "Password is verified only in the browser after ciphertext download. Maximum zero-knowledge: the server never checks the password. Best when you trust link secrecy and want strongest ZK.",
"admin.passwordMode.server_gate": "Server stores an Argon2id hash and releases ciphertext only after a correct password. Slightly weaker ZK metadata but stops offline bruteforce if someone steals the share link without the password.",
"admin.passwordMode.client_only": "Password also wraps ciphertext in the browser. Server stores an Argon2id hash and rejects wrong passwords before the one-time unwrap (mistypes do not burn the package).",
"admin.passwordMode.server_gate": "Server stores an Argon2id hash and releases ciphertext only after a correct password. Ciphertext is still encrypted client-side with the same password.",
"admin.password.maxAttempts": "Wrong password attempts (unwrap)",
"admin.password.maxAttemptsHint": "After this many wrong passwords the wrap is destroyed. Default: 3.",
"admin.captcha.title": "CAPTCHA",
"admin.captcha.provider": "Provider",
"admin.captcha.turnstileSite": "Turnstile site key",
@@ -152,6 +165,7 @@
"admin.audit.wrapId": "Wrap ID",
"admin.audit.filter": "Filter",
"admin.audit.allEvents": "All events",
"admin.audit.perPage": "Per page",
"admin.audit.shown": "events shown",
"admin.audit.of": "of",
"admin.audit.events": "events",
@@ -188,6 +202,11 @@
"create.title": "Упакуй. Отправь. Исчезни.",
"create.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.",
"create.language": "Язык подсветки",
"create.languageChange": "Изменить тип",
"create.languagePickTitle": "Тип текста",
"create.languagePickHint": "Выберите, как подсвечивать текст.",
"create.languageUnsure": "Не уверен — возможно: {suggestions}. Или измените тип.",
"create.languageUnsurePlain": "Не удалось определить тип — при необходимости измените вручную.",
"create.text": "Текст",
"create.textPlaceholder": "Вставь секреты, конфиги, заметки…",
"create.drop": "Перетащи файлы, выбери или вставь скриншот из буфера (⌘V / Ctrl+V)",
@@ -197,6 +216,7 @@
"create.passwordGenerate": "Сгенерировать пароль",
"create.passwordGenerateTip": "Сгенерировать надёжный пароль",
"create.submit": "Создать wrapped-токен",
"create.working": "Шифрование и загрузка…",
"create.openToken": "Открыть токен",
"create.successTitle": "Токен выдан",
"create.successWarn": "Unwrap один раз. После открытия ciphertext удаляется на сервере.",
@@ -206,7 +226,8 @@
"create.needPayload": "Добавь текст или хотя бы один файл.",
"create.tooLarge": "Пакет превышает лимит размера.",
"create.mimeDenied": "Один или несколько MIME-типов запрещены.",
"create.expires": "Истекает {datetime} · {relative}",
"create.expires": "Истекает {datetime}",
"create.expiresRelative": "{relative}",
"create.ttl.1h": "1 час",
"create.ttl.6h": "6 часов",
"create.ttl.24h": "24 часа",
@@ -237,14 +258,18 @@
"unwrap.tokenPlaceholder": "wrapped_v1.… или ID",
"unwrap.password": "Пароль (если задан)",
"unwrap.submit": "Расшифровать",
"unwrap.working": "Расшифровка…",
"unwrap.destroyed": "Копия на сервере уничтожена. Превью только в этой сессии браузера.",
"unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.",
"unwrap.badPassword": "Неверный пароль.",
"unwrap.badPasswordRemaining": "Неверный пароль. Осталось попыток: {n} из {max}.",
"unwrap.passwordLocked": "Слишком много неверных паролей. Этот wrap уничтожен.",
"unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).",
"common.copy": "Копировать",
"common.copied": "Скопировано",
"common.download": "Скачать",
"common.error": "Что-то пошло не так.",
"common.working": "Подождите…",
"admin.nav.settings": "Настройки",
"admin.nav.audit": "Аудит",
"admin.nav.danger": "Опасная зона",
@@ -283,8 +308,10 @@
"admin.mime.hint": "Один шаблон на строку. Точные типы и wildcards вроде image/*.",
"admin.mime.examples": "Примеры / рекомендуемые значения",
"admin.passwordMode.title": "Режим пароля",
"admin.passwordMode.client_only": "Пароль проверяется только в браузере после скачивания ciphertext. Максимальный zero-knowledge: сервер пароль не проверяет. Лучше, если доверяете секретности ссылки.",
"admin.passwordMode.server_gate": "Сервер хранит Argon2id-хеш и отдаёт ciphertext только после верного пароля. Чуть слабее ZK по метаданным, но защищает от офлайн-брута при утечке ссылки без пароля.",
"admin.passwordMode.client_only": "Пароль также оборачивает ciphertext в браузере. Сервер хранит Argon2id-хеш и отклоняет неверный пароль до одноразового unwrap (опечатка не сжигает пакет).",
"admin.passwordMode.server_gate": "Сервер хранит Argon2id-хеш и отдаёт ciphertext только после верного пароля. Ciphertext по-прежнему шифруется на клиенте тем же паролем.",
"admin.password.maxAttempts": "Неверных вводов пароля (unwrap)",
"admin.password.maxAttemptsHint": "После стольких неверных паролей wrap уничтожается. По умолчанию: 3.",
"admin.captcha.title": "CAPTCHA",
"admin.captcha.provider": "Провайдер",
"admin.captcha.turnstileSite": "Turnstile site key",
@@ -318,6 +345,7 @@
"admin.audit.wrapId": "Wrap ID",
"admin.audit.filter": "Фильтр",
"admin.audit.allEvents": "Все события",
"admin.audit.perPage": "На странице",
"admin.audit.shown": "событий показано",
"admin.audit.of": "из",
"admin.audit.events": "событий",
@@ -358,9 +386,11 @@
return current() === "ru" ? "ru-RU" : "en-GB";
}
function formatExpires(iso) {
function expiresParts(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
if (Number.isNaN(d.getTime())) {
return { datetime: String(iso), relative: "" };
}
const datetime = new Intl.DateTimeFormat(locale(), {
day: "numeric",
month: "long",
@@ -376,7 +406,19 @@
else if (mins < 60 * 48) relative = t("relative.inHours", { n: Math.round(mins / 60) });
else relative = t("relative.inDays", { n: Math.round(mins / (60 * 24)) });
}
return t("create.expires", { datetime, relative });
return { datetime, relative };
}
function formatExpires(iso) {
const { datetime, relative } = expiresParts(iso);
return `${t("create.expires", { datetime })} · ${t("create.expiresRelative", { relative })}`;
}
function formatExpiresHtml(iso) {
const { datetime, relative } = expiresParts(iso);
const main = t("create.expires", { datetime });
const rel = t("create.expiresRelative", { relative });
return `<span class="expires-main">${main}</span><span class="expires-rel">${rel}</span>`;
}
function apply() {
@@ -418,5 +460,13 @@
if (btn) btn.addEventListener("click", toggle);
});
window.WrappedI18n = { t, apply, current, locale, formatExpires, onChange };
window.WrappedI18n = {
t,
apply,
current,
locale,
formatExpires,
formatExpiresHtml,
onChange,
};
})();
+52
View File
@@ -0,0 +1,52 @@
(() => {
let busyEl = null;
function ensureBusy() {
if (busyEl) return busyEl;
busyEl = document.createElement("div");
busyEl.id = "busy-overlay";
busyEl.className = "busy-overlay hidden";
busyEl.setAttribute("aria-live", "polite");
busyEl.setAttribute("aria-busy", "true");
busyEl.innerHTML = `
<div class="busy-card">
<span class="busy-logo" aria-hidden="true">
<img src="/static/favicon.svg" alt="" width="48" height="48" />
</span>
<p class="busy-text" data-busy-label></p>
</div>
`;
document.body.appendChild(busyEl);
return busyEl;
}
function showBusy(message) {
const el = ensureBusy();
const label = el.querySelector("[data-busy-label]");
if (label) {
label.textContent =
message || window.WrappedI18n?.t("common.working") || "Working…";
}
el.classList.remove("hidden");
document.body.classList.add("busy-open");
}
function hideBusy() {
if (!busyEl) return;
busyEl.classList.add("hidden");
document.body.classList.remove("busy-open");
}
function formatBytes(n) {
const bytes = Number(n) || 0;
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`;
const mb = bytes / (1024 * 1024);
if (mb < 1024) return `${mb < 10 ? mb.toFixed(2) : mb.toFixed(1)} MB`;
const gb = mb / 1024;
return `${gb.toFixed(2)} GB`;
}
window.WrappedUI = { showBusy, hideBusy, formatBytes };
})();
+54 -22
View File
@@ -70,44 +70,53 @@
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
function makeDownloadBtn(onClick) {
const btn = document.createElement("button");
btn.className = "btn download-btn";
btn.type = "button";
btn.innerHTML = `<i class="fa-solid fa-download" aria-hidden="true"></i><span>${t("common.download")}</span>`;
btn.addEventListener("click", onClick);
return btn;
}
function renderPackage(pack) {
els.items.innerHTML = "";
for (const item of pack.items || []) {
const card = document.createElement("div");
card.className = "item-card";
if (item.type === "text") {
card.innerHTML = `<div><strong>text</strong> · <span class="mono">${item.language || "plaintext"}</span></div>`;
const lang = item.language || "plaintext";
const head = document.createElement("div");
head.className = "item-card-head";
head.innerHTML = `<div><strong>text</strong> · <span class="mono">${lang}</span></div>`;
head.appendChild(
makeDownloadBtn(() => {
downloadBlob(
`wrapped-${lang}.txt`,
new Blob([item.content || ""], { type: "text/plain" })
);
})
);
card.appendChild(head);
const pre = document.createElement("pre");
pre.textContent = item.content || "";
const code = document.createElement("code");
pre.appendChild(code);
card.appendChild(pre);
const btn = document.createElement("button");
btn.className = "btn";
btn.type = "button";
btn.textContent = t("common.download");
btn.addEventListener("click", () => {
downloadBlob(
`wrapped-${item.language || "text"}.txt`,
new Blob([item.content || ""], { type: "text/plain" })
);
});
card.appendChild(btn);
window.WrappedHighlight.highlightElement(code, item.content || "", 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" });
card.innerHTML = `<div><strong>${item.name}</strong> · <span class="mono">${item.mime}</span> · ${bytes.length} B</div>`;
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)));
card.appendChild(head);
if ((item.mime || "").startsWith("image/")) {
const img = document.createElement("img");
img.alt = item.name;
img.src = URL.createObjectURL(blob);
card.appendChild(img);
}
const btn = document.createElement("button");
btn.className = "btn";
btn.type = "button";
btn.textContent = t("common.download");
btn.style.marginTop = "0.6rem";
btn.addEventListener("click", () => downloadBlob(item.name || "file", blob));
card.appendChild(btn);
}
els.items.appendChild(card);
}
@@ -127,6 +136,7 @@
}
els.btn.disabled = true;
window.WrappedUI.showBusy(t("unwrap.working"));
try {
const resp = await fetch(`/api/v1/wraps/${encodeURIComponent(parsed.wrapId)}/unwrap`, {
method: "POST",
@@ -137,6 +147,27 @@
}),
});
if (resp.status === 403) {
const err = await resp.json().catch(() => ({}));
const detail = err.detail;
if (detail && typeof detail === "object") {
if (detail.code === "password_locked") {
showError(t("unwrap.passwordLocked"));
return;
}
if (detail.code === "bad_password") {
const n = Number(detail.attempts_remaining);
const max = Number(detail.attempts_max);
if (Number.isFinite(n)) {
showError(
t("unwrap.badPasswordRemaining", {
n,
max: Number.isFinite(max) ? max : n,
})
);
return;
}
}
}
showError(t("unwrap.badPassword"));
return;
}
@@ -155,6 +186,7 @@
);
} catch (err) {
if (err.message === "bad_password" || err.message === "password_required") {
// Should be rare now (server gates first); keep UX clear.
showError(t("unwrap.badPassword"));
return;
}
@@ -168,6 +200,7 @@
} catch {
showError(t("common.error"));
} finally {
window.WrappedUI.hideBusy();
els.btn.disabled = false;
}
});
@@ -177,7 +210,6 @@
settings = await resp.json();
loadCaptcha();
// Prefill from path + hash
const pathMatch = location.pathname.match(/\/w\/([A-Za-z0-9]+)/);
if (pathMatch && !els.token.value) {
els.token.value = pathMatch[1];
+29 -18
View File
@@ -1,7 +1,7 @@
{% extends "admin_base.html" %}
{% block admin_content %}
<section class="audit-page">
<form class="audit-filters" method="get" action="/admin/audit">
<form class="audit-filters" id="audit-filter-form" method="get" action="/admin/audit">
<input type="hidden" name="page" value="1" />
<label class="audit-field">
<span data-i18n="admin.audit.eventType">Event type</span>
@@ -16,6 +16,14 @@
<span data-i18n="admin.audit.wrapId">Wrap ID</span>
<input name="wrap_id" value="{{ wrap_id }}" class="mono" placeholder="…" autocomplete="off" />
</label>
<label class="audit-field">
<span data-i18n="admin.audit.perPage">Per page</span>
<select name="per_page" id="audit-per-page">
{% for n in per_page_options %}
<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button class="btn primary audit-filter-btn" type="submit" data-i18n="admin.audit.filter">Filter</button>
</form>
@@ -46,7 +54,7 @@
<tbody>
{% for e in events %}
<tr class="{% if not e.success %}audit-row-fail{% endif %}">
<td class="mono audit-time">{{ e.created_at }}</td>
<td class="mono audit-time" data-ts="{{ e.created_at.isoformat() if e.created_at else '' }}">{{ e.created_at }}</td>
<td><span class="audit-event-pill">{{ e.event_type }}</span></td>
<td>
{% if e.success %}
@@ -69,34 +77,39 @@
</table>
</div>
{% if pages > 1 %}
{% if show_pagination %}
<nav class="pagination" aria-label="Pagination">
{% if has_prev %}
<a class="btn" href="/admin/audit?page={{ page - 1 }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<a class="btn pagination-nav" href="/admin/audit?page={{ page - 1 }}&amp;per_page={{ per_page }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span data-i18n="admin.audit.prev">Prev</span>
</a>
{% else %}
<span class="btn" aria-disabled="true" disabled>
<span class="btn pagination-nav" aria-disabled="true">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span data-i18n="admin.audit.prev">Prev</span>
</span>
{% endif %}
<span class="pagination-status">
<span data-i18n="admin.audit.page">Page</span>
{{ page }}
<span data-i18n="admin.audit.of">of</span>
{{ pages }}
</span>
<div class="pagination-pages">
{% for p in page_window %}
{% if p is none %}
<span class="pagination-ellipsis" aria-hidden="true"></span>
{% elif p == page %}
<span class="pagination-page is-current" aria-current="page">{{ p }}</span>
{% else %}
<a class="pagination-page" href="/admin/audit?page={{ p }}&amp;per_page={{ per_page }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">{{ p }}</a>
{% endif %}
{% endfor %}
</div>
{% if has_next %}
<a class="btn" href="/admin/audit?page={{ page + 1 }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<a class="btn pagination-nav" href="/admin/audit?page={{ page + 1 }}&amp;per_page={{ per_page }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<span data-i18n="admin.audit.next">Next</span>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</a>
{% else %}
<span class="btn" aria-disabled="true" disabled>
<span class="btn pagination-nav" aria-disabled="true">
<span data-i18n="admin.audit.next">Next</span>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</span>
@@ -104,9 +117,7 @@
</nav>
{% endif %}
</section>
<script>
document.getElementById("audit-event-type")?.addEventListener("change", (e) => {
e.target.form?.requestSubmit();
});
</script>
{% endblock %}
{% block admin_scripts %}
<script src="/static/js/admin-audit.js"></script>
{% endblock %}
+1
View File
@@ -68,5 +68,6 @@
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/modal.js"></script>
{% block admin_scripts %}{% endblock %}
</body>
</html>
+14
View File
@@ -80,6 +80,20 @@
</span>
</label>
{% endfor %}
<label class="admin-field-spaced">
<span data-i18n="admin.password.maxAttempts">Wrong password attempts (unwrap)</span>
<input
name="password_max_attempts"
type="number"
min="1"
max="50"
step="1"
value="{{ settings.password_max_attempts or 3 }}"
/>
<small class="hint" data-i18n="admin.password.maxAttemptsHint">
After this many wrong passwords the wrap is destroyed. Default: 3.
</small>
</label>
</section>
<section class="admin-tabpanel" data-panel="captcha" role="tabpanel" hidden>
+5
View File
@@ -12,6 +12,8 @@
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<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 />
{% block head %}{% endblock %}
</head>
<body>
@@ -68,8 +70,11 @@
· <a href="https://devops.org.ru" target="_blank" rel="noopener noreferrer">devops.org.ru</a>
</p>
</footer>
<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/highlight-ui.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+30 -19
View File
@@ -2,27 +2,26 @@
{% block content %}
<section class="hero-panel create-panel">
<div class="composer">
<div class="field-row">
<label for="text-language" data-i18n="create.language">Language</label>
<select id="text-language">
<option value="plaintext" data-i18n="lang.plaintext">Обычный текст</option>
<option value="markdown" data-i18n="lang.markdown">Markdown</option>
<option value="json" data-i18n="lang.json">JSON</option>
<option value="yaml" data-i18n="lang.yaml">YAML</option>
<option value="python" data-i18n="lang.python">Python</option>
<option value="javascript" data-i18n="lang.javascript">JavaScript</option>
<option value="typescript" data-i18n="lang.typescript">TypeScript</option>
<option value="go" data-i18n="lang.go">Go</option>
<option value="rust" data-i18n="lang.rust">Rust</option>
<option value="sql" data-i18n="lang.sql">SQL</option>
<option value="bash" data-i18n="lang.bash">Bash</option>
<option value="html" data-i18n="lang.html">HTML</option>
<option value="css" data-i18n="lang.css">CSS</option>
</select>
<div class="lang-bar">
<div class="lang-current">
<span class="lang-label" data-i18n="create.language">Highlight language</span>
<button type="button" class="lang-chip" id="lang-chip" aria-haspopup="dialog">
<span id="lang-chip-text">Plain text</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
</div>
<button type="button" class="btn tiny ghost" id="lang-change" data-i18n="create.languageChange">
Change type
</button>
<input type="hidden" id="text-language" value="plaintext" />
</div>
<p id="lang-hint" class="lang-hint hidden"></p>
<label class="sr-only" for="payload-text" data-i18n="create.text">Text</label>
<textarea id="payload-text" class="code-input" spellcheck="false" data-i18n-placeholder="create.textPlaceholder" placeholder="Paste secrets, configs, notes…"></textarea>
<div class="code-editor" id="code-editor">
<pre class="code-highlight" aria-hidden="true"><code id="code-highlight" class="hljs"></code></pre>
<textarea id="payload-text" class="code-input" spellcheck="false" data-i18n-placeholder="create.textPlaceholder" placeholder="Paste secrets, configs, notes…"></textarea>
</div>
<div id="dropzone" class="dropzone" tabindex="0">
<p data-i18n="create.drop">Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)</p>
@@ -71,10 +70,22 @@
<input id="share-token" class="mono" readonly />
<button type="button" class="btn" id="copy-token" data-i18n="common.copy">Copy</button>
</div>
<p class="meta" id="expires-meta"></p>
<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>
<div id="lang-modal" class="modal-root hidden" role="dialog" aria-modal="true" aria-labelledby="lang-modal-title">
<div class="modal-backdrop" data-lang-close></div>
<div class="modal-panel lang-modal-panel" role="document">
<h2 class="modal-title" id="lang-modal-title" data-i18n="create.languagePickTitle">Text type</h2>
<p class="modal-message" data-i18n="create.languagePickHint">Choose how the text should be highlighted.</p>
<div class="lang-grid" id="lang-grid"></div>
<div class="modal-actions">
<button type="button" class="btn" data-lang-close data-i18n="modal.confirm.cancel">Cancel</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/crypto.js"></script>
+3 -1
View File
@@ -18,7 +18,9 @@
</div>
<div id="captcha-slot" class="captcha-slot"></div>
<p id="form-error" class="error hidden"></p>
<button type="button" id="unwrap-btn" class="btn primary" data-i18n="unwrap.submit">Unwrap</button>
<div class="actions actions-center">
<button type="button" id="unwrap-btn" class="btn primary" data-i18n="unwrap.submit">Unwrap</button>
</div>
</div>
<div id="result-panel" class="result-panel hidden">