Улучшить unwrap, UI и админку; обновить документацию.
- Неверный пароль не сжигает wrap сразу: Argon2-гейт до consume и лимит попыток (по умолчанию 3) в настройках - Подсветка синтаксиса, автоопределение языка, спиннеры, размеры файлов, пагинация audit - make push (git, Ctrl-D) и актуальный README
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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 }}&event_type={{ event_type|urlencode }}&wrap_id={{ wrap_id|urlencode }}">
|
||||
<a class="btn pagination-nav" href="/admin/audit?page={{ page - 1 }}&per_page={{ per_page }}&event_type={{ event_type|urlencode }}&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 }}&per_page={{ per_page }}&event_type={{ event_type|urlencode }}&wrap_id={{ wrap_id|urlencode }}">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_next %}
|
||||
<a class="btn" href="/admin/audit?page={{ page + 1 }}&event_type={{ event_type|urlencode }}&wrap_id={{ wrap_id|urlencode }}">
|
||||
<a class="btn pagination-nav" href="/admin/audit?page={{ page + 1 }}&per_page={{ per_page }}&event_type={{ event_type|urlencode }}&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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user