diff --git a/Makefile b/Makefile index b4d409b..ccf2f7d 100644 --- a/Makefile +++ b/Makefile @@ -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 &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 diff --git a/README.md b/README.md index f8f21bf..9a01f45 100644 --- a/README.md +++ b/README.md @@ -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/#` или токен `wrapped_v1..`. Ключ шифрования в 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 --- diff --git a/alembic/versions/002_password_attempts.py b/alembic/versions/002_password_attempts.py new file mode 100644 index 0000000..2f0d862 --- /dev/null +++ b/alembic/versions/002_password_attempts.py @@ -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") diff --git a/app/api/admin.py b/app/api/admin.py index 8926e90..1eb310a 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -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, diff --git a/app/api/wraps.py b/app/api/wraps.py index b8227d3..6abf56b 100644 --- a/app/api/wraps.py +++ b/app/api/wraps.py @@ -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 diff --git a/app/models.py b/app/models.py index 7b97835..e15ad09 100644 --- a/app/models.py +++ b/app/models.py @@ -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, diff --git a/app/schemas.py b/app/schemas.py index c52e44c..d28831a 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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 diff --git a/app/services/settings_service.py b/app/services/settings_service.py index bf5dfc5..f1137d6 100644 --- a/app/services/settings_service.py +++ b/app/services/settings_service.py @@ -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)), } diff --git a/app/static/css/app.css b/app/static/css/app.css index cc0dd89..b4d47ff 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -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; diff --git a/app/static/js/admin-audit.js b/app/static/js/admin-audit.js index 10c129c..0ee2630 100644 --- a/app/static/js/admin-audit.js +++ b/app/static/js/admin-audit.js @@ -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; diff --git a/app/static/js/create.js b/app/static/js/create.js index 1fedd54..d1a312c 100644 --- a/app/static/js/create.js +++ b/app/static/js/create.js @@ -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 = `${f.name} (${f.type || "file"} · ${f.size} B)`; + li.innerHTML = `${f.name} (${f.type || "file"} · ${window.WrappedUI.formatBytes(f.size)})`; 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); }); } diff --git a/app/static/js/highlight-ui.js b/app/static/js/highlight-ui.js new file mode 100644 index 0000000..79a50c2 --- /dev/null +++ b/app/static/js/highlight-ui.js @@ -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(" 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"], + }); +})(); diff --git a/app/static/js/i18n.js b/app/static/js/i18n.js index 759f447..8beb012 100644 --- a/app/static/js/i18n.js +++ b/app/static/js/i18n.js @@ -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 `${main}${rel}`; } 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, + }; })(); diff --git a/app/static/js/ui.js b/app/static/js/ui.js new file mode 100644 index 0000000..fc817b6 --- /dev/null +++ b/app/static/js/ui.js @@ -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 = ` +
+ +

+
+ `; + 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 }; +})(); diff --git a/app/static/js/unwrap.js b/app/static/js/unwrap.js index f86fb32..d3ed253 100644 --- a/app/static/js/unwrap.js +++ b/app/static/js/unwrap.js @@ -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 = `${t("common.download")}`; + 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 = `
text · ${item.language || "plaintext"}
`; + const lang = item.language || "plaintext"; + const head = document.createElement("div"); + head.className = "item-card-head"; + head.innerHTML = `
text · ${lang}
`; + 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 = `
${item.name} · ${item.mime} · ${bytes.length} B
`; + const head = document.createElement("div"); + head.className = "item-card-head"; + head.innerHTML = `
${item.name} · ${item.mime} · ${window.WrappedUI.formatBytes(bytes.length)}
`; + 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]; diff --git a/app/templates/admin_audit.html b/app/templates/admin_audit.html index 6c67a7e..08ec3c1 100644 --- a/app/templates/admin_audit.html +++ b/app/templates/admin_audit.html @@ -1,7 +1,7 @@ {% extends "admin_base.html" %} {% block admin_content %}
-
+ +
@@ -46,7 +54,7 @@ {% for e in events %} - {{ e.created_at }} + {{ e.created_at }} {{ e.event_type }} {% if e.success %} @@ -69,34 +77,39 @@ - {% if pages > 1 %} + {% if show_pagination %} {% endif %}
- +{% endblock %} +{% block admin_scripts %} + {% endblock %} diff --git a/app/templates/admin_base.html b/app/templates/admin_base.html index a80b795..7e96f61 100644 --- a/app/templates/admin_base.html +++ b/app/templates/admin_base.html @@ -68,5 +68,6 @@ + {% block admin_scripts %}{% endblock %} diff --git a/app/templates/admin_settings.html b/app/templates/admin_settings.html index cab4798..ab63ad9 100644 --- a/app/templates/admin_settings.html +++ b/app/templates/admin_settings.html @@ -80,6 +80,20 @@ {% endfor %} +