commit 8f2d798e1aaccba43707baeb7d3429b7bfe71641 Author: Sergey Antropoff Date: Fri Jul 17 15:57:36 2026 +0300 Init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d3b907f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.env +.venv +venv +__pycache__ +*.pyc +.mypy_cache +.ruff_cache +.pytest_cache +dist +*.tgz +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..81222ac --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# Application +APP_NAME=Wrapped +APP_ENV=development +APP_SECRET_KEY=change-me-to-a-long-random-string +APP_BASE_URL=http://localhost:8000 +LOG_LEVEL=INFO +# Swagger UI (/docs), ReDoc, openapi.json — keep true for local dev +DOCS_ENABLED=true + +# Admin (change in production) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=38dmWjE1p + +# Postgres +DATABASE_URL=postgresql+asyncpg://wrapped:wrapped@postgres:5432/wrapped + +# MinIO / S3 +S3_ENDPOINT_URL=http://minio:9000 +S3_ACCESS_KEY=wrappedminio +S3_SECRET_KEY=wrappedminio123 +S3_BUCKET=wrapped +S3_REGION=us-east-1 +S3_USE_SSL=false +S3_CREATE_BUCKET=true + +# Optional CAPTCHA secrets (site keys can also be set in admin UI) +TURNSTILE_SECRET_KEY= +HCAPTCHA_SECRET_KEY= + +# Client IP / audit: trust these peers for X-Forwarded-For / X-Real-IP. +# Use * only when the app is not publicly reachable (e.g. behind ClusterIP + Ingress). +# Default covers loopback + RFC1918 (Docker Compose nginx, k8s CNI). +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 + +# Proxies allowed to set X-Forwarded-For / X-Real-IP (audit client IP). +# Use * behind Ingress when only the proxy can reach the app. +# Default covers loopback, RFC1918, and Docker Desktop gateway. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,192.168.65.0/24 + +# Client IP / audit: peers allowed to set X-Forwarded-For, X-Real-IP, CF-Connecting-IP. +# Default = loopback + RFC1918 (Docker Desktop gateway, k8s pod CIDRs, local proxies). +# In Kubernetes behind Ingress use * (only the ingress can reach the Service). +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 + +# Client IP / audit: peers allowed to set X-Forwarded-For, X-Real-IP, CF-Connecting-IP. +# Use * behind a dedicated ingress (recommended in k8s). Default = loopback + RFC1918. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2b0256 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +dist/ +build/ +*.log +.DS_Store +.idea/ +.vscode/ +helm/wrapped/charts/ +*.tgz +.coverage +htmlcov/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4010207 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY pyproject.toml README.md ./ +COPY app ./app +COPY alembic ./alembic +COPY alembic.ini ./ + +RUN pip install --upgrade pip \ + && pip install . + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8000/health || exit 1 + +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=${TRUSTED_PROXIES:-*}"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b4d409b --- /dev/null +++ b/Makefile @@ -0,0 +1,105 @@ +.PHONY: help env build up down restart logs shell migrate revision release helm-package helm-lint clean ps + +COMPOSE ?= docker compose +IMAGE_NAME ?= wrapped +IMAGE_TAG ?= 0.1.0 +# Docker Hub namespace → image: $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) +# Example: inecs/wrapped:0.1.0 +RELEASE_REGISTRY ?= inecs +# Set PUSH=0 to build/tag only (no docker push) +PUSH ?= 1 +APP_PORT ?= 8000 + +help: + @echo "Wrapped — make targets" + @echo "" + @echo " make env Copy .env.example -> .env (if missing)" + @echo " make build Build app image" + @echo " make up Start dev stack (app + Postgres + MinIO)" + @echo " make down Stop stack" + @echo " make restart Restart app service" + @echo " make logs Tail app logs" + @echo " make ps Show compose status" + @echo " make shell Shell into app container" + @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 helm-lint Lint Helm chart" + @echo " make helm-package Package Helm chart" + @echo " make clean Remove containers, volumes, local image" + @echo "" + @echo "Release examples:" + @echo " make release IMAGE_TAG=0.1.0" + @echo " make release IMAGE_TAG=0.1.0 PUSH=0" + +env: + @test -f .env || cp .env.example .env + @echo ".env ready" + +build: env + $(COMPOSE) build app + +up: env + $(COMPOSE) up -d --build + @echo "" + @echo "Wrapped: http://localhost:$(APP_PORT)" + @echo "MinIO console: http://localhost:9001 (wrappedminio / wrappedminio123)" + @echo "Admin: http://localhost:$(APP_PORT)/admin" + +down: + $(COMPOSE) down + +restart: + $(COMPOSE) restart app + +logs: + $(COMPOSE) logs -f app + +ps: + $(COMPOSE) ps + +shell: + $(COMPOSE) exec app sh + +migrate: + $(COMPOSE) exec app alembic upgrade head + +revision: + @test -n "$(m)" || (echo 'Usage: make revision m="message"' && exit 1) + $(COMPOSE) exec app alembic revision --autogenerate -m "$(m)" + +# Release: build image, tag as $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG), push to Docker Hub. +# External Postgres/MinIO: set DATABASE_URL and S3_* in runtime env / Helm values. +release: env + @echo "Building $(IMAGE_NAME):$(IMAGE_TAG)" + docker build -t $(IMAGE_NAME):$(IMAGE_TAG) . + docker tag $(IMAGE_NAME):$(IMAGE_TAG) $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) + @echo "Tagged $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)" + @if [ "$(PUSH)" = "1" ]; then \ + echo "Pushing $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)"; \ + docker push $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG); \ + else \ + echo "PUSH=0 — skipped docker push"; \ + fi + @$(MAKE) helm-package IMAGE_TAG=$(IMAGE_TAG) + +helm-lint: + helm lint ./helm/wrapped + +# Helm chart --version must be SemVer; non-semver IMAGE_TAG (e.g. latest) → 0.0.0- +helm-package: + @mkdir -p dist + @tag="$(IMAGE_TAG)"; \ + if echo "$$tag" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.+].*)?$$'; then \ + chart_ver="$$tag"; \ + else \ + chart_ver="0.0.0-$$tag"; \ + echo "IMAGE_TAG '$$tag' is not SemVer — helm chart version=$$chart_ver"; \ + fi; \ + helm package ./helm/wrapped --destination dist --version "$$chart_ver" --app-version "$$tag" + @echo "Helm chart in dist/" + +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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8f21bf --- /dev/null +++ b/README.md @@ -0,0 +1,445 @@ +# Wrapped + +Анонимный сервис одноразовой передачи зашифрованных данных (текст, изображения, файлы). Шифрование выполняется в браузере (Web Crypto), на сервере хранится только ciphertext. После первого успешного открытия пакет удаляется из хранилища. + +Возможности: + +- zero-knowledge шифрование на клиенте +- одноразовое открытие (unwrap) +- опциональный пароль (`client_only` или `server_gate`) +- CAPTCHA: Cloudflare Turnstile и/или hCaptcha +- админка с лимитами, TTL, allowlist MIME, audit-логом +- Docker Compose и Helm + +Ссылка для получателя: `/w/#` или токен `wrapped_v1..`. Ключ шифрования в URL-фрагменте (`#...`) на сервер не уходит. + +## Скриншоты + +Интерфейс на русском и английском, светлая и тёмная тема. + +### Создание wrap + +| Светлая тема | Тёмная тема | +|:------------:|:-----------:| +| ![Создание — светлая](docs/screenshots/create-light.png) | ![Создание — тёмная](docs/screenshots/create-dark.png) | + +### Создание с текстом и файлами + +Текст с подсветкой синтаксиса, вложения (изображения, документы), TTL и опциональный пароль. + +| Светлая тема | Тёмная тема | +|:------------:|:-----------:| +| ![С контентом — светлая](docs/screenshots/create-filled-light.png) | ![С контентом — тёмная](docs/screenshots/create-filled-dark.png) | + +### Токен выдан + +После создания — ссылка `/w/#` и `wrapped_v1`-токен. Ключ только во фрагменте URL. + +| Светлая тема | Тёмная тема | +|:------------:|:-----------:| +| ![Токен выдан — светлая](docs/screenshots/token-issued-light.png) | ![Токен выдан — тёмная](docs/screenshots/token-issued-dark.png) | + +### Unwrap (открытие) + +Одноразовое открытие: ciphertext удаляется на сервере, превью — только в текущей сессии браузера. + +| Светлая тема | Тёмная тема | +|:------------:|:-----------:| +| ![Unwrap — светлая](docs/screenshots/unwrap-light.png) | ![Unwrap — тёмная](docs/screenshots/unwrap-dark.png) | + +--- + +## Быстрый старт (разработка) + +Нужны Docker и Docker Compose. + +```bash +make env # создаёт .env из .env.example +make up # app + Postgres + MinIO + nginx +``` + +| Сервис | URL | +|--------|-----| +| Приложение | http://localhost:8000 | +| Админка | http://localhost:8000/admin | +| MinIO Console | http://localhost:9001 (`wrappedminio` / `wrappedminio123`) | +| Postgres (опционально с хоста) | `127.0.0.1:5433` | + +```bash +make logs # логи app +make down # остановить +make clean # остановить и удалить volumes +``` + +Локальный `docker-compose.yml` **собирает** образ из Dockerfile и монтирует код с `--reload` — это режим разработки. Для сервера используйте готовый образ с Docker Hub (см. ниже). + +--- + +## Запуск на сервере (Docker Hub + Compose) + +Образ приложения публикуется на [Docker Hub](https://hub.docker.com/). На сервере его **не нужно собирать** — достаточно `docker compose pull` / `up`. + +### 1. Подготовка + +Создайте каталог и файлы: + +```bash +mkdir -p /opt/wrapped && cd /opt/wrapped +``` + +Создайте `.env` (обязательно смените секреты): + +```env +# Приложение +APP_NAME=Wrapped +APP_ENV=production +APP_SECRET_KEY=замените-на-длинную-случайную-строку +APP_BASE_URL=https://wrapped.example.com +LOG_LEVEL=INFO +DOCS_ENABLED=false + +# Админ +ADMIN_USERNAME=admin +ADMIN_PASSWORD=замените-пароль + +# БД и S3 задаются в compose (см. ниже). Для внешнего Postgres/MinIO +# переопределите DATABASE_URL и S3_* здесь или в environment сервиса app. + +# CAPTCHA (опционально; site keys также можно задать в админке) +TURNSTILE_SECRET_KEY= +HCAPTCHA_SECRET_KEY= + +# Доверие к прокси для реального IP клиента +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 + +APP_PORT=8000 +``` + +### 2. `docker-compose.yml` для продакшена + +Образ на Docker Hub: `inecs/wrapped` (тег задайте нужный, например `0.1.0`). + +```yaml +services: + proxy: + image: nginx:1.27-alpine + container_name: wrapped-proxy + restart: unless-stopped + ports: + - "${APP_PORT:-8000}:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - app + + app: + # Образ с Docker Hub — не build: + image: inecs/wrapped:0.1.0 + container_name: wrapped-app + restart: unless-stopped + expose: + - "8000" + env_file: + - .env + environment: + DATABASE_URL: postgresql+asyncpg://wrapped:CHANGE_DB_PASSWORD@postgres:5432/wrapped + S3_ENDPOINT_URL: http://minio:9000 + S3_ACCESS_KEY: CHANGE_MINIO_USER + S3_SECRET_KEY: CHANGE_MINIO_PASSWORD + S3_BUCKET: wrapped + S3_USE_SSL: "false" + S3_CREATE_BUCKET: "true" + APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8000} + APP_ENV: production + DOCS_ENABLED: "false" + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16} + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_started + minio-init: + condition: service_completed_successfully + # Миграции и старт уже в CMD образа: + # alembic upgrade head && uvicorn ... + + postgres: + image: postgres:16-alpine + container_name: wrapped-postgres + restart: unless-stopped + environment: + POSTGRES_USER: wrapped + POSTGRES_PASSWORD: CHANGE_DB_PASSWORD + POSTGRES_DB: wrapped + volumes: + - wrapped_pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wrapped -d wrapped"] + interval: 5s + timeout: 5s + retries: 10 + + minio: + image: minio/minio:latest + container_name: wrapped-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: CHANGE_MINIO_USER + MINIO_ROOT_PASSWORD: CHANGE_MINIO_PASSWORD + volumes: + - wrapped_minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + + minio-init: + image: minio/mc:latest + container_name: wrapped-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 CHANGE_MINIO_USER CHANGE_MINIO_PASSWORD && + mc mb --ignore-existing local/wrapped && + exit 0 + " + +volumes: + wrapped_pg_data: + wrapped_minio_data: +``` + +Рядом положите `nginx.conf` (прокси на app): + +```nginx +server { + listen 80; + server_name _; + + client_max_body_size 64m; + + location / { + proxy_pass http://app:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 120s; + } +} +``` + +Готовый пример конфига также есть в репозитории: `deploy/nginx.conf`. + +### 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 +docker compose pull app +docker compose up -d app +``` + +HTTPS лучше завернуть снаружи (Caddy, Traefik, nginx на хосте, Cloudflare Tunnel) и проксировать на `${APP_PORT}`. В `APP_BASE_URL` укажите публичный `https://...` URL. + +### Внешний Postgres и S3/MinIO + +Если БД и объектное хранилище уже есть, уберите сервисы `postgres`, `minio`, `minio-init` из compose и задайте: + +| Переменная | Пример | +|------------|--------| +| `DATABASE_URL` | `postgresql+asyncpg://user:pass@db-host:5432/wrapped` | +| `S3_ENDPOINT_URL` | `https://s3.example.com` | +| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | ключи | +| `S3_BUCKET` | `wrapped` | +| `S3_USE_SSL` | `true` | +| `S3_CREATE_BUCKET` | `false` (если бакет уже создан) | +| `S3_REGION` | `us-east-1` | + +Минимальный compose в этом случае — только `app` (+ опционально `proxy`): + +```yaml +services: + app: + image: inecs/wrapped:0.1.0 + restart: unless-stopped + ports: + - "8000:8000" + env_file: + - .env + environment: + APP_ENV: production + DOCS_ENABLED: "false" +``` + +--- + +## Переменные окружения + +| Переменная | Описание | По умолчанию | +|------------|----------|--------------| +| `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` | +| `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_BUCKET` | Имя бакета | `wrapped` | +| `S3_REGION` | Регион | `us-east-1` | +| `S3_USE_SSL` | TLS к S3 | `false` | +| `S3_CREATE_BUCKET` | Создавать бакет при старте | `true` | +| `TURNSTILE_SECRET_KEY` | Секрет Turnstile | пусто | +| `HCAPTCHA_SECRET_KEY` | Секрет hCaptcha | пусто | +| `TRUSTED_PROXIES` | IP/CIDR прокси для `X-Forwarded-*` | loopback + RFC1918 | + +Полный шаблон: `.env.example`. + +--- + +## Модель безопасности + +- **Zero-knowledge.** Шифрование AES-GCM в браузере. Сервер видит только ciphertext и метаданные (TTL, MIME, размер). +- **Одноразовое открытие.** После успешного unwrap объект удаляется из S3/MinIO, статус wrap → `consumed`. +- **Пароль.** Режим `client_only` — пароль участвует в ключе на клиенте; `server_gate` — сервер проверяет хеш (Argon2) до выдачи ciphertext. +- **CAPTCHA.** Включается в админке; секреты — через env, site keys — в UI. +- **IP в audit.** Реальный IP берётся из заголовков только от `TRUSTED_PROXIES`. + +Ключ в `#fragment` не отправляется на сервер в HTTP-запросе страницы. + +--- + +## Админка + +- 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` + +--- + +## API (автоматизация) + +Тот же ZK-протокол, что и в UI: сначала шифруете локально, затем: + +| Метод | Путь | Назначение | +|-------|------|------------| +| `GET` | `/api/v1/settings` | Публичные лимиты, CAPTCHA, режим пароля | +| `POST` | `/api/v1/wraps` | Загрузить ciphertext + метаданные | +| `POST` | `/api/v1/wraps/{id}/unwrap` | Одноразово получить ciphertext | +| `GET` | `/health` | Healthcheck | + +Серверного endpoint «зашифруй за меня» нет. + +При `DOCS_ENABLED=true` доступны Swagger `/docs` и OpenAPI `/openapi.json`. + +--- + +## Сборка и публикация образа + +Нужен логин в Docker Hub: `docker login` (пользователь `inecs`). + +```bash +# Сборка + push на hub.docker.com как inecs/wrapped:0.1.0 +make release IMAGE_TAG=0.1.0 + +# Только собрать и затегать локально (без push) +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`. + +--- + +## Kubernetes (Helm) + +```bash +helm upgrade --install wrapped ./helm/wrapped \ + -f my-values.yaml +``` + +В `values.yaml` задайте образ с Hub, внешние DB/S3 и секреты: + +```yaml +image: + repository: inecs/wrapped + tag: "0.1.0" + pullPolicy: IfNotPresent + +app: + env: production + baseUrl: "https://wrapped.example.com" + secretKey: "..." + adminUsername: admin + adminPassword: "..." + docsEnabled: false + trustedProxies: "*" # если до пода достучаться может только Ingress + +external: + databaseUrl: "postgresql+asyncpg://..." + s3: + endpointUrl: "https://..." + accessKey: "..." + secretKey: "..." + bucket: wrapped + createBucket: false +``` + +Упаковка чарта: `make helm-package IMAGE_TAG=0.1.0`. + +--- + +## Make-цели (разработка) + +| Цель | Описание | +|------|----------| +| `make env` | `.env` из `.env.example` | +| `make up` | Поднять dev-стек | +| `make down` / `make clean` | Остановить / с volumes | +| `make logs` / `make ps` | Логи / статус | +| `make migrate` | `alembic upgrade head` | +| `make release` | Сборка образа (+ push) и Helm package | +| `make helm-lint` / `make helm-package` | Helm | + +--- + +## Требования + +- Python 3.12+ (для запуска без Docker) +- PostgreSQL 16+ +- S3-совместимое хранилище (MinIO и т.п.) +- Docker Compose v2 — для деплоя как выше + +--- + +## Лицензия + +Proprietary / на ваше усмотрение. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..cd64a16 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..c03ece9 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,59 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.config import get_settings +from app.db import Base +from app import models # noqa: F401 + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + import asyncio + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..e9c408e --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,19 @@ +# Template used by Alembic for new revisions. +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/001_initial.py b/alembic/versions/001_initial.py new file mode 100644 index 0000000..76adfec --- /dev/null +++ b/alembic/versions/001_initial.py @@ -0,0 +1,123 @@ +"""initial schema + +Revision ID: 001_initial +Revises: +Create Date: 2026-07-17 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + captcha_provider = postgresql.ENUM( + "off", "turnstile", "hcaptcha", name="captcha_provider", create_type=False + ) + password_mode = postgresql.ENUM( + "client_only", "server_gate", name="password_mode", create_type=False + ) + wrap_status = postgresql.ENUM( + "pending", "consumed", "expired", name="wrap_status", create_type=False + ) + + captcha_provider.create(op.get_bind(), checkfirst=True) + password_mode.create(op.get_bind(), checkfirst=True) + wrap_status.create(op.get_bind(), checkfirst=True) + + op.create_table( + "app_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("max_upload_bytes", sa.BigInteger(), nullable=False), + sa.Column("default_ttl_seconds", sa.Integer(), nullable=False), + sa.Column("max_ttl_seconds", sa.Integer(), nullable=False), + sa.Column("mime_allowlist", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("rate_limit_create_per_minute", sa.Integer(), nullable=False), + sa.Column("rate_limit_unwrap_per_minute", sa.Integer(), nullable=False), + sa.Column("rate_limit_admin_login_per_minute", sa.Integer(), nullable=False), + sa.Column("captcha_provider", captcha_provider, nullable=False), + sa.Column("turnstile_site_key", sa.String(length=256), nullable=False), + sa.Column("hcaptcha_site_key", sa.String(length=256), nullable=False), + sa.Column("password_mode", password_mode, nullable=False), + sa.Column("audit_retention_days", sa.Integer(), nullable=False), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "wraps", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("status", wrap_status, nullable=False), + sa.Column("object_key", sa.String(length=512), nullable=False), + sa.Column("size_bytes", sa.BigInteger(), nullable=False), + sa.Column("content_types", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("item_count", sa.Integer(), nullable=False), + sa.Column("has_password", sa.Boolean(), nullable=False), + sa.Column("password_hash", sa.Text(), nullable=True), + sa.Column("password_mode", password_mode, nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("creator_ip", sa.String(length=64), nullable=True), + sa.Column("creator_ua", sa.String(length=512), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("object_key"), + ) + op.create_index("ix_wraps_expires_at", "wraps", ["expires_at"]) + op.create_index("ix_wraps_status", "wraps", ["status"]) + op.create_index("ix_wraps_created_at", "wraps", ["created_at"]) + + op.create_table( + "audit_events", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("wrap_id", sa.String(length=32), nullable=True), + sa.Column("success", sa.Boolean(), nullable=False), + sa.Column("ip", sa.String(length=64), nullable=True), + sa.Column("user_agent", sa.String(length=512), nullable=True), + sa.Column("accept_language", sa.String(length=128), nullable=True), + sa.Column("forwarded_for", sa.String(length=256), nullable=True), + sa.Column("details", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_audit_created_at", "audit_events", ["created_at"]) + op.create_index("ix_audit_event_type", "audit_events", ["event_type"]) + op.create_index("ix_audit_events_wrap_id", "audit_events", ["wrap_id"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_events_wrap_id", table_name="audit_events") + op.drop_index("ix_audit_event_type", table_name="audit_events") + op.drop_index("ix_audit_created_at", table_name="audit_events") + op.drop_table("audit_events") + op.drop_index("ix_wraps_created_at", table_name="wraps") + op.drop_index("ix_wraps_status", table_name="wraps") + op.drop_index("ix_wraps_expires_at", table_name="wraps") + op.drop_table("wraps") + op.drop_table("app_settings") + op.execute("DROP TYPE IF EXISTS wrap_status") + op.execute("DROP TYPE IF EXISTS password_mode") + op.execute("DROP TYPE IF EXISTS captcha_provider") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..8926e90 --- /dev/null +++ b/app/api/admin.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.db import get_db +from app.models import CaptchaProvider, DEFAULT_MIME_ALLOWLIST, PasswordMode +from app.schemas import AdminSettingsUpdate +from app.services.admin_auth import ( + AdminAuth, + AdminWebAuth, + clear_admin_cookie, + get_admin_user, + set_admin_cookie, +) +from app.services.audit import ( + count_audit, + list_audit, + list_audit_event_types, + purge_old_audit, + write_audit, +) +from app.services.rate_limit import rate_limiter +from app.services.settings_service import PASSWORD_MODE_HELP, get_or_create_settings +from app.services.wraps import cleanup_expired_meta, expire_due_wraps, purge_all_wraps, request_meta + +router = APIRouter(prefix="/admin", include_in_schema=False) +api_router = APIRouter(prefix="/admin/api", tags=["Admin"]) +templates = Jinja2Templates(directory="app/templates") + + +@router.get("") +async def admin_root(request: Request): + if get_admin_user(request): + return RedirectResponse("/admin/settings", status_code=302) + return RedirectResponse("/admin/login", status_code=302) + + +@router.get("/login") +async def login_page(request: Request): + if get_admin_user(request): + return RedirectResponse("/admin/settings", status_code=302) + return templates.TemplateResponse( + request, + "admin_login.html", + {"title": "Admin login"}, + ) + + +@router.post("/login") +async def login_submit(request: Request, db: AsyncSession = Depends(get_db)): + settings = get_settings() + app_settings = await get_or_create_settings(db) + meta = request_meta(request) + ip = meta["ip"] or "unknown" + + if not rate_limiter.allow( + f"admin_login:{ip}", app_settings.rate_limit_admin_login_per_minute + ): + await write_audit( + db, + event_type="admin.login", + success=False, + **meta, + details={"reason": "rate_limited"}, + ) + return templates.TemplateResponse( + request, + "admin_login.html", + {"title": "Admin login", "error": "Too many attempts. Try later.", "error_key": "admin.login.error.rate"}, + status_code=429, + ) + + form = await request.form() + username = str(form.get("username") or "") + password = str(form.get("password") or "") + + if username == settings.admin_username and password == settings.admin_password: + purged_audit = await purge_old_audit(db, app_settings.audit_retention_days) + expired = await expire_due_wraps(db) + cleaned = await cleanup_expired_meta(db) + await write_audit( + db, + event_type="admin.login", + success=True, + **meta, + details={ + "purged_audit": purged_audit, + "expired_wraps": expired, + "cleaned_meta": cleaned, + }, + ) + response = RedirectResponse("/admin/settings", status_code=302) + set_admin_cookie(response, username) + return response + + await write_audit( + db, + event_type="admin.login", + success=False, + **meta, + details={"reason": "bad_credentials"}, + ) + return templates.TemplateResponse( + request, + "admin_login.html", + {"title": "Admin login", "error": "Invalid credentials", "error_key": "admin.login.error.bad"}, + status_code=401, + ) + + +@router.post("/logout") +async def logout(request: Request, db: AsyncSession = Depends(get_db)): + meta = request_meta(request) + if get_admin_user(request): + await write_audit(db, event_type="admin.logout", success=True, **meta) + response = RedirectResponse("/admin/login", status_code=302) + clear_admin_cookie(response) + return response + + +@router.get("/settings") +async def settings_page( + request: Request, + db: AsyncSession = Depends(get_db), + _admin: AdminWebAuth = ..., +): + row = await get_or_create_settings(db) + env = get_settings() + return templates.TemplateResponse( + request, + "admin_settings.html", + { + "title": "Settings", + "settings": row, + "password_mode_help": PASSWORD_MODE_HELP, + "mime_examples": DEFAULT_MIME_ALLOWLIST, + "turnstile_secret_set": bool(env.turnstile_secret_key), + "hcaptcha_secret_set": bool(env.hcaptcha_secret_key), + "captcha_providers": list(CaptchaProvider), + "password_modes": list(PasswordMode), + "active": "settings", + }, + ) + + +@router.post("/settings") +async def settings_save( + request: Request, + db: AsyncSession = Depends(get_db), + admin: AdminWebAuth = ..., +): + row = await get_or_create_settings(db) + form = await request.form() + meta = request_meta(request) + + def as_int(name: str, default: int) -> int: + raw = form.get(name) + try: + return int(str(raw)) + except Exception: + return default + + old = { + "max_upload_bytes": row.max_upload_bytes, + "default_ttl_seconds": row.default_ttl_seconds, + "max_ttl_seconds": row.max_ttl_seconds, + "captcha_provider": row.captcha_provider.value, + "password_mode": row.password_mode.value, + } + + row.max_upload_bytes = max(1, as_int("max_upload_mb", max(1, row.max_upload_bytes // 1048576))) * 1048576 + row.default_ttl_seconds = max(1, as_int("default_ttl_hours", max(1, row.default_ttl_seconds // 3600))) * 3600 + row.max_ttl_seconds = max(1, as_int("max_ttl_hours", max(1, row.max_ttl_seconds // 3600))) * 3600 + if row.default_ttl_seconds > row.max_ttl_seconds: + row.default_ttl_seconds = row.max_ttl_seconds + row.rate_limit_create_per_minute = as_int( + "rate_limit_create_per_minute", row.rate_limit_create_per_minute + ) + row.rate_limit_unwrap_per_minute = as_int( + "rate_limit_unwrap_per_minute", row.rate_limit_unwrap_per_minute + ) + row.rate_limit_admin_login_per_minute = as_int( + "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.turnstile_site_key = str(form.get("turnstile_site_key") or "").strip() + row.hcaptcha_site_key = str(form.get("hcaptcha_site_key") or "").strip() + + mime_raw = str(form.get("mime_allowlist") or "") + row.mime_allowlist = [line.strip() for line in mime_raw.splitlines() if line.strip()] + + captcha = str(form.get("captcha_provider") or "off") + try: + row.captcha_provider = CaptchaProvider(captcha) + except ValueError: + pass + + pwd_mode = str(form.get("password_mode") or "client_only") + try: + row.password_mode = PasswordMode(pwd_mode) + except ValueError: + pass + + await db.commit() + await write_audit( + db, + event_type="admin.settings_update", + success=True, + **meta, + details={"admin": admin, "old": old}, + ) + return RedirectResponse("/admin/settings?saved=1", status_code=302) + + +@router.post("/purge") +async def purge_all( + request: Request, + db: AsyncSession = Depends(get_db), + admin: AdminWebAuth = ..., +): + meta = request_meta(request) + form = await request.form() + confirm = str(form.get("confirm") or "").strip().upper() + if confirm != "PURGE": + await write_audit( + db, + event_type="admin.purge", + success=False, + **meta, + details={"admin": admin, "reason": "confirm_mismatch"}, + ) + return RedirectResponse("/admin/danger?purge=confirm", status_code=302) + + try: + stats = await purge_all_wraps(db) + await write_audit( + db, + event_type="admin.purge", + success=True, + **meta, + details={"admin": admin, **stats}, + ) + return RedirectResponse( + f"/admin/danger?purged=1&wraps={stats['wraps_deleted']}&objects={stats['objects_deleted']}", + status_code=302, + ) + except Exception as exc: + await write_audit( + db, + event_type="admin.purge", + success=False, + **meta, + details={"admin": admin, "reason": "error", "error": str(exc)[:200]}, + ) + return RedirectResponse("/admin/danger?purge=error", status_code=302) + + +@router.get("/danger") +async def danger_page( + request: Request, + _admin: AdminWebAuth = ..., +): + return templates.TemplateResponse( + request, + "admin_danger.html", + { + "title": "Danger zone", + "active": "danger", + }, + ) + + +@router.get("/audit") +async def audit_page( + request: Request, + db: AsyncSession = Depends(get_db), + _admin: AdminWebAuth = ..., +): + event_type = request.query_params.get("event_type") or None + wrap_id = request.query_params.get("wrap_id") or None + if event_type == "": + event_type = None + per_page = 50 + try: + page = max(1, int(request.query_params.get("page") or "1")) + except ValueError: + page = 1 + total = await count_audit(db, event_type=event_type, wrap_id=wrap_id or None) + pages = max(1, (total + per_page - 1) // per_page) if total else 1 + if page > pages: + page = pages + offset = (page - 1) * per_page + events = await list_audit( + db, + limit=per_page, + offset=offset, + event_type=event_type, + wrap_id=wrap_id or None, + ) + event_types = await list_audit_event_types(db) + from_idx = offset + 1 if total else 0 + to_idx = offset + len(events) + return templates.TemplateResponse( + request, + "admin_audit.html", + { + "title": "Audit", + "events": events, + "event_type": event_type or "", + "wrap_id": wrap_id or "", + "event_types": event_types, + "active": "audit", + "page": page, + "pages": pages, + "per_page": per_page, + "total": total, + "from_idx": from_idx, + "to_idx": to_idx, + "has_prev": page > 1, + "has_next": page < pages, + }, + ) + + +@api_router.get("/settings", summary="Get admin settings") +async def admin_settings_api( + db: AsyncSession = Depends(get_db), + _admin: AdminAuth = ..., +): + row = await get_or_create_settings(db) + return JSONResponse( + { + "max_upload_bytes": row.max_upload_bytes, + "default_ttl_seconds": row.default_ttl_seconds, + "max_ttl_seconds": row.max_ttl_seconds, + "mime_allowlist": row.mime_allowlist, + "captcha_provider": row.captcha_provider.value, + "password_mode": row.password_mode.value, + "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, + "rate_limit_admin_login_per_minute": row.rate_limit_admin_login_per_minute, + } + ) + + +@api_router.put("/settings", summary="Update admin settings") +async def admin_settings_put( + body: AdminSettingsUpdate, + request: Request, + db: AsyncSession = Depends(get_db), + admin: AdminAuth = ..., +): + row = await get_or_create_settings(db) + data = body.model_dump(exclude_unset=True) + if "captcha_provider" in data and data["captcha_provider"] is not None: + row.captcha_provider = CaptchaProvider(data.pop("captcha_provider")) + if "password_mode" in data and data["password_mode"] is not None: + row.password_mode = PasswordMode(data.pop("password_mode")) + for key, value in data.items(): + if value is not None and hasattr(row, key): + setattr(row, key, value) + await db.commit() + meta = request_meta(request) + await write_audit( + db, + event_type="admin.settings_update", + success=True, + **meta, + details={"admin": admin, "via": "api"}, + ) + return {"ok": True} + + +@api_router.post("/purge", summary="Force-delete all wraps and MinIO objects") +async def admin_purge_api( + request: Request, + db: AsyncSession = Depends(get_db), + admin: AdminAuth = ..., + confirm: str = "PURGE", +): + meta = request_meta(request) + if confirm.strip().upper() != "PURGE": + raise HTTPException(status_code=400, detail="confirm must be PURGE") + stats = await purge_all_wraps(db) + await write_audit( + db, + event_type="admin.purge", + success=True, + **meta, + details={"admin": admin, "via": "api", **stats}, + ) + return {"ok": True, **stats} + + +@api_router.get("/audit", summary="List audit events") +async def admin_audit_api( + db: AsyncSession = Depends(get_db), + _admin: AdminAuth = ..., + event_type: str | None = None, + wrap_id: str | None = None, + limit: int = 100, + offset: int = 0, +): + events = await list_audit( + db, + limit=min(limit, 500), + offset=max(0, offset), + event_type=event_type, + wrap_id=wrap_id, + ) + return [ + { + "id": str(e.id), + "created_at": e.created_at.isoformat() if e.created_at else None, + "event_type": e.event_type, + "wrap_id": e.wrap_id, + "success": e.success, + "ip": e.ip, + "user_agent": e.user_agent, + "details": e.details, + } + for e in events + ] diff --git a/app/api/wraps.py b/app/api/wraps.py new file mode 100644 index 0000000..b8227d3 --- /dev/null +++ b/app/api/wraps.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import base64 +from datetime import datetime, timedelta, timezone + +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.schemas import ( + PublicSettingsOut, + UnwrapRequest, + UnwrapResponse, + WrapCreateRequest, + WrapCreateResponse, +) +from app.services.audit import write_audit +from app.services.captcha import verify_captcha +from app.services.password_hash import hash_password, verify_password +from app.services.rate_limit import rate_limiter +from app.services.settings_service import get_or_create_settings, mime_allowed, public_settings_payload +from app.services.storage import storage +from app.services.wraps import delete_wrap_object, expire_due_wraps, new_wrap_id, request_meta + +router = APIRouter(prefix="/api/v1") + + +@router.get( + "/settings", + response_model=PublicSettingsOut, + tags=["Public"], + summary="Public client settings", +) +async def public_settings(db: AsyncSession = Depends(get_db)) -> PublicSettingsOut: + row = await get_or_create_settings(db) + return PublicSettingsOut(**public_settings_payload(row)) + + +@router.post( + "/wraps", + response_model=WrapCreateResponse, + tags=["Wraps"], + summary="Create encrypted wrap", +) +async def create_wrap( + body: WrapCreateRequest, + request: Request, + db: AsyncSession = Depends(get_db), +) -> WrapCreateResponse: + meta = request_meta(request) + settings = await get_or_create_settings(db) + ip = meta["ip"] or "unknown" + + if not rate_limiter.allow( + f"create:{ip}", settings.rate_limit_create_per_minute + ): + await write_audit( + db, + event_type="wrap.create", + success=False, + **meta, + details={"reason": "rate_limited"}, + ) + raise HTTPException(status_code=429, detail="rate_limited") + + ok, reason = await verify_captcha( + settings.captcha_provider, + body.captcha_token, + ip, + turnstile_site_configured=bool(settings.turnstile_site_key), + hcaptcha_site_configured=bool(settings.hcaptcha_site_key), + ) + if not ok: + await write_audit( + db, + event_type="wrap.create", + success=False, + **meta, + details={"reason": f"captcha_{reason}"}, + ) + raise HTTPException(status_code=400, detail="captcha_failed") + + if body.ttl_seconds > settings.max_ttl_seconds: + raise HTTPException(status_code=400, detail="ttl_too_large") + + try: + ciphertext = base64.b64decode(body.ciphertext_b64, validate=True) + except Exception as exc: + raise HTTPException(status_code=400, detail="invalid_ciphertext") from exc + + if len(ciphertext) > settings.max_upload_bytes: + await write_audit( + db, + event_type="wrap.create", + success=False, + **meta, + details={"reason": "too_large", "size": len(ciphertext)}, + ) + raise HTTPException(status_code=413, detail="too_large") + + allowlist = list(settings.mime_allowlist or []) + for ct in body.content_types: + if not mime_allowed(allowlist, ct): + await write_audit( + db, + event_type="wrap.create", + success=False, + **meta, + details={"reason": "mime_denied", "content_type": ct}, + ) + raise HTTPException(status_code=400, detail="mime_not_allowed") + + 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) + + wrap_id = new_wrap_id() + object_key = f"wraps/{wrap_id}.bin" + now = datetime.now(timezone.utc) + expires_at = now + timedelta(seconds=body.ttl_seconds) + + await storage.put_bytes(object_key, ciphertext) + + wrap = Wrap( + id=wrap_id, + status=WrapStatus.pending, + object_key=object_key, + size_bytes=len(ciphertext), + content_types=body.content_types, + item_count=body.item_count, + has_password=body.has_password, + password_hash=password_hash, + password_mode=settings.password_mode, + expires_at=expires_at, + creator_ip=ip, + creator_ua=(meta["user_agent"] or "")[:512] or None, + ) + db.add(wrap) + await db.commit() + + await write_audit( + db, + event_type="wrap.create", + success=True, + wrap_id=wrap_id, + **meta, + details={ + "size_bytes": len(ciphertext), + "item_count": body.item_count, + "content_types": body.content_types, + "ttl_seconds": body.ttl_seconds, + "has_password": body.has_password, + "password_mode": settings.password_mode.value, + }, + ) + + return WrapCreateResponse( + wrap_id=wrap_id, + expires_at=expires_at, + password_mode=settings.password_mode.value, + share_path=f"/w/{wrap_id}", + ) + + +@router.post( + "/wraps/{wrap_id}/unwrap", + response_model=UnwrapResponse, + tags=["Wraps"], + summary="One-time unwrap", +) +async def unwrap( + wrap_id: str, + body: UnwrapRequest, + request: Request, + db: AsyncSession = Depends(get_db), +) -> UnwrapResponse: + meta = request_meta(request) + settings = await get_or_create_settings(db) + ip = meta["ip"] or "unknown" + + if not rate_limiter.allow( + f"unwrap:{ip}", settings.rate_limit_unwrap_per_minute + ): + await write_audit( + db, + event_type="wrap.unwrap", + success=False, + wrap_id=wrap_id, + **meta, + details={"reason": "rate_limited"}, + ) + raise HTTPException(status_code=429, detail="rate_limited") + + ok, reason = await verify_captcha( + settings.captcha_provider, + body.captcha_token, + ip, + turnstile_site_configured=bool(settings.turnstile_site_key), + hcaptcha_site_configured=bool(settings.hcaptcha_site_key), + ) + if not ok: + await write_audit( + db, + event_type="wrap.unwrap", + success=False, + wrap_id=wrap_id, + **meta, + details={"reason": f"captcha_{reason}"}, + ) + raise HTTPException(status_code=400, detail="captcha_failed") + + await expire_due_wraps(db) + + from sqlalchemy import select + + result = await db.execute(select(Wrap).where(Wrap.id == wrap_id)) + wrap = result.scalar_one_or_none() + + # Uniform-ish failure for enumeration resistance + def fail(reason: str, status: int = 410) -> None: + raise HTTPException(status_code=status, detail=reason) + + if not wrap or wrap.status != WrapStatus.pending: + await write_audit( + db, + event_type="wrap.unwrap", + success=False, + wrap_id=wrap_id, + **meta, + details={"reason": "unavailable"}, + ) + fail("unavailable") + + assert wrap is not None + if wrap.expires_at <= datetime.now(timezone.utc): + wrap.status = WrapStatus.expired + 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": "expired"}, + ) + fail("unavailable") + + if ( + wrap.has_password + and wrap.password_mode == PasswordMode.server_gate + and wrap.password_hash + ): + if not body.password or not verify_password(wrap.password_hash, body.password): + await write_audit( + db, + event_type="wrap.unwrap", + success=False, + wrap_id=wrap_id, + **meta, + details={"reason": "bad_password"}, + ) + raise HTTPException(status_code=403, detail="bad_password") + + # Atomic consume + from sqlalchemy import update + + now = datetime.now(timezone.utc) + upd = await db.execute( + update(Wrap) + .where( + Wrap.id == wrap_id, + Wrap.status == WrapStatus.pending, + Wrap.expires_at > now, + ) + .values(status=WrapStatus.consumed, consumed_at=now) + .returning(Wrap.id) + ) + consumed = upd.scalar_one_or_none() + await db.commit() + if not consumed: + await write_audit( + db, + event_type="wrap.unwrap", + success=False, + wrap_id=wrap_id, + **meta, + details={"reason": "unavailable"}, + ) + fail("unavailable") + + try: + data = await storage.get_bytes(wrap.object_key) + except Exception: + await write_audit( + db, + event_type="wrap.unwrap", + success=False, + wrap_id=wrap_id, + **meta, + details={"reason": "storage_error"}, + ) + raise HTTPException(status_code=500, detail="storage_error") from None + finally: + await delete_wrap_object(db, wrap) + + await write_audit( + db, + event_type="wrap.unwrap", + success=True, + wrap_id=wrap_id, + **meta, + details={ + "size_bytes": wrap.size_bytes, + "item_count": wrap.item_count, + "content_types": wrap.content_types, + }, + ) + + return UnwrapResponse( + ciphertext_b64=base64.b64encode(data).decode("ascii"), + content_types=list(wrap.content_types or []), + item_count=wrap.item_count, + has_password=wrap.has_password, + password_mode=wrap.password_mode.value, + size_bytes=wrap.size_bytes, + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..0c4715b --- /dev/null +++ b/app/config.py @@ -0,0 +1,52 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + app_name: str = "Wrapped" + app_env: str = "development" + app_secret_key: str = "change-me" + app_base_url: str = "http://localhost:8000" + log_level: str = "INFO" + # Swagger / ReDoc / openapi.json. Default: on in development, off otherwise. + docs_enabled: bool | None = None + + admin_username: str = "admin" + admin_password: str = "38dmWjE1p" + + database_url: str = "postgresql+asyncpg://wrapped:wrapped@localhost:5432/wrapped" + + s3_endpoint_url: str = "http://localhost:9000" + s3_access_key: str = "wrappedminio" + s3_secret_key: str = "wrappedminio123" + s3_bucket: str = "wrapped" + s3_region: str = "us-east-1" + s3_use_ssl: bool = False + s3_create_bucket: bool = True + + turnstile_secret_key: str = "" + hcaptcha_secret_key: str = "" + + session_cookie_name: str = "wrapped_admin" + session_max_age: int = 60 * 60 * 12 + + # Comma-separated IPs/CIDRs allowed to set X-Forwarded-For / X-Real-IP. + # Use * to trust any peer (recommended in k8s when only Ingress reaches the pod). + # Default: loopback + RFC1918 + Docker Desktop gateway range. + trusted_proxies: str = ( + "127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,192.168.65.0/24" + ) + + @property + def is_docs_enabled(self) -> bool: + if self.docs_enabled is not None: + return self.docs_enabled + return self.app_env.lower() in {"development", "dev", "local"} + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..fab4d1e --- /dev/null +++ b/app/db.py @@ -0,0 +1,20 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.config import get_settings + + +class Base(DeclarativeBase): + pass + + +settings = get_settings() +engine = create_async_engine(settings.database_url, pool_pre_ping=True, echo=False) +SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with SessionLocal() as session: + yield session diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4617823 --- /dev/null +++ b/app/main.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, Request +from fastapi.openapi.utils import get_openapi +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware + +from app.api import admin, wraps +from app.config import get_settings +from app.db import SessionLocal +from app.services.settings_service import get_or_create_settings +from app.services.storage import storage + +OPENAPI_TAGS = [ + { + "name": "System", + "description": "Service health and runtime checks.", + }, + { + "name": "Public", + "description": "Anonymous client configuration (limits, captcha, password mode).", + }, + { + "name": "Wraps", + "description": "Zero-knowledge create / one-time unwrap of encrypted packages.", + }, + { + "name": "Admin", + "description": "Protected admin JSON API. Authorize with HTTP Basic (admin username/password from env).", + }, +] + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + await storage.ensure_bucket() + async with SessionLocal() as db: + await get_or_create_settings(db) + yield + + +def custom_openapi(app: FastAPI): + if app.openapi_schema: + return app.openapi_schema + schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + tags=OPENAPI_TAGS, + ) + schema.setdefault("components", {}).setdefault("securitySchemes", {}) + schema["components"]["securitySchemes"]["HTTPBasic"] = { + "type": "http", + "scheme": "basic", + "description": "Admin username and password from environment (ADMIN_USERNAME / ADMIN_PASSWORD).", + } + # Mark Admin-tagged operations as secured + for path_item in schema.get("paths", {}).values(): + for operation in path_item.values(): + if not isinstance(operation, dict): + continue + if "Admin" in (operation.get("tags") or []): + operation["security"] = [{"HTTPBasic": []}] + app.openapi_schema = schema + return app.openapi_schema + + +def create_app() -> FastAPI: + settings = get_settings() + docs_on = settings.is_docs_enabled + app = FastAPI( + title=settings.app_name, + version="0.1.0", + description=( + "Wrapped — zero-knowledge one-time encrypted drop.\n\n" + "Public wrap APIs are anonymous. Admin APIs require HTTP Basic." + ), + lifespan=lifespan, + openapi_tags=OPENAPI_TAGS if docs_on else None, + docs_url="/docs" if docs_on else None, + redoc_url="/redoc" if docs_on else None, + openapi_url="/openapi.json" if docs_on else None, + ) + + # Honor X-Forwarded-For / X-Real-IP from ingress / reverse proxies. + trusted = (settings.trusted_proxies or "").strip() or "*" + app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=trusted) + + app.mount("/static", StaticFiles(directory="app/static"), name="static") + app.include_router(wraps.router) + app.include_router(admin.router) + app.include_router(admin.api_router) + + templates = Jinja2Templates(directory="app/templates") + + @app.get("/health", tags=["System"], summary="Health check", include_in_schema=docs_on) + async def health(): + return {"status": "ok", "service": settings.app_name} + + @app.get("/", include_in_schema=False) + async def index(request: Request): + return templates.TemplateResponse( + request, + "create.html", + {"title": "Wrapped", "page": "create"}, + ) + + @app.get("/w/{wrap_id}", include_in_schema=False) + async def unwrap_page(request: Request, wrap_id: str): + return templates.TemplateResponse( + request, + "unwrap.html", + {"title": "Unwrap", "page": "unwrap", "wrap_id": wrap_id}, + ) + + @app.get("/unwrap", include_in_schema=False) + async def unwrap_token_page(request: Request): + return templates.TemplateResponse( + request, + "unwrap.html", + {"title": "Unwrap", "page": "unwrap", "wrap_id": ""}, + ) + + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + if ( + exc.status_code == 401 + and request.url.path.startswith("/admin") + and not request.url.path.startswith("/admin/api") + ): + return RedirectResponse("/admin/login", status_code=302) + from fastapi.responses import JSONResponse + + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + headers=exc.headers, + ) + + if docs_on: + app.openapi = lambda: custom_openapi(app) + return app + + +app = create_app() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..7b97835 --- /dev/null +++ b/app/models.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import enum +from datetime import datetime +from typing import Any +from uuid import uuid4 + +from sqlalchemy import ( + BigInteger, + Boolean, + DateTime, + Enum, + Index, + Integer, + String, + Text, + func, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class WrapStatus(str, enum.Enum): + pending = "pending" + consumed = "consumed" + expired = "expired" + + +class PasswordMode(str, enum.Enum): + client_only = "client_only" + server_gate = "server_gate" + + +class CaptchaProvider(str, enum.Enum): + off = "off" + turnstile = "turnstile" + hcaptcha = "hcaptcha" + + +class AppSettings(Base): + __tablename__ = "app_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + max_upload_bytes: Mapped[int] = mapped_column(BigInteger, default=25 * 1024 * 1024) + default_ttl_seconds: Mapped[int] = mapped_column(Integer, default=24 * 3600) + max_ttl_seconds: Mapped[int] = mapped_column(Integer, default=7 * 24 * 3600) + mime_allowlist: Mapped[list[Any]] = mapped_column( + JSONB, + default=list, + ) + rate_limit_create_per_minute: Mapped[int] = mapped_column(Integer, default=10) + rate_limit_unwrap_per_minute: Mapped[int] = mapped_column(Integer, default=30) + rate_limit_admin_login_per_minute: Mapped[int] = mapped_column(Integer, default=5) + captcha_provider: Mapped[CaptchaProvider] = mapped_column( + Enum(CaptchaProvider, name="captcha_provider"), + default=CaptchaProvider.off, + ) + turnstile_site_key: Mapped[str] = mapped_column(String(256), default="") + hcaptcha_site_key: Mapped[str] = mapped_column(String(256), default="") + password_mode: Mapped[PasswordMode] = mapped_column( + Enum(PasswordMode, name="password_mode"), + default=PasswordMode.client_only, + ) + 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() + ) + + +class Wrap(Base): + __tablename__ = "wraps" + __table_args__ = (Index("ix_wraps_expires_at", "expires_at"),) + + id: Mapped[str] = mapped_column(String(32), primary_key=True) + status: Mapped[WrapStatus] = mapped_column( + Enum(WrapStatus, name="wrap_status"), + default=WrapStatus.pending, + index=True, + ) + object_key: Mapped[str] = mapped_column(String(512), unique=True) + size_bytes: Mapped[int] = mapped_column(BigInteger) + content_types: Mapped[list[Any]] = mapped_column(JSONB, default=list) + 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_mode: Mapped[PasswordMode] = mapped_column( + Enum(PasswordMode, name="password_mode", create_constraint=False), + default=PasswordMode.client_only, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + creator_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + creator_ua: Mapped[str | None] = mapped_column(String(512), nullable=True) + + +class AuditEvent(Base): + __tablename__ = "audit_events" + __table_args__ = ( + Index("ix_audit_created_at", "created_at"), + Index("ix_audit_event_type", "event_type"), + ) + + id: Mapped[Any] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid4) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + event_type: Mapped[str] = mapped_column(String(64)) + wrap_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + success: Mapped[bool] = mapped_column(Boolean, default=True) + ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True) + accept_language: Mapped[str | None] = mapped_column(String(128), nullable=True) + forwarded_for: Mapped[str | None] = mapped_column(String(256), nullable=True) + details: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict) + + +DEFAULT_MIME_ALLOWLIST = [ + "image/*", + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", + "text/*", + "text/plain", + "text/markdown", + "text/csv", + "text/html", + "text/css", + "text/javascript", + "application/json", + "application/xml", + "application/pdf", + "application/zip", + "application/x-zip-compressed", + "application/gzip", + "application/x-gzip", + "application/x-tar", + "application/x-7z-compressed", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/msword", + "application/vnd.ms-excel", + "audio/*", + "video/*", +] diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..c52e44c --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class PublicSettingsOut(BaseModel): + max_upload_bytes: int + default_ttl_seconds: int + max_ttl_seconds: int + mime_allowlist: list[str] + captcha_provider: str + turnstile_site_key: str + hcaptcha_site_key: str + password_mode: str + password_mode_description: dict[str, str] + + +class WrapCreateRequest(BaseModel): + ciphertext_b64: str = Field(..., min_length=1) + ttl_seconds: int = Field(..., gt=0) + content_types: list[str] = Field(default_factory=list) + item_count: int = Field(default=1, ge=1, le=100) + has_password: bool = False + password: str | None = None + captcha_token: str | None = None + + +class WrapCreateResponse(BaseModel): + wrap_id: str + expires_at: datetime + password_mode: str + share_path: str + + +class UnwrapRequest(BaseModel): + password: str | None = None + captcha_token: str | None = None + + +class UnwrapResponse(BaseModel): + ciphertext_b64: str + content_types: list[str] + item_count: int + has_password: bool + password_mode: str + size_bytes: int + + +class AdminLoginRequest(BaseModel): + username: str + password: str + + +class AdminSettingsUpdate(BaseModel): + max_upload_bytes: int | None = None + default_ttl_seconds: int | None = None + max_ttl_seconds: int | None = None + mime_allowlist: list[str] | None = None + rate_limit_create_per_minute: int | None = None + rate_limit_unwrap_per_minute: int | None = None + rate_limit_admin_login_per_minute: int | None = None + captcha_provider: str | None = None + turnstile_site_key: str | None = None + hcaptcha_site_key: str | None = None + password_mode: str | None = None + audit_retention_days: int | None = None + + +class AuditEventOut(BaseModel): + id: str + created_at: datetime + event_type: str + wrap_id: str | None + success: bool + ip: str | None + user_agent: str | None + accept_language: str | None + forwarded_for: str | None + details: dict[str, Any] diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/admin_auth.py b/app/services/admin_auth.py new file mode 100644 index 0000000..51ab836 --- /dev/null +++ b/app/services/admin_auth.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import secrets +from typing import Annotated + +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from starlette.responses import Response + +from app.config import get_settings + +http_basic = HTTPBasic(auto_error=False) + + +def _serializer() -> URLSafeTimedSerializer: + settings = get_settings() + return URLSafeTimedSerializer(settings.app_secret_key, salt="wrapped-admin") + + +def create_session_token(username: str) -> str: + return _serializer().dumps({"u": username}) + + +def read_session_token(token: str, max_age: int) -> str | None: + try: + data = _serializer().loads(token, max_age=max_age) + return data.get("u") + except (BadSignature, SignatureExpired, Exception): + return None + + +def set_admin_cookie(response: Response, username: str) -> None: + settings = get_settings() + token = create_session_token(username) + response.set_cookie( + settings.session_cookie_name, + token, + httponly=True, + samesite="lax", + max_age=settings.session_max_age, + secure=settings.app_env == "production", + path="/", + ) + + +def clear_admin_cookie(response: Response) -> None: + settings = get_settings() + response.delete_cookie(settings.session_cookie_name, path="/") + + +def get_admin_user(request: Request) -> str | None: + settings = get_settings() + token = request.cookies.get(settings.session_cookie_name) + if not token: + return None + return read_session_token(token, settings.session_max_age) + + +def _basic_ok(credentials: HTTPBasicCredentials) -> bool: + settings = get_settings() + user_ok = secrets.compare_digest(credentials.username, settings.admin_username) + pass_ok = secrets.compare_digest(credentials.password, settings.admin_password) + return user_ok and pass_ok + + +async def require_admin_web(request: Request) -> str: + """Cookie-only auth for HTML admin pages (redirect via exception handler).""" + cookie_user = get_admin_user(request) + if cookie_user: + return cookie_user + raise HTTPException(status_code=401, detail="unauthorized") + + +async def require_admin_api( + request: Request, + credentials: Annotated[HTTPBasicCredentials | None, Depends(http_basic)] = None, +) -> str: + """Cookie or HTTP Basic — for JSON admin API / Swagger.""" + cookie_user = get_admin_user(request) + if cookie_user: + return cookie_user + if credentials and _basic_ok(credentials): + return credentials.username + raise HTTPException( + status_code=401, + detail="unauthorized", + headers={"WWW-Authenticate": "Basic"}, + ) + + +AdminWebAuth = Annotated[str, Depends(require_admin_web)] +AdminAuth = Annotated[str, Depends(require_admin_api)] diff --git a/app/services/audit.py b/app/services/audit.py new file mode 100644 index 0000000..dd74fc3 --- /dev/null +++ b/app/services/audit.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import AuditEvent + +KNOWN_AUDIT_EVENT_TYPES = [ + "wrap.create", + "wrap.unwrap", + "admin.login", + "admin.logout", + "admin.settings_update", + "admin.purge", +] + + +async def write_audit( + db: AsyncSession, + *, + event_type: str, + success: bool, + wrap_id: str | None = None, + ip: str | None = None, + user_agent: str | None = None, + accept_language: str | None = None, + forwarded_for: str | None = None, + details: dict[str, Any] | None = None, +) -> None: + event = AuditEvent( + event_type=event_type, + success=success, + wrap_id=wrap_id, + ip=ip, + user_agent=(user_agent or "")[:512] or None, + accept_language=(accept_language or "")[:128] or None, + forwarded_for=(forwarded_for or "")[:256] or None, + details=details or {}, + ) + db.add(event) + await db.commit() + + +async def purge_old_audit(db: AsyncSession, retention_days: int) -> int: + cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) + result = await db.execute(delete(AuditEvent).where(AuditEvent.created_at < cutoff)) + await db.commit() + return result.rowcount or 0 + + +def _audit_filters(event_type: str | None, wrap_id: str | None): + filters = [] + if event_type: + filters.append(AuditEvent.event_type == event_type) + if wrap_id: + filters.append(AuditEvent.wrap_id == wrap_id) + return filters + + +async def list_audit( + db: AsyncSession, + *, + limit: int = 100, + offset: int = 0, + event_type: str | None = None, + wrap_id: str | None = None, +) -> list[AuditEvent]: + stmt = select(AuditEvent).order_by(AuditEvent.created_at.desc()).offset(offset).limit(limit) + for f in _audit_filters(event_type, wrap_id): + stmt = stmt.where(f) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def count_audit( + db: AsyncSession, + *, + event_type: str | None = None, + wrap_id: str | None = None, +) -> int: + stmt = select(func.count()).select_from(AuditEvent) + for f in _audit_filters(event_type, wrap_id): + stmt = stmt.where(f) + result = await db.execute(stmt) + return int(result.scalar_one() or 0) + + +async def list_audit_event_types(db: AsyncSession) -> list[str]: + """Known types plus distinct values already stored in DB (dynamic dropdown).""" + result = await db.execute( + select(AuditEvent.event_type).distinct().order_by(AuditEvent.event_type) + ) + from_db = [row[0] for row in result.all() if row[0]] + return sorted(set(KNOWN_AUDIT_EVENT_TYPES) | set(from_db)) diff --git a/app/services/captcha.py b/app/services/captcha.py new file mode 100644 index 0000000..a562d93 --- /dev/null +++ b/app/services/captcha.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import httpx + +from app.config import get_settings +from app.models import CaptchaProvider + + +async def verify_captcha( + provider: CaptchaProvider, + token: str | None, + remote_ip: str | None, + *, + turnstile_site_configured: bool, + hcaptcha_site_configured: bool, +) -> tuple[bool, str]: + if provider == CaptchaProvider.off: + return True, "off" + + if not token: + return False, "missing_token" + + settings = get_settings() + + if provider == CaptchaProvider.turnstile: + if not turnstile_site_configured: + return False, "turnstile_not_configured" + secret = settings.turnstile_secret_key + if not secret: + return False, "turnstile_secret_missing" + return await _post_verify( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + {"secret": secret, "response": token, "remoteip": remote_ip or ""}, + ) + + if provider == CaptchaProvider.hcaptcha: + if not hcaptcha_site_configured: + return False, "hcaptcha_not_configured" + secret = settings.hcaptcha_secret_key + if not secret: + return False, "hcaptcha_secret_missing" + return await _post_verify( + "https://hcaptcha.com/siteverify", + {"secret": secret, "response": token, "remoteip": remote_ip or ""}, + ) + + return False, "unknown_provider" + + +async def _post_verify(url: str, data: dict) -> tuple[bool, str]: + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post(url, data=data) + payload = resp.json() + if payload.get("success"): + return True, "ok" + return False, "verify_failed" + except Exception: + return False, "verify_error" diff --git a/app/services/cleanup.py b/app/services/cleanup.py new file mode 100644 index 0000000..97d08fe --- /dev/null +++ b/app/services/cleanup.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Wrap, WrapStatus +from app.services.storage import storage + + +async def purge_expired_wraps(db: AsyncSession) -> int: + now = datetime.now(timezone.utc) + result = await db.execute( + select(Wrap).where( + and_( + Wrap.status == WrapStatus.pending, + Wrap.expires_at < now, + ) + ) + ) + wraps = list(result.scalars().all()) + count = 0 + for wrap in wraps: + try: + await storage.delete(wrap.object_key) + except Exception: + pass + wrap.status = WrapStatus.expired + count += 1 + if count: + await db.commit() + return count + + +async def cleanup_consumed_orphans(db: AsyncSession) -> int: + """Best-effort: mark very old pending with missing logic already handled elsewhere.""" + result = await db.execute( + select(Wrap).where( + or_( + Wrap.status == WrapStatus.consumed, + Wrap.status == WrapStatus.expired, + ) + ).limit(0) + ) + _ = result + return 0 diff --git a/app/services/client_ip.py b/app/services/client_ip.py new file mode 100644 index 0000000..3d00b41 --- /dev/null +++ b/app/services/client_ip.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import ipaddress +import re + +from fastapi import Request + +from app.config import get_settings + +_FORWARDED_FOR_RE = re.compile(r"for=(?:\"?\[?)([^;\"\\]\S*)", re.IGNORECASE) + + +def _parse_ip(value: str | None) -> str | None: + if not value: + return None + raw = value.strip().strip('"').strip() + if not raw or raw.lower() == "unknown": + return None + # [IPv6]:port or IPv4:port + if raw.startswith("["): + end = raw.find("]") + if end != -1: + raw = raw[1:end] + elif raw.count(":") == 1 and "." in raw: + raw = raw.rsplit(":", 1)[0] + try: + addr = ipaddress.ip_address(raw) + except ValueError: + return None + # Normalize IPv4-mapped IPv6 (::ffff:1.2.3.4 → 1.2.3.4) + if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped: + return str(addr.ipv4_mapped) + return str(addr) + + +def _peer_ip(request: Request) -> str | None: + if request.client and request.client.host: + return _parse_ip(request.client.host) or request.client.host + return None + + +def _trusted_proxy_nets() -> list[ipaddress.IPv4Network | ipaddress.IPv6Network] | None: + """None means trust any peer (TRUSTED_PROXIES=*).""" + raw = (get_settings().trusted_proxies or "").strip() + if not raw or raw == "*": + return None + nets: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [] + for part in raw.split(","): + part = part.strip() + if not part: + continue + try: + if "/" in part: + nets.append(ipaddress.ip_network(part, strict=False)) + else: + ip = ipaddress.ip_address(part) + nets.append(ipaddress.ip_network(f"{ip}/{ip.max_prefixlen}")) + except ValueError: + continue + return nets + + +def _is_trusted_peer(peer: str | None) -> bool: + if not peer: + return False + nets = _trusted_proxy_nets() + if nets is None: + return True + try: + addr = ipaddress.ip_address(peer) + except ValueError: + return False + return any(addr in net for net in nets) + + +def _ips_from_forwarded_for(header: str | None) -> list[str]: + if not header: + return [] + out: list[str] = [] + for part in header.split(","): + ip = _parse_ip(part) + if ip: + out.append(ip) + return out + + +def _ips_from_forwarded(header: str | None) -> list[str]: + if not header: + return [] + out: list[str] = [] + for match in _FORWARDED_FOR_RE.finditer(header): + ip = _parse_ip(match.group(1)) + if ip: + out.append(ip) + return out + + +def client_ip(request: Request) -> str | None: + """ + Resolve the original client IP. + + When the immediate peer is a trusted proxy, prefer (in order): + CF-Connecting-IP, True-Client-IP, X-Real-IP, left-most X-Forwarded-For, + RFC 7239 Forwarded. Otherwise use the TCP peer address. + """ + peer = _peer_ip(request) + if _is_trusted_peer(peer): + for header in ("cf-connecting-ip", "true-client-ip", "x-real-ip"): + ip = _parse_ip(request.headers.get(header)) + if ip: + return ip + chain = _ips_from_forwarded_for(request.headers.get("x-forwarded-for")) + if chain: + return chain[0] + chain = _ips_from_forwarded(request.headers.get("forwarded")) + if chain: + return chain[0] + return peer + + +def request_meta(request: Request) -> dict[str, str | None]: + forwarded = request.headers.get("x-forwarded-for") + real_ip = request.headers.get("x-real-ip") + cf_ip = request.headers.get("cf-connecting-ip") + forwarded_store = forwarded or real_ip or cf_ip + return { + "ip": client_ip(request), + "user_agent": request.headers.get("user-agent"), + "accept_language": request.headers.get("accept-language"), + "forwarded_for": (forwarded_store or "")[:256] or None, + } + + +def uvicorn_forwarded_allow_ips() -> str: + """Value for uvicorn --forwarded-allow-ips (CIDRs not supported → * or IP list).""" + raw = (get_settings().trusted_proxies or "").strip() + if not raw or raw == "*" or "/" in raw: + return "*" + return raw diff --git a/app/services/password_hash.py b/app/services/password_hash.py new file mode 100644 index 0000000..0ffde78 --- /dev/null +++ b/app/services/password_hash.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +_ph = PasswordHasher() + + +def hash_password(password: str) -> str: + return _ph.hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + try: + return _ph.verify(password_hash, password) + except VerifyMismatchError: + return False + except Exception: + return False diff --git a/app/services/rate_limit.py b/app/services/rate_limit.py new file mode 100644 index 0000000..b2433c0 --- /dev/null +++ b/app/services/rate_limit.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import time +from collections import defaultdict, deque +from threading import Lock + + +class RateLimiter: + """In-memory sliding window limiter (per process). Good enough for single-replica / dev.""" + + def __init__(self) -> None: + self._hits: dict[str, deque[float]] = defaultdict(deque) + self._lock = Lock() + + def allow(self, key: str, limit: int, window_seconds: int = 60) -> bool: + if limit <= 0: + return True + now = time.monotonic() + with self._lock: + q = self._hits[key] + while q and now - q[0] > window_seconds: + q.popleft() + if len(q) >= limit: + return False + q.append(now) + return True + + +rate_limiter = RateLimiter() diff --git a/app/services/settings_service.py b/app/services/settings_service.py new file mode 100644 index 0000000..bf5dfc5 --- /dev/null +++ b/app/services/settings_service.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import ( + DEFAULT_MIME_ALLOWLIST, + AppSettings, + CaptchaProvider, + PasswordMode, +) + + +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." + ), + "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." + ), +} + + +async def get_or_create_settings(db: AsyncSession) -> AppSettings: + result = await db.execute(select(AppSettings).where(AppSettings.id == 1)) + row = result.scalar_one_or_none() + if row: + return row + row = AppSettings( + id=1, + mime_allowlist=list(DEFAULT_MIME_ALLOWLIST), + captcha_provider=CaptchaProvider.off, + password_mode=PasswordMode.client_only, + ) + db.add(row) + await db.commit() + await db.refresh(row) + return row + + +def mime_allowed(allowlist: list[str], content_type: str) -> bool: + ct = (content_type or "").split(";")[0].strip().lower() + if not ct: + return False + for pattern in allowlist: + p = pattern.strip().lower() + if not p: + continue + if p.endswith("/*"): + if ct.startswith(p[:-1]): + return True + elif ct == p: + return True + return False + + +def public_settings_payload(row: AppSettings) -> dict: + return { + "max_upload_bytes": row.max_upload_bytes, + "default_ttl_seconds": row.default_ttl_seconds, + "max_ttl_seconds": row.max_ttl_seconds, + "mime_allowlist": list(row.mime_allowlist or []), + "captcha_provider": row.captcha_provider.value, + "turnstile_site_key": row.turnstile_site_key or "", + "hcaptcha_site_key": row.hcaptcha_site_key or "", + "password_mode": row.password_mode.value, + "password_mode_description": PASSWORD_MODE_HELP, + } diff --git a/app/services/storage.py b/app/services/storage.py new file mode 100644 index 0000000..415f58a --- /dev/null +++ b/app/services/storage.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator + +from aiobotocore.session import get_session + +from app.config import get_settings + + +class ObjectStorage: + def __init__(self) -> None: + self.settings = get_settings() + + def _client_kwargs(self) -> dict[str, Any]: + return { + "endpoint_url": self.settings.s3_endpoint_url, + "aws_access_key_id": self.settings.s3_access_key, + "aws_secret_access_key": self.settings.s3_secret_key, + "region_name": self.settings.s3_region, + "use_ssl": self.settings.s3_use_ssl, + } + + @asynccontextmanager + async def client(self) -> AsyncIterator[Any]: + session = get_session() + async with session.create_client("s3", **self._client_kwargs()) as client: + yield client + + async def ensure_bucket(self) -> None: + if not self.settings.s3_create_bucket: + return + async with self.client() as client: + try: + await client.head_bucket(Bucket=self.settings.s3_bucket) + except Exception: + await client.create_bucket(Bucket=self.settings.s3_bucket) + + async def put_bytes(self, key: str, data: bytes, content_type: str = "application/octet-stream") -> None: + async with self.client() as client: + await client.put_object( + Bucket=self.settings.s3_bucket, + Key=key, + Body=data, + ContentType=content_type, + ) + + async def get_bytes(self, key: str) -> bytes: + async with self.client() as client: + resp = await client.get_object(Bucket=self.settings.s3_bucket, Key=key) + async with resp["Body"] as stream: + return await stream.read() + + async def delete(self, key: str) -> None: + async with self.client() as client: + await client.delete_object(Bucket=self.settings.s3_bucket, Key=key) + + async def delete_prefix(self, prefix: str = "wraps/") -> int: + """Delete all objects under prefix. Returns number of deleted keys.""" + deleted = 0 + async with self.client() as client: + token: str | None = None + while True: + kwargs: dict[str, Any] = { + "Bucket": self.settings.s3_bucket, + "Prefix": prefix, + "MaxKeys": 1000, + } + if token: + kwargs["ContinuationToken"] = token + resp = await client.list_objects_v2(**kwargs) + contents = resp.get("Contents") or [] + if contents: + # delete_objects accepts up to 1000 keys + await client.delete_objects( + Bucket=self.settings.s3_bucket, + Delete={ + "Objects": [{"Key": obj["Key"]} for obj in contents], + "Quiet": True, + }, + ) + deleted += len(contents) + if not resp.get("IsTruncated"): + break + token = resp.get("NextContinuationToken") + return deleted + + +storage = ObjectStorage() diff --git a/app/services/util.py b/app/services/util.py new file mode 100644 index 0000000..1c0fbda --- /dev/null +++ b/app/services/util.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import secrets +from datetime import datetime, timedelta, timezone + +from app.services.client_ip import client_ip, request_meta + + +def new_wrap_id() -> str: + return secrets.token_urlsafe(18).replace("-", "").replace("_", "")[:24] + + +def expires_at_from_ttl(ttl_seconds: int) -> datetime: + return datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds) + + +__all__ = ["client_ip", "request_meta", "new_wrap_id", "expires_at_from_ttl"] diff --git a/app/services/wraps.py b/app/services/wraps.py new file mode 100644 index 0000000..ecb7739 --- /dev/null +++ b/app/services/wraps.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import secrets +from datetime import datetime, timezone + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Wrap, WrapStatus +from app.services.client_ip import client_ip, request_meta +from app.services.storage import storage + + +def new_wrap_id() -> str: + return secrets.token_urlsafe(18).replace("-", "").replace("_", "")[:24] + + +async def expire_due_wraps(db: AsyncSession) -> int: + now = datetime.now(timezone.utc) + result = await db.execute( + select(Wrap).where(Wrap.status == WrapStatus.pending, Wrap.expires_at <= now) + ) + rows = list(result.scalars().all()) + for wrap in rows: + try: + await storage.delete(wrap.object_key) + except Exception: + pass + wrap.status = WrapStatus.expired + if rows: + await db.commit() + return len(rows) + + +async def delete_wrap_object(db: AsyncSession, wrap: Wrap) -> None: + try: + await storage.delete(wrap.object_key) + except Exception: + pass + + +async def cleanup_expired_meta(db: AsyncSession) -> int: + """Remove long-expired wrap rows (objects already deleted).""" + now = datetime.now(timezone.utc) + result = await db.execute( + delete(Wrap).where( + Wrap.status.in_([WrapStatus.expired, WrapStatus.consumed]), + Wrap.expires_at < now, + ) + ) + await db.commit() + return result.rowcount or 0 + + +async def mark_consumed(db: AsyncSession, wrap_id: str) -> Wrap | None: + now = datetime.now(timezone.utc) + result = await db.execute( + update(Wrap) + .where(Wrap.id == wrap_id, Wrap.status == WrapStatus.pending, Wrap.expires_at > now) + .values(status=WrapStatus.consumed, consumed_at=now) + .returning(Wrap) + ) + row = result.scalar_one_or_none() + await db.commit() + return row + + +async def purge_all_wraps(db: AsyncSession) -> dict[str, int]: + """Force-delete all wrap DB rows and all objects under wraps/ in MinIO.""" + count_result = await db.execute(select(Wrap.id)) + db_count = len(list(count_result.scalars().all())) + await db.execute(delete(Wrap)) + await db.commit() + objects_deleted = await storage.delete_prefix("wraps/") + return {"wraps_deleted": db_count, "objects_deleted": objects_deleted} diff --git a/app/static/apple-touch-icon.png b/app/static/apple-touch-icon.png new file mode 100644 index 0000000..b4a1fa6 Binary files /dev/null and b/app/static/apple-touch-icon.png differ diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..cc0dd89 --- /dev/null +++ b/app/static/css/app.css @@ -0,0 +1,1047 @@ +:root { + --font-sans: "Outfit", sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, monospace; + --radius: 14px; + --gap: 1rem; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.35); +} + +html[data-theme="dark"] { + --bg0: #070b12; + --bg1: #0d1420; + --bg2: #121c2b; + --line: rgba(140, 170, 210, 0.14); + --text: #e8eef8; + --muted: #93a4bd; + --accent: #3dd6c6; + --accent-2: #6ea8ff; + --danger: #ff6b7a; + --ok: #5dde8a; + --warn: #f0c674; + --panel: rgba(13, 20, 32, 0.86); + --input: rgba(8, 12, 20, 0.9); + --grid: rgba(110, 168, 255, 0.05); + --btn-bg: #162233; + --btn-fg: #eef3fb; + --btn-border: rgba(160, 190, 230, 0.18); + --btn-hover-bg: #1c2b40; + --btn-primary-from: #1fa89c; + --btn-primary-mid: #2f8fd4; + --btn-primary-to: #4a74e8; + --btn-primary-fg: #f4f8ff; + --btn-primary-shadow: rgba(47, 143, 212, 0.28); + --btn-danger-from: #b91f35; + --btn-danger-to: #e25565; + --btn-danger-fg: #fff5f6; + --brand-mark-fg: #f5fafc; +} + +html[data-theme="light"] { + --bg0: #f5f8fc; + --bg1: #ffffff; + --bg2: #eef3fa; + --line: rgba(30, 60, 110, 0.12); + --text: #14233a; + --muted: #5d6f8a; + --accent: #1aa89c; + --accent-2: #4b86f0; + --danger: #e25565; + --ok: #1f9a4e; + --warn: #a87400; + --panel: rgba(255, 255, 255, 0.94); + --input: #ffffff; + --grid: rgba(75, 134, 240, 0.07); + --shadow: 0 16px 40px rgba(30, 60, 110, 0.1); + --btn-bg: #ffffff; + --btn-fg: #14233a; + --btn-border: rgba(30, 60, 110, 0.14); + --btn-hover-bg: #eef3fb; + --btn-primary-from: #b8f3ec; + --btn-primary-mid: #c8ddff; + --btn-primary-to: #d4e0ff; + --btn-primary-fg: #0d1b2e; + --btn-primary-shadow: rgba(75, 134, 240, 0.18); + --btn-danger-from: #ffe0e4; + --btn-danger-to: #ffd0d6; + --btn-danger-fg: #8a1526; + --brand-mark-fg: #0a1628; +} + +* { box-sizing: border-box; } +html, body { min-height: 100%; } +body { + margin: 0; + font-family: var(--font-sans); + color: var(--text); + background: radial-gradient(1200px 600px at 10% -10%, rgba(61, 214, 198, 0.12), transparent 50%), + radial-gradient(900px 500px at 100% 0%, rgba(110, 168, 255, 0.14), transparent 45%), + var(--bg0); + line-height: 1.5; +} +html[data-theme="light"] body { + background: radial-gradient(1100px 520px at 8% -8%, rgba(79, 214, 200, 0.18), transparent 52%), + radial-gradient(900px 480px at 100% 0%, rgba(109, 180, 255, 0.2), transparent 48%), + var(--bg0); +} + +.bg-grid { + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(var(--grid) 1px, transparent 1px), + linear-gradient(90deg, var(--grid) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(ellipse at center, black 30%, transparent 75%); + z-index: 0; +} + +.topbar, .shell, .site-footer, .admin-top, .admin-shell, .admin-login-shell { + position: relative; + z-index: 1; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.1rem 1.5rem; + max-width: 1100px; + margin: 0 auto; +} +.admin-top { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 0.75rem; + max-width: none; + width: 100%; + margin: 0; + box-sizing: border-box; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.7rem; + text-decoration: none; + color: var(--text); +} + +.brand-text { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} + +.brand-name { + font-weight: 700; + letter-spacing: 0.02em; + font-size: 1.25rem; + line-height: 1.1; +} + +.brand-tagline { + font-size: 0.72rem; + color: var(--muted); + font-weight: 500; + letter-spacing: 0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: min(52vw, 320px); +} + +.brand-mark { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: 10px; + background: linear-gradient(135deg, var(--btn-primary-from), var(--btn-primary-to)); + color: var(--brand-mark-fg); + box-shadow: 0 4px 14px var(--btn-primary-shadow); + animation: pulse 4s ease-in-out infinite; +} + +.brand-mark i { + font-size: 0.95rem; + line-height: 1; +} + +@keyframes pulse { + 0%, 100% { filter: saturate(1); transform: scale(1); } + 50% { filter: saturate(1.2); transform: scale(1.04); } +} + +.top-actions { display: flex; gap: 0.5rem; } + +.icon-btn { + min-width: 42px; + height: 42px; + border-radius: 12px; + border: 1px solid var(--btn-border); + background: var(--btn-bg); + color: var(--btn-fg); + cursor: pointer; + display: grid; + place-items: center; + backdrop-filter: blur(10px); + transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s, background 0.2s; +} +.icon-btn:hover { + border-color: var(--accent-2); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(59, 126, 240, 0.12); +} +html[data-theme="dark"] .icon-btn:hover { + box-shadow: none; +} +.icon { width: 18px; height: 18px; } +.hidden { display: none !important; } + +.shell { + max-width: 860px; + margin: 0 auto; + padding: 0.5rem 1.25rem 3rem; +} + +.hero-panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: calc(var(--radius) + 4px); + padding: 1.75rem; + box-shadow: var(--shadow); + backdrop-filter: blur(16px); + animation: rise 0.55s ease both; +} + +@keyframes rise { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: none; } +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.72rem; + color: var(--accent); + margin: 0 0 0.6rem; + font-weight: 600; +} + +.panel-head h1 { + margin: 0 0 0.55rem; + font-size: clamp(1.8rem, 4vw, 2.4rem); + letter-spacing: -0.03em; + line-height: 1.15; +} + +.lede { color: var(--muted); margin: 0 0 1.5rem; max-width: 42rem; } + +.composer, .success-panel, .result-panel { display: grid; gap: 0.9rem; } + +label { display: block; font-size: 0.86rem; color: var(--muted); margin-bottom: 0.35rem; } + +input, select, textarea, .code-input { + width: 100%; + border: 1px solid var(--line); + background: var(--input); + color: var(--text); + border-radius: 12px; + padding: 0.75rem 0.9rem; + font: inherit; +} + +.code-input, .mono, input.mono, pre.mono, code { + font-family: var(--font-mono); + font-size: 0.9rem; +} + +.code-input { + min-height: 220px; + resize: vertical; + line-height: 1.45; + tab-size: 2; +} + +.dropzone { + border: 1px dashed rgba(61, 214, 198, 0.45); + border-radius: 12px; + padding: 1.25rem; + text-align: center; + color: var(--muted); + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} +.dropzone:hover, .dropzone:focus, .dropzone.dragover { + background: rgba(61, 214, 198, 0.06); + border-color: var(--accent); + outline: none; +} + +.file-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.4rem; } +.file-list li { + display: flex; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.75rem; + border: 1px solid var(--line); + border-radius: 10px; + font-size: 0.9rem; +} +.file-list button { + border: 0; + background: transparent; + color: var(--danger); + cursor: pointer; +} + +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; } +.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem; } +@media (max-width: 720px) { + .grid-2, .grid-3 { grid-template-columns: 1fr; } +} + +.actions { display: flex; flex-wrap: wrap; gap: 0.7rem; align-items: center; } +.actions-center { justify-content: center; } + +.btn { + appearance: none; + border: 1px solid var(--btn-border); + background: var(--btn-bg); + color: var(--btn-fg); + border-radius: 12px; + padding: 0.7rem 1rem; + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.15s, border-color 0.15s, background 0.15s, box-shadow 0.15s, filter 0.15s; +} +.btn:hover { + transform: translateY(-1px); + border-color: var(--accent-2); + background: var(--btn-hover-bg); +} +.btn.primary { + background: linear-gradient(135deg, var(--btn-primary-from), var(--btn-primary-mid) 45%, var(--btn-primary-to)); + color: var(--btn-primary-fg); + border: 1px solid transparent; + box-shadow: 0 8px 22px var(--btn-primary-shadow); +} +html[data-theme="light"] .btn.primary { + border-color: rgba(30, 60, 110, 0.12); +} +html[data-theme="light"] .brand-mark { + border: 1px solid rgba(30, 60, 110, 0.12); +} +.btn.primary:hover { + filter: brightness(1.06); + background: linear-gradient(135deg, var(--btn-primary-from), var(--btn-primary-mid) 45%, var(--btn-primary-to)); + box-shadow: 0 10px 26px var(--btn-primary-shadow); +} +.btn.ghost { + background: transparent; + border-color: transparent; + color: var(--muted); +} +.btn.ghost:hover { + color: var(--text); + background: rgba(59, 126, 240, 0.08); + border-color: transparent; + box-shadow: none; +} +.btn.danger { + background: linear-gradient(135deg, var(--btn-danger-from), var(--btn-danger-to)); + color: var(--btn-danger-fg); + border: 1px solid transparent; + box-shadow: 0 8px 20px rgba(214, 69, 85, 0.18); +} +.btn.danger:hover { + filter: brightness(1.05); + background: linear-gradient(135deg, var(--btn-danger-from), var(--btn-danger-to)); +} + +.danger-zone { + border-color: rgba(255, 107, 122, 0.35); +} +.purge-form { + display: grid; + gap: 0.7rem; + max-width: 420px; +} + +.error { color: var(--danger); margin: 0; } +.ok { color: var(--ok); } +.warn { color: var(--warn); } + +.copy-row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; } + +.site-footer { + text-align: center; + color: var(--muted); + font-size: 0.85rem; + padding: 0.5rem 1.25rem 2.25rem; + max-width: 760px; + margin: 0 auto; +} +.footer-pillars { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.55rem 0.7rem; +} +.footer-pillars li { + display: inline-flex; + align-items: center; + gap: 0.55rem; + padding: 0.55rem 0.85rem; + border: 1px solid rgba(110, 168, 255, 0.22); + border-radius: 12px; + background: linear-gradient(160deg, rgba(110, 168, 255, 0.1), rgba(13, 20, 32, 0.35)); + color: var(--text); + text-align: left; + backdrop-filter: blur(8px); + max-width: 240px; +} +html[data-theme="light"] .footer-pillars li { + background: linear-gradient(160deg, rgba(47, 111, 237, 0.1), rgba(255, 255, 255, 0.85)); + border-color: rgba(47, 111, 237, 0.2); +} +.footer-pillars i { + color: var(--accent-2); + font-size: 0.95rem; + width: 1.1rem; + text-align: center; + flex-shrink: 0; +} +.footer-pillars span { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} +.footer-pillars strong { + font-size: 0.8rem; + font-weight: 650; + letter-spacing: 0.01em; + line-height: 1.2; +} +.footer-pillars small { + font-size: 0.7rem; + color: var(--muted); + font-weight: 500; + line-height: 1.25; +} +.footer-copy { + margin: 0.85rem 0 0; + font-size: 0.8rem; + color: var(--muted); +} +.footer-copy a { + color: var(--accent-2); + text-decoration: none; + font-weight: 600; +} +.footer-copy a:hover { + text-decoration: underline; +} + +.sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; clip: rect(0,0,0,0); border: 0; +} + +.input-with-action { + position: relative; + display: flex; + align-items: stretch; +} +.input-with-action > input { + width: 100%; + padding-right: 2.75rem; +} +.field-icon-btn { + position: absolute; + right: 0.35rem; + top: 50%; + transform: translateY(-50%); + width: 2.05rem; + height: 2.05rem; + border: none; + border-radius: 9px; + background: transparent; + color: var(--muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + transition: color 0.15s, background 0.15s; +} +.field-icon-btn:hover, +.field-icon-btn:focus-visible { + color: var(--accent-2); + background: rgba(75, 134, 240, 0.1); + outline: none; +} +.field-icon-btn:active { + transform: translateY(-50%) scale(0.96); +} +.ui-tooltip { + position: absolute; + right: 0; + bottom: calc(100% + 0.45rem); + z-index: 5; + min-width: max-content; + max-width: 220px; + padding: 0.4rem 0.55rem; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--panel); + color: var(--text); + font-size: 0.75rem; + font-weight: 500; + line-height: 1.3; + box-shadow: var(--shadow); + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(4px); + transition: opacity 0.15s, transform 0.15s, visibility 0.15s; + white-space: nowrap; +} +.ui-tooltip::after { + content: ""; + position: absolute; + top: 100%; + right: 0.7rem; + border: 5px solid transparent; + border-top-color: var(--line); +} +.field-icon-btn:hover .ui-tooltip, +.field-icon-btn:focus-visible .ui-tooltip { + opacity: 1; + visibility: visible; + transform: none; +} + +.items { display: grid; gap: 1rem; } +.item-card { + border: 1px solid var(--line); + border-radius: 12px; + padding: 1rem; + background: var(--input); +} +.item-card pre { + margin: 0.6rem 0 0; + overflow: auto; + max-height: 420px; + padding: 0.8rem; + border-radius: 8px; + background: rgba(0,0,0,0.25); +} +html[data-theme="light"] .item-card pre { background: rgba(16, 32, 56, 0.05); } +.item-card img { + max-width: 100%; + border-radius: 8px; + margin-top: 0.6rem; +} + +/* Admin */ +.admin-body { min-height: 100vh; } +.admin-login-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1rem; +} +.admin-card { + width: min(960px, 100%); +} +.admin-card { + display: grid; + gap: 0.7rem; + padding: 1.6rem; + border-radius: 16px; + border: 1px solid var(--line); + background: var(--panel); + box-shadow: var(--shadow); +} +.login-card { + width: min(320px, 100%); + gap: 0.55rem; + padding: 1rem 1.1rem 1.15rem; + border-radius: 14px; +} +.login-head { + display: flex; + align-items: center; + gap: 0.65rem; + margin-bottom: 0.25rem; +} +.login-head .brand-mark { + width: 30px; + height: 30px; +} +.login-head .brand-mark i { + font-size: 0.85rem; +} +.login-head .brand-name { + font-size: 1.05rem; + line-height: 1.1; +} +.login-sub { + font-size: 0.72rem; + color: var(--muted); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.login-card label { + margin-bottom: 0; + font-size: 0.78rem; +} +.login-card input { + margin-top: 0.25rem; + padding: 0.55rem 0.7rem; + font-size: 0.9rem; +} +.login-card .btn { + width: 100%; + margin-top: 0.2rem; + padding: 0.6rem 0.9rem; +} +.login-error { + margin: 0; + font-size: 0.82rem; +} +.admin-top-right { + display: flex; + align-items: center; + gap: 0.85rem; + flex-wrap: wrap; + justify-content: flex-end; +} +.admin-login-toggles { + position: fixed; + top: 1.1rem; + right: 1.5rem; + z-index: 2; +} +.admin-nav { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; + padding: 0.3rem; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); + backdrop-filter: blur(12px); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12); +} +.admin-nav-link { + display: inline-flex; + align-items: center; + gap: 0.45rem; + appearance: none; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + text-decoration: none; + font: inherit; + font-size: 0.86rem; + font-weight: 600; + padding: 0.5rem 0.8rem; + border-radius: 10px; + cursor: pointer; + transition: color 0.15s, background 0.15s, border-color 0.15s; +} +.admin-nav-link i { + font-size: 0.78rem; + opacity: 0.9; +} +.admin-nav-link:hover { + color: var(--text); + background: rgba(110, 168, 255, 0.08); +} +.admin-nav-link.active { + color: var(--text); + background: rgba(110, 168, 255, 0.16); + border-color: rgba(110, 168, 255, 0.28); +} +.admin-nav-danger:hover, +.admin-nav-danger.active { + color: var(--danger); + background: rgba(255, 107, 122, 0.1); + border-color: rgba(255, 107, 122, 0.28); +} +.admin-nav-logout:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.06); +} +html[data-theme="light"] .admin-nav { + box-shadow: 0 8px 24px rgba(20, 40, 70, 0.08); +} +.danger-page { + max-width: 520px; +} +.admin-form, .admin-section { display: grid; gap: 0.8rem; margin-bottom: 1.4rem; } +.admin-section { + padding: 1rem; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); +} +.admin-tabs { + display: grid; + gap: 1rem; +} +.admin-tablist { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0.35rem; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); +} +.admin-tab { + appearance: none; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + border-radius: 10px; + padding: 0.55rem 0.85rem; + font: inherit; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: color 0.15s, background 0.15s, border-color 0.15s; +} +.admin-tab:hover { + color: var(--text); + background: rgba(110, 168, 255, 0.08); +} +.admin-tab.active { + color: var(--text); + background: rgba(110, 168, 255, 0.14); + border-color: rgba(110, 168, 255, 0.28); +} +.admin-tab-danger.active { + color: var(--danger); + background: rgba(255, 107, 122, 0.1); + border-color: rgba(255, 107, 122, 0.28); +} +.admin-tabpanel { + display: none; + gap: 0.8rem; + padding: 1.1rem; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); +} +.admin-tabpanel.active { + display: grid; +} +.admin-tabpanel h2 { + margin: 0 0 0.2rem; + font-size: 1.15rem; +} +.admin-tab-actions { + display: flex; + justify-content: flex-start; + margin-top: 0.25rem; +} +.admin-form { + display: grid; + gap: 1rem; + margin-bottom: 0; +} + +.radio-block { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.7rem; + align-items: start; + padding: 0.7rem; + border: 1px solid var(--line); + border-radius: 10px; +} +.radio-block small { display: block; color: var(--muted); margin-top: 0.25rem; } +.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; } +.admin-shell { + margin: 0; + padding: 0 0.5rem 2rem; + width: 100%; + max-width: none; + box-sizing: border-box; +} +.admin-shell > h1 { + padding: 0 0.25rem; + margin: 0 0 0.85rem; +} +.audit-page { + display: grid; + gap: 0.85rem; +} +.audit-filters { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + align-items: end; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); +} +.audit-field { + display: grid; + gap: 0.3rem; + min-width: 180px; + margin: 0; +} +.audit-field-grow { + flex: 1 1 220px; + min-width: 200px; +} +.audit-field span { + font-size: 0.78rem; + color: var(--muted); + font-weight: 600; +} +.audit-field select, +.audit-field input { + margin: 0; +} +.audit-filter-btn { + height: 42px; + align-self: end; +} +.audit-meta { + display: flex; + gap: 0.4rem; + align-items: baseline; + color: var(--muted); + font-size: 0.85rem; + padding: 0 0.2rem; +} +.audit-count { + color: var(--accent-2); + font-weight: 700; + font-family: var(--font-mono); +} +.audit-table-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); +} +.audit-table { + width: 100%; + border-collapse: collapse; + font-size: 0.84rem; +} +.audit-table th, +.audit-table td { + border-bottom: 1px solid var(--line); + padding: 0.7rem 0.75rem; + text-align: left; + vertical-align: top; +} +.audit-table th { + position: sticky; + top: 0; + background: color-mix(in srgb, var(--bg2) 88%, transparent); + backdrop-filter: blur(8px); + color: var(--muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; + z-index: 1; +} +.audit-table tbody tr:hover { + background: rgba(110, 168, 255, 0.05); +} +.audit-row-fail { + background: rgba(255, 107, 122, 0.04); +} +.audit-event-pill { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.78rem; + padding: 0.2rem 0.5rem; + border-radius: 8px; + border: 1px solid rgba(110, 168, 255, 0.25); + background: rgba(110, 168, 255, 0.1); + color: var(--text); + white-space: nowrap; +} +.audit-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + font-weight: 700; + padding: 0.18rem 0.5rem; + border-radius: 999px; +} +.audit-status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + font-weight: 700; + padding: 0.18rem 0.5rem; + border-radius: 999px; +} +.audit-badge.ok, .audit-status.ok { + color: var(--ok); + background: rgba(93, 222, 138, 0.12); +} +.audit-badge.fail, .audit-status.fail { + color: var(--danger); + background: rgba(255, 107, 122, 0.12); +} +.audit-badge i, .audit-status i { + font-size: 0.7rem; +} +.audit-time { + white-space: nowrap; + font-size: 0.78rem; + color: var(--muted); +} +.audit-wrap { + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; +} +.audit-details code { + display: block; + max-width: 360px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.72rem; + line-height: 1.35; + color: var(--muted); + background: transparent; +} +.audit-empty { + text-align: center; + color: var(--muted); + padding: 2rem 1rem !important; +} +.audit-table .ua { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-top: 1.1rem; + flex-wrap: wrap; +} +.pagination .btn { + gap: 0.45rem; + padding: 0.55rem 0.9rem; + min-width: 7rem; +} +.pagination .btn[aria-disabled="true"], +.pagination .btn[disabled] { + opacity: 0.4; + pointer-events: none; + transform: none; + box-shadow: none; +} +.pagination-status { + color: var(--muted); + font-size: 0.92rem; + font-variant-numeric: tabular-nums; +} + +/* Confirm modal */ +body.modal-open { + overflow: hidden; +} +.modal-root { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 1.25rem; +} +.modal-root.hidden { + display: none; +} +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(8, 14, 24, 0.55); + backdrop-filter: blur(4px); +} +html[data-theme="light"] .modal-backdrop { + background: rgba(20, 35, 58, 0.35); +} +.modal-panel { + position: relative; + width: min(420px, 100%); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + box-shadow: var(--shadow); + padding: 1.35rem 1.4rem 1.25rem; + animation: modal-in 0.18s ease-out; +} +@keyframes modal-in { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: none; } +} +.modal-title { + margin: 0 0 0.55rem; + font-size: 1.15rem; + font-weight: 650; +} +.modal-message { + margin: 0 0 1.25rem; + color: var(--muted); + line-height: 1.45; +} +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + flex-wrap: wrap; +} +.filters { display: flex; flex-wrap: wrap; gap: 0.7rem; margin-bottom: 1rem; align-items: end; } +.table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 12px; } +.inline { display: inline; margin: 0; } +.admin-shell label > span { + display: block; + margin-bottom: 0.35rem; +} +.login-card label > span { + display: block; + margin-bottom: 0.25rem; +} diff --git a/app/static/favicon-32.png b/app/static/favicon-32.png new file mode 100644 index 0000000..444e464 Binary files /dev/null and b/app/static/favicon-32.png differ diff --git a/app/static/favicon.ico b/app/static/favicon.ico new file mode 100644 index 0000000..1b537fb Binary files /dev/null and b/app/static/favicon.ico differ diff --git a/app/static/favicon.svg b/app/static/favicon.svg new file mode 100644 index 0000000..04088f6 --- /dev/null +++ b/app/static/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/static/js/admin-audit.js b/app/static/js/admin-audit.js new file mode 100644 index 0000000..10c129c --- /dev/null +++ b/app/static/js/admin-audit.js @@ -0,0 +1,25 @@ +(() => { + const form = document.getElementById("audit-filter-form"); + const typeSelect = document.getElementById("audit-event-type"); + if (form && typeSelect) { + typeSelect.addEventListener("change", () => form.requestSubmit()); + } + + // Human-friendly local timestamps + document.querySelectorAll(".audit-time[data-ts]").forEach((el) => { + const raw = el.getAttribute("data-ts"); + if (!raw) return; + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return; + const locale = window.WrappedI18n?.locale?.() || undefined; + el.textContent = new Intl.DateTimeFormat(locale, { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(d); + el.title = raw; + }); +})(); diff --git a/app/static/js/admin-tabs.js b/app/static/js/admin-tabs.js new file mode 100644 index 0000000..7bf5eee --- /dev/null +++ b/app/static/js/admin-tabs.js @@ -0,0 +1,32 @@ +(() => { + const root = document.getElementById("admin-tabs"); + if (!root) return; + + const tabs = [...root.querySelectorAll(".admin-tab")]; + const panels = [...root.querySelectorAll(".admin-tabpanel")]; + + function activate(name) { + const id = name || "limits"; + tabs.forEach((tab) => { + const on = tab.dataset.tab === id; + tab.classList.toggle("active", on); + tab.setAttribute("aria-selected", on ? "true" : "false"); + }); + panels.forEach((panel) => { + const on = panel.dataset.panel === id; + panel.classList.toggle("active", on); + panel.hidden = !on; + }); + const url = new URL(location.href); + url.hash = id; + history.replaceState(null, "", url); + } + + tabs.forEach((tab) => { + tab.addEventListener("click", () => activate(tab.dataset.tab)); + }); + + const fromHash = (location.hash || "").replace(/^#/, ""); + const initial = tabs.some((t) => t.dataset.tab === fromHash) ? fromHash : "limits"; + activate(initial); +})(); diff --git a/app/static/js/create.js b/app/static/js/create.js new file mode 100644 index 0000000..1fedd54 --- /dev/null +++ b/app/static/js/create.js @@ -0,0 +1,304 @@ +(() => { + const t = (k) => window.WrappedI18n.t(k); + const files = []; + let settings = null; + let captchaWidgetId = null; + + const els = { + text: document.getElementById("payload-text"), + lang: document.getElementById("text-language"), + ttl: document.getElementById("ttl-seconds"), + password: document.getElementById("password"), + generatePassword: document.getElementById("generate-password"), + dropzone: document.getElementById("dropzone"), + fileInput: document.getElementById("file-input"), + fileList: document.getElementById("file-list"), + wrapBtn: document.getElementById("wrap-btn"), + error: document.getElementById("form-error"), + success: document.getElementById("success-panel"), + composer: document.querySelector(".composer"), + shareLink: document.getElementById("share-link"), + shareToken: document.getElementById("share-token"), + expiresMeta: document.getElementById("expires-meta"), + captchaSlot: document.getElementById("captcha-slot"), + }; + + function showError(msg) { + els.error.textContent = msg; + els.error.classList.remove("hidden"); + } + + function clearError() { + els.error.classList.add("hidden"); + els.error.textContent = ""; + } + + function mimeAllowed(ct) { + const allow = settings.mime_allowlist || []; + const base = (ct || "").split(";")[0].trim().toLowerCase(); + return allow.some((p) => { + const pat = p.toLowerCase(); + if (pat.endsWith("/*")) return base.startsWith(pat.slice(0, -1)); + return base === pat; + }); + } + + function renderFiles() { + els.fileList.innerHTML = ""; + files.forEach((f, idx) => { + const li = document.createElement("li"); + li.innerHTML = `${f.name} (${f.type || "file"} · ${f.size} B)`; + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "×"; + btn.addEventListener("click", () => { + files.splice(idx, 1); + renderFiles(); + }); + li.appendChild(btn); + els.fileList.appendChild(li); + }); + } + + function addFiles(list) { + for (const f of list) { + if (!mimeAllowed(f.type || "application/octet-stream")) { + showError(t("create.mimeDenied") + `: ${f.type || f.name}`); + continue; + } + files.push(f); + } + renderFiles(); + } + + let lastExpiresAt = null; + + function fillTtl() { + if (!settings || !els.ttl) return; + const max = settings.max_ttl_seconds; + const def = settings.default_ttl_seconds; + const selected = els.ttl.value ? Number(els.ttl.value) : def; + const options = [ + { key: "create.ttl.1h", value: 3600 }, + { key: "create.ttl.6h", value: 6 * 3600 }, + { key: "create.ttl.24h", value: 24 * 3600 }, + { key: "create.ttl.3d", value: 3 * 24 * 3600 }, + { key: "create.ttl.7d", value: 7 * 24 * 3600 }, + ].filter((o) => o.value <= max); + if (!options.find((o) => o.value === def) && def <= max) { + options.unshift({ key: "create.ttl.default", value: def, seconds: def }); + } + const pick = options.some((o) => o.value === selected) ? selected : def; + els.ttl.innerHTML = options + .map((o) => { + const label = + o.key === "create.ttl.default" + ? t("create.ttl.default", { seconds: o.seconds }) + : t(o.key); + return ``; + }) + .join(""); + } + + function refreshExpiresMeta() { + if (lastExpiresAt && els.expiresMeta) { + els.expiresMeta.textContent = window.WrappedI18n.formatExpires(lastExpiresAt); + } + } + + function loadCaptcha() { + els.captchaSlot.innerHTML = ""; + captchaWidgetId = null; + const provider = settings.captcha_provider; + if (provider === "turnstile" && settings.turnstile_site_key) { + const div = document.createElement("div"); + div.className = "cf-turnstile"; + div.dataset.sitekey = settings.turnstile_site_key; + els.captchaSlot.appendChild(div); + const s = document.createElement("script"); + s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; + s.async = true; + document.body.appendChild(s); + } else if (provider === "hcaptcha" && settings.hcaptcha_site_key) { + const div = document.createElement("div"); + div.className = "h-captcha"; + div.dataset.sitekey = settings.hcaptcha_site_key; + els.captchaSlot.appendChild(div); + const s = document.createElement("script"); + s.src = "https://js.hcaptcha.com/1/api.js"; + s.async = true; + document.body.appendChild(s); + } + } + + function captchaToken() { + if (settings.captcha_provider === "turnstile" && window.turnstile) { + return window.turnstile.getResponse?.() || ""; + } + if (settings.captcha_provider === "hcaptcha" && window.hcaptcha) { + return window.hcaptcha.getResponse?.(captchaWidgetId) || window.hcaptcha.getResponse?.() || ""; + } + return ""; + } + + async function init() { + const resp = await fetch("/api/v1/settings"); + settings = await resp.json(); + fillTtl(); + loadCaptcha(); + } + + els.dropzone.addEventListener("click", () => els.fileInput.click()); + els.fileInput.addEventListener("change", () => addFiles(els.fileInput.files)); + ["dragenter", "dragover"].forEach((ev) => { + els.dropzone.addEventListener(ev, (e) => { + e.preventDefault(); + els.dropzone.classList.add("dragover"); + }); + }); + ["dragleave", "drop"].forEach((ev) => { + els.dropzone.addEventListener(ev, (e) => { + e.preventDefault(); + els.dropzone.classList.remove("dragover"); + }); + }); + els.dropzone.addEventListener("drop", (e) => addFiles(e.dataTransfer.files)); + + document.addEventListener("paste", (e) => { + const items = e.clipboardData?.items; + if (!items) return; + const pasted = []; + for (const item of items) { + if (item.kind === "file") { + const f = item.getAsFile(); + if (f) pasted.push(f); + } + } + if (pasted.length) { + e.preventDefault(); + addFiles(pasted); + } + }); + + async function copyFrom(input, btn) { + await navigator.clipboard.writeText(input.value); + const old = btn.textContent; + btn.textContent = t("common.copied"); + setTimeout(() => (btn.textContent = old), 1200); + } + + document.getElementById("copy-link").addEventListener("click", () => { + copyFrom(els.shareLink, document.getElementById("copy-link")); + }); + document.getElementById("copy-token").addEventListener("click", () => { + copyFrom(els.shareToken, document.getElementById("copy-token")); + }); + document.getElementById("create-another").addEventListener("click", () => { + location.reload(); + }); + + els.wrapBtn.addEventListener("click", async () => { + clearError(); + const text = els.text.value; + if (!text.trim() && files.length === 0) { + showError(t("create.needPayload")); + return; + } + + const items = []; + const contentTypes = []; + if (text.trim()) { + items.push({ + type: "text", + language: els.lang.value, + content: text, + }); + contentTypes.push("text/plain"); + } + for (const f of files) { + const item = await window.WrappedCrypto.fileToItem(f); + items.push(item); + contentTypes.push(item.mime); + } + + for (const ct of contentTypes) { + if (!mimeAllowed(ct)) { + showError(t("create.mimeDenied")); + return; + } + } + + 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; + try { + 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, + captcha_token: captchaToken() || null, + }; + const resp = await fetch("/api/v1/wraps", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + showError(err.detail || t("common.error")); + return; + } + const data = await resp.json(); + const token = window.WrappedCrypto.buildToken(data.wrap_id, keyB64url); + const link = `${location.origin}${data.share_path}#${keyB64url}`; + els.shareLink.value = link; + els.shareToken.value = token; + lastExpiresAt = data.expires_at; + refreshExpiresMeta(); + els.composer.classList.add("hidden"); + els.success.classList.remove("hidden"); + } catch { + showError(t("common.error")); + } finally { + els.wrapBtn.disabled = false; + } + }); + + function generatePassword(length = 20) { + const alphabet = + "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*-_"; + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join(""); + } + + els.generatePassword?.addEventListener("click", () => { + const pwd = generatePassword(); + els.password.type = "text"; + els.password.value = pwd; + els.password.focus(); + els.password.select(); + }); + + if (window.WrappedI18n.onChange) { + window.WrappedI18n.onChange(() => { + fillTtl(); + refreshExpiresMeta(); + }); + } + + init().catch(() => showError(t("common.error"))); +})(); diff --git a/app/static/js/crypto.js b/app/static/js/crypto.js new file mode 100644 index 0000000..8b24576 --- /dev/null +++ b/app/static/js/crypto.js @@ -0,0 +1,199 @@ +(() => { + const te = new TextEncoder(); + const td = new TextDecoder(); + + function b64encode(buf) { + const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf; + let s = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(s); + } + + function b64decode(str) { + const bin = atob(str); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + + function b64urlEncode(buf) { + return b64encode(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + } + + function b64urlDecode(str) { + const pad = "=".repeat((4 - (str.length % 4)) % 4); + return b64decode(str.replace(/-/g, "+").replace(/_/g, "/") + pad); + } + + async function importAesKey(raw) { + return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [ + "encrypt", + "decrypt", + ]); + } + + async function derivePasswordKey(password, salt) { + const base = await crypto.subtle.importKey( + "raw", + te.encode(password), + "PBKDF2", + false, + ["deriveKey"] + ); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: 210000, hash: "SHA-256" }, + base, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + } + + async function encryptPackage(packageObj, password) { + const dek = crypto.getRandomValues(new Uint8Array(32)); + const payload = te.encode(JSON.stringify(packageObj)); + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const key = await importAesKey(dek); + const encrypted = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, payload) + ); + + let body = encrypted; + let bodyNonce = nonce; + let flags = 0; + let salt = new Uint8Array(16); + let outerNonce = new Uint8Array(12); + + if (password) { + flags = 1; + salt = crypto.getRandomValues(new Uint8Array(16)); + outerNonce = crypto.getRandomValues(new Uint8Array(12)); + const pwKey = await derivePasswordKey(password, salt); + const inner = new Uint8Array(nonce.length + encrypted.length); + inner.set(nonce, 0); + inner.set(encrypted, nonce.length); + body = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv: outerNonce }, pwKey, inner) + ); + bodyNonce = outerNonce; + } + + // header: magic(4) version(1) flags(1) salt(16) nonce(12) + ciphertext + const header = new Uint8Array(4 + 1 + 1 + 16 + 12); + header.set(te.encode("WRPD"), 0); + header[4] = 1; + header[5] = flags; + header.set(salt, 6); + header.set(bodyNonce, 22); + const out = new Uint8Array(header.length + body.length); + out.set(header, 0); + out.set(body, header.length); + + return { + ciphertext: out, + keyB64url: b64urlEncode(dek), + }; + } + + async function decryptPackage(ciphertext, keyB64url, password) { + if (ciphertext.length < 34) throw new Error("truncated"); + const magic = td.decode(ciphertext.subarray(0, 4)); + if (magic !== "WRPD") throw new Error("bad_magic"); + const flags = ciphertext[5]; + const salt = ciphertext.subarray(6, 22); + const nonce = ciphertext.subarray(22, 34); + const body = ciphertext.subarray(34); + const dek = b64urlDecode(keyB64url); + + let plainEncrypted; + let innerNonce; + + if (flags & 1) { + if (!password) throw new Error("password_required"); + const pwKey = await derivePasswordKey(password, salt); + let inner; + try { + inner = new Uint8Array( + await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, pwKey, body) + ); + } catch { + throw new Error("bad_password"); + } + innerNonce = inner.subarray(0, 12); + plainEncrypted = inner.subarray(12); + } else { + innerNonce = nonce; + plainEncrypted = body; + } + + const key = await importAesKey(dek); + let payload; + try { + payload = new Uint8Array( + await crypto.subtle.decrypt({ name: "AES-GCM", iv: innerNonce }, key, plainEncrypted) + ); + } catch { + throw new Error("decrypt_failed"); + } + return JSON.parse(td.decode(payload)); + } + + function buildToken(wrapId, keyB64url) { + return `wrapped_v1.${wrapId}.${keyB64url}`; + } + + function parseToken(input) { + const raw = (input || "").trim(); + if (!raw) return null; + if (raw.startsWith("wrapped_v1.")) { + const parts = raw.split("."); + if (parts.length < 3) return null; + return { wrapId: parts[1], key: parts.slice(2).join(".") }; + } + // bare id — key may come from location.hash + if (/^[A-Za-z0-9]+$/.test(raw) && raw.length >= 16) { + return { wrapId: raw, key: null }; + } + try { + const url = new URL(raw); + const m = url.pathname.match(/\/w\/([A-Za-z0-9]+)/); + if (m) { + return { wrapId: m[1], key: url.hash ? url.hash.slice(1) : null }; + } + } catch { + /* ignore */ + } + return null; + } + + function bytesToBase64(bytes) { + return b64encode(bytes); + } + + function base64ToBytes(b64) { + return b64decode(b64); + } + + async function fileToItem(file) { + const buf = new Uint8Array(await file.arrayBuffer()); + return { + type: "file", + name: file.name || "file", + mime: file.type || "application/octet-stream", + data_b64: b64encode(buf), + }; + } + + window.WrappedCrypto = { + encryptPackage, + decryptPackage, + buildToken, + parseToken, + bytesToBase64, + base64ToBytes, + fileToItem, + b64urlEncode, + }; +})(); diff --git a/app/static/js/i18n.js b/app/static/js/i18n.js new file mode 100644 index 0000000..759f447 --- /dev/null +++ b/app/static/js/i18n.js @@ -0,0 +1,422 @@ +(() => { + const KEY = "wrapped.lang"; + const dict = { + en: { + "footer.tagline": "Zero-knowledge · one-time · encrypted", + "footer.lede": "Wrap text, images or files into a one-time token. Encryption happens in your browser — the server never sees plaintext.", + "footer.cap.text": "Text", + "footer.cap.text.hint": "Secrets, configs, notes", + "footer.cap.media": "Images & files", + "footer.cap.media.hint": "Screenshots, archives, docs", + "footer.cap.browser": "Encrypted in-browser", + "footer.cap.browser.hint": "Only ciphertext reaches the server", + "footer.pillar.zk": "Zero-knowledge", + "footer.pillar.once": "One-time", + "footer.pillar.encrypted": "Encrypted", + "footer.pillar.zk.hint": "Server never sees content", + "footer.pillar.once.hint": "Gone after unwrap", + "footer.pillar.encrypted.hint": "Key lives only in your link", + "footer.copy.author": "Sergey Antropov", + "brand.tagline": "Wrap. Send. Vanish.", + "create.eyebrow": "Secure drop", + "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.text": "Text", + "create.textPlaceholder": "Paste secrets, configs, notes…", + "create.drop": "Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)", + "create.ttl": "Time to live", + "create.password": "Password (optional)", + "create.passwordPlaceholder": "Extra unlock secret", + "create.passwordGenerate": "Generate password", + "create.passwordGenerateTip": "Generate a strong password", + "create.submit": "Create wrapped token", + "create.openToken": "Open a token", + "create.successTitle": "Token issued", + "create.successWarn": "One-time unwrap. After someone opens it, ciphertext is destroyed on the server.", + "create.shareLink": "Share link", + "create.token": "Wrapped token", + "create.another": "Create another", + "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.ttl.1h": "1 hour", + "create.ttl.6h": "6 hours", + "create.ttl.24h": "24 hours", + "create.ttl.3d": "3 days", + "create.ttl.7d": "7 days", + "create.ttl.default": "Default ({seconds} s)", + "lang.plaintext": "Plain text", + "lang.markdown": "Markdown", + "lang.json": "JSON", + "lang.yaml": "YAML", + "lang.python": "Python", + "lang.javascript": "JavaScript", + "lang.typescript": "TypeScript", + "lang.go": "Go", + "lang.rust": "Rust", + "lang.sql": "SQL", + "lang.bash": "Bash", + "lang.html": "HTML", + "lang.css": "CSS", + "relative.inMinutes": "in {n} min", + "relative.inHours": "in {n} h", + "relative.inDays": "in {n} d", + "relative.soon": "soon", + "unwrap.eyebrow": "Unwrap", + "unwrap.title": "Reveal once", + "unwrap.lede": "Paste a wrapped token or open a share link. Ciphertext is fetched once and deleted from the server.", + "unwrap.token": "Wrapped token or ID", + "unwrap.tokenPlaceholder": "wrapped_v1.… or wrap id", + "unwrap.password": "Password (if set)", + "unwrap.submit": "Unwrap", + "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.unavailable": "Unavailable (already used, expired, or invalid).", + "common.copy": "Copy", + "common.copied": "Copied", + "common.download": "Download", + "common.error": "Something went wrong.", + "admin.nav.settings": "Settings", + "admin.nav.audit": "Audit", + "admin.nav.danger": "Danger", + "admin.nav.site": "Site", + "admin.nav.logout": "Logout", + "admin.brand.tagline": "Admin console", + "admin.settings.title": "Settings", + "admin.audit.title": "Audit", + "admin.tab.limits": "Limits", + "admin.tab.rate": "Rate limits", + "admin.tab.mime": "MIME", + "admin.tab.password": "Password", + "admin.tab.captcha": "CAPTCHA", + "admin.login.sub": "Admin", + "admin.login.username": "Username", + "admin.login.password": "Password", + "admin.login.submit": "Sign in", + "admin.login.error.rate": "Too many attempts. Try later.", + "admin.login.error.bad": "Invalid credentials", + "admin.flash.saved": "Settings saved.", + "admin.flash.purgedPrefix": "Purged", + "admin.flash.purgedWraps": "wrap(s) and", + "admin.flash.purgedObjects": "object(s).", + "admin.flash.purgeConfirm": "Type PURGE to confirm forced cleanup.", + "admin.flash.purgeError": "Purge failed. Check logs / MinIO connectivity.", + "admin.limits.title": "Limits", + "admin.limits.maxUpload": "Max upload (MB)", + "admin.limits.defaultTtl": "Default TTL (hours)", + "admin.limits.maxTtl": "Max TTL (hours)", + "admin.limits.auditRetention": "Audit retention (days)", + "admin.rate.title": "Rate limits (per IP / minute)", + "admin.rate.create": "Create", + "admin.rate.unwrap": "Unwrap", + "admin.rate.adminLogin": "Admin login", + "admin.mime.title": "MIME allowlist", + "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.captcha.title": "CAPTCHA", + "admin.captcha.provider": "Provider", + "admin.captcha.turnstileSite": "Turnstile site key", + "admin.captcha.hcaptchaSite": "hCaptcha site key", + "admin.captcha.secrets": "Secrets:", + "admin.captcha.set": "set", + "admin.captcha.missing": "missing", + "admin.captcha.viaEnv": "(via env TURNSTILE_SECRET_KEY / HCAPTCHA_SECRET_KEY)", + "admin.captcha.helperTitle": "How to get CAPTCHA tokens", + "admin.captcha.turnstile.1": "Open Cloudflare Turnstile dashboard", + "admin.captcha.turnstile.2": "Create a site widget for your domain (use localhost for dev)", + "admin.captcha.turnstile.3": "Copy Site Key into the field above", + "admin.captcha.turnstile.4": "Copy Secret Key into env TURNSTILE_SECRET_KEY and restart", + "admin.captcha.turnstile.5": "Set provider to turnstile", + "admin.captcha.hcaptcha.1": "Open hCaptcha dashboard", + "admin.captcha.hcaptcha.2": "New site → copy Sitekey here", + "admin.captcha.hcaptcha.3": "Secret into env HCAPTCHA_SECRET_KEY, restart", + "admin.captcha.hcaptcha.4": "Set provider to hcaptcha", + "admin.settings.save": "Save settings", + "admin.danger.title": "Danger zone", + "admin.danger.hint": "Force-delete all wraps from the database and all ciphertext objects in MinIO (wraps/). Audit log is kept. This cannot be undone.", + "admin.danger.confirmLabel": "Type PURGE to confirm", + "admin.danger.submit": "Clear all wraps & files", + "admin.danger.confirmDialog": "Delete ALL wraps and files? This cannot be undone.", + "admin.danger.confirmTitle": "Confirm purge", + "admin.danger.confirmOk": "Delete everything", + "modal.confirm.title": "Confirm", + "modal.confirm.ok": "Confirm", + "modal.confirm.cancel": "Cancel", + "admin.audit.eventType": "Event type", + "admin.audit.wrapId": "Wrap ID", + "admin.audit.filter": "Filter", + "admin.audit.allEvents": "All events", + "admin.audit.shown": "events shown", + "admin.audit.of": "of", + "admin.audit.events": "events", + "admin.audit.page": "Page", + "admin.audit.prev": "Prev", + "admin.audit.next": "Next", + "admin.audit.col.time": "Time", + "admin.audit.col.event": "Event", + "admin.audit.col.ok": "OK", + "admin.audit.col.wrap": "Wrap", + "admin.audit.col.details": "Details", + "admin.audit.yes": "yes", + "admin.audit.no": "no", + "admin.audit.empty": "No events", + }, + ru: { + "footer.tagline": "Zero-knowledge · одноразово · зашифровано", + "footer.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.", + "footer.cap.text": "Текст", + "footer.cap.text.hint": "Секреты, конфиги, заметки", + "footer.cap.media": "Картинки и файлы", + "footer.cap.media.hint": "Скриншоты, архивы, документы", + "footer.cap.browser": "Шифрование в браузере", + "footer.cap.browser.hint": "На сервер уходит только ciphertext", + "footer.pillar.zk": "Zero-knowledge", + "footer.pillar.once": "Одноразово", + "footer.pillar.encrypted": "Зашифровано", + "footer.pillar.zk.hint": "Сервер не видит содержимое", + "footer.pillar.once.hint": "После открытия — удаление", + "footer.pillar.encrypted.hint": "Ключ только в вашей ссылке", + "footer.copy.author": "Сергей Антропов", + "brand.tagline": "Упакуй. Отправь. Исчезни.", + "create.eyebrow": "Безопасная передача", + "create.title": "Упакуй. Отправь. Исчезни.", + "create.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.", + "create.language": "Язык подсветки", + "create.text": "Текст", + "create.textPlaceholder": "Вставь секреты, конфиги, заметки…", + "create.drop": "Перетащи файлы, выбери или вставь скриншот из буфера (⌘V / Ctrl+V)", + "create.ttl": "Время жизни", + "create.password": "Пароль (необязательно)", + "create.passwordPlaceholder": "Дополнительный секрет", + "create.passwordGenerate": "Сгенерировать пароль", + "create.passwordGenerateTip": "Сгенерировать надёжный пароль", + "create.submit": "Создать wrapped-токен", + "create.openToken": "Открыть токен", + "create.successTitle": "Токен выдан", + "create.successWarn": "Unwrap один раз. После открытия ciphertext удаляется на сервере.", + "create.shareLink": "Ссылка", + "create.token": "Wrapped-токен", + "create.another": "Создать ещё", + "create.needPayload": "Добавь текст или хотя бы один файл.", + "create.tooLarge": "Пакет превышает лимит размера.", + "create.mimeDenied": "Один или несколько MIME-типов запрещены.", + "create.expires": "Истекает {datetime} · {relative}", + "create.ttl.1h": "1 час", + "create.ttl.6h": "6 часов", + "create.ttl.24h": "24 часа", + "create.ttl.3d": "3 дня", + "create.ttl.7d": "7 дней", + "create.ttl.default": "По умолчанию ({seconds} с)", + "lang.plaintext": "Обычный текст", + "lang.markdown": "Markdown", + "lang.json": "JSON", + "lang.yaml": "YAML", + "lang.python": "Python", + "lang.javascript": "JavaScript", + "lang.typescript": "TypeScript", + "lang.go": "Go", + "lang.rust": "Rust", + "lang.sql": "SQL", + "lang.bash": "Bash", + "lang.html": "HTML", + "lang.css": "CSS", + "relative.inMinutes": "через {n} мин", + "relative.inHours": "через {n} ч", + "relative.inDays": "через {n} дн", + "relative.soon": "скоро", + "unwrap.eyebrow": "Unwrap", + "unwrap.title": "Открыть один раз", + "unwrap.lede": "Вставь wrapped-токен или открой ссылку. Ciphertext скачивается один раз и удаляется на сервере.", + "unwrap.token": "Wrapped-токен или ID", + "unwrap.tokenPlaceholder": "wrapped_v1.… или ID", + "unwrap.password": "Пароль (если задан)", + "unwrap.submit": "Расшифровать", + "unwrap.destroyed": "Копия на сервере уничтожена. Превью только в этой сессии браузера.", + "unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.", + "unwrap.badPassword": "Неверный пароль.", + "unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).", + "common.copy": "Копировать", + "common.copied": "Скопировано", + "common.download": "Скачать", + "common.error": "Что-то пошло не так.", + "admin.nav.settings": "Настройки", + "admin.nav.audit": "Аудит", + "admin.nav.danger": "Опасная зона", + "admin.nav.site": "Сайт", + "admin.nav.logout": "Выйти", + "admin.brand.tagline": "Консоль администратора", + "admin.settings.title": "Настройки", + "admin.audit.title": "Аудит", + "admin.tab.limits": "Лимиты", + "admin.tab.rate": "Rate limits", + "admin.tab.mime": "MIME", + "admin.tab.password": "Пароль", + "admin.tab.captcha": "CAPTCHA", + "admin.login.sub": "Админ", + "admin.login.username": "Логин", + "admin.login.password": "Пароль", + "admin.login.submit": "Войти", + "admin.login.error.rate": "Слишком много попыток. Попробуйте позже.", + "admin.login.error.bad": "Неверный логин или пароль", + "admin.flash.saved": "Настройки сохранены.", + "admin.flash.purgedPrefix": "Удалено", + "admin.flash.purgedWraps": "wrap(ов) и", + "admin.flash.purgedObjects": "объект(ов).", + "admin.flash.purgeConfirm": "Введите PURGE для подтверждения очистки.", + "admin.flash.purgeError": "Очистка не удалась. Проверьте логи / MinIO.", + "admin.limits.title": "Лимиты", + "admin.limits.maxUpload": "Макс. размер (МБ)", + "admin.limits.defaultTtl": "TTL по умолчанию (часы)", + "admin.limits.maxTtl": "Макс. TTL (часы)", + "admin.limits.auditRetention": "Хранение аудита (дни)", + "admin.rate.title": "Rate limits (на IP / минуту)", + "admin.rate.create": "Создание", + "admin.rate.unwrap": "Unwrap", + "admin.rate.adminLogin": "Вход в админку", + "admin.mime.title": "MIME allowlist", + "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.captcha.title": "CAPTCHA", + "admin.captcha.provider": "Провайдер", + "admin.captcha.turnstileSite": "Turnstile site key", + "admin.captcha.hcaptchaSite": "hCaptcha site key", + "admin.captcha.secrets": "Секреты:", + "admin.captcha.set": "задан", + "admin.captcha.missing": "нет", + "admin.captcha.viaEnv": "(через env TURNSTILE_SECRET_KEY / HCAPTCHA_SECRET_KEY)", + "admin.captcha.helperTitle": "Как получить токены CAPTCHA", + "admin.captcha.turnstile.1": "Откройте Cloudflare Turnstile dashboard", + "admin.captcha.turnstile.2": "Создайте виджет для домена (для dev — localhost)", + "admin.captcha.turnstile.3": "Скопируйте Site Key в поле выше", + "admin.captcha.turnstile.4": "Secret Key в env TURNSTILE_SECRET_KEY и перезапустите", + "admin.captcha.turnstile.5": "Выберите provider = turnstile", + "admin.captcha.hcaptcha.1": "Откройте hCaptcha dashboard", + "admin.captcha.hcaptcha.2": "Новый сайт → Sitekey сюда", + "admin.captcha.hcaptcha.3": "Secret в env HCAPTCHA_SECRET_KEY, перезапуск", + "admin.captcha.hcaptcha.4": "Выберите provider = hcaptcha", + "admin.settings.save": "Сохранить", + "admin.danger.title": "Опасная зона", + "admin.danger.hint": "Принудительно удаляет все wraps из БД и все ciphertext-объекты в MinIO (wraps/). Аудит сохраняется. Отменить нельзя.", + "admin.danger.confirmLabel": "Введите PURGE для подтверждения", + "admin.danger.submit": "Очистить все wraps и файлы", + "admin.danger.confirmDialog": "Удалить ВСЕ wraps и файлы? Это нельзя отменить.", + "admin.danger.confirmTitle": "Подтверждение очистки", + "admin.danger.confirmOk": "Удалить всё", + "modal.confirm.title": "Подтверждение", + "modal.confirm.ok": "Подтвердить", + "modal.confirm.cancel": "Отмена", + "admin.audit.eventType": "Тип события", + "admin.audit.wrapId": "Wrap ID", + "admin.audit.filter": "Фильтр", + "admin.audit.allEvents": "Все события", + "admin.audit.shown": "событий показано", + "admin.audit.of": "из", + "admin.audit.events": "событий", + "admin.audit.page": "Страница", + "admin.audit.prev": "Назад", + "admin.audit.next": "Далее", + "admin.audit.col.time": "Время", + "admin.audit.col.event": "Событие", + "admin.audit.col.ok": "OK", + "admin.audit.col.wrap": "Wrap", + "admin.audit.col.details": "Детали", + "admin.audit.yes": "да", + "admin.audit.no": "нет", + "admin.audit.empty": "Нет событий", + }, + }; + + const listeners = []; + + function current() { + const stored = localStorage.getItem(KEY); + if (stored === "en" || stored === "ru") return stored; + return "ru"; + } + + function t(key, vars) { + const lang = current(); + let text = (dict[lang] && dict[lang][key]) || dict.en[key] || key; + if (vars) { + Object.entries(vars).forEach(([k, v]) => { + text = text.replaceAll(`{${k}}`, String(v)); + }); + } + return text; + } + + function locale() { + return current() === "ru" ? "ru-RU" : "en-GB"; + } + + function formatExpires(iso) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + const datetime = new Intl.DateTimeFormat(locale(), { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(d); + const diffMs = d.getTime() - Date.now(); + let relative = t("relative.soon"); + if (diffMs > 0) { + const mins = Math.round(diffMs / 60000); + if (mins < 60) relative = t("relative.inMinutes", { n: Math.max(1, mins) }); + 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 }); + } + + function apply() { + const lang = current(); + document.documentElement.lang = lang; + const label = document.getElementById("lang-label"); + if (label) label.textContent = lang.toUpperCase(); + document.querySelectorAll("[data-i18n]").forEach((el) => { + el.textContent = t(el.getAttribute("data-i18n")); + }); + document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { + el.setAttribute("placeholder", t(el.getAttribute("data-i18n-placeholder"))); + }); + document.querySelectorAll("[data-i18n-tooltip]").forEach((el) => { + el.textContent = t(el.getAttribute("data-i18n-tooltip")); + }); + listeners.forEach((fn) => { + try { + fn(lang); + } catch { + /* ignore */ + } + }); + } + + function onChange(fn) { + listeners.push(fn); + } + + function toggle() { + const next = current() === "en" ? "ru" : "en"; + localStorage.setItem(KEY, next); + apply(); + } + + document.addEventListener("DOMContentLoaded", () => { + apply(); + const btn = document.getElementById("lang-toggle"); + if (btn) btn.addEventListener("click", toggle); + }); + + window.WrappedI18n = { t, apply, current, locale, formatExpires, onChange }; +})(); diff --git a/app/static/js/modal.js b/app/static/js/modal.js new file mode 100644 index 0000000..a7ead9d --- /dev/null +++ b/app/static/js/modal.js @@ -0,0 +1,86 @@ +(() => { + let root = null; + let resolveFn = null; + + function ensure() { + if (root) return root; + root = document.createElement("div"); + root.id = "confirm-modal"; + root.className = "modal-root hidden"; + root.setAttribute("role", "dialog"); + root.setAttribute("aria-modal", "true"); + root.innerHTML = ` + + + `; + document.body.appendChild(root); + + root.addEventListener("click", (e) => { + const t = e.target; + if (!(t instanceof Element)) return; + if (t.closest("[data-modal-cancel]")) finish(false); + if (t.closest("[data-modal-ok]")) finish(true); + }); + + document.addEventListener("keydown", (e) => { + if (!root || root.classList.contains("hidden")) return; + if (e.key === "Escape") { + e.preventDefault(); + finish(false); + } + }); + + return root; + } + + function finish(ok) { + if (!root || root.classList.contains("hidden")) return; + root.classList.add("hidden"); + document.body.classList.remove("modal-open"); + const fn = resolveFn; + resolveFn = null; + if (fn) fn(ok); + } + + function t(key, fallback) { + return window.WrappedI18n?.t(key) || fallback; + } + + /** + * @param {{ title?: string, message: string, confirmLabel?: string, cancelLabel?: string, danger?: boolean }} opts + * @returns {Promise} + */ + function confirm(opts) { + const el = ensure(); + const title = opts.title || t("modal.confirm.title", "Confirm"); + const message = opts.message || ""; + const confirmLabel = opts.confirmLabel || t("modal.confirm.ok", "Confirm"); + const cancelLabel = opts.cancelLabel || t("modal.confirm.cancel", "Cancel"); + const danger = opts.danger !== false; + + el.querySelector("#confirm-modal-title").textContent = title; + el.querySelector("#confirm-modal-message").textContent = message; + const okBtn = el.querySelector("#confirm-modal-ok"); + const cancelBtn = el.querySelector("#confirm-modal-cancel"); + okBtn.textContent = confirmLabel; + cancelBtn.textContent = cancelLabel; + okBtn.className = danger ? "btn danger" : "btn primary"; + + el.classList.remove("hidden"); + document.body.classList.add("modal-open"); + okBtn.focus(); + + return new Promise((resolve) => { + resolveFn = resolve; + }); + } + + window.WrappedModal = { confirm }; +})(); diff --git a/app/static/js/theme.js b/app/static/js/theme.js new file mode 100644 index 0000000..2aabd44 --- /dev/null +++ b/app/static/js/theme.js @@ -0,0 +1,30 @@ +(() => { + const KEY = "wrapped.theme"; + + function preferred() { + return localStorage.getItem(KEY) || "dark"; + } + + function apply(theme) { + document.documentElement.setAttribute("data-theme", theme); + const sun = document.getElementById("theme-icon-sun"); + const moon = document.getElementById("theme-icon-moon"); + if (sun && moon) { + sun.classList.toggle("hidden", theme !== "light"); + moon.classList.toggle("hidden", theme !== "dark"); + } + } + + function toggle() { + const next = preferred() === "dark" ? "light" : "dark"; + localStorage.setItem(KEY, next); + apply(next); + } + + apply(preferred()); + document.addEventListener("DOMContentLoaded", () => { + apply(preferred()); + const btn = document.getElementById("theme-toggle"); + if (btn) btn.addEventListener("click", toggle); + }); +})(); diff --git a/app/static/js/unwrap.js b/app/static/js/unwrap.js new file mode 100644 index 0000000..f86fb32 --- /dev/null +++ b/app/static/js/unwrap.js @@ -0,0 +1,188 @@ +(() => { + const t = (k) => window.WrappedI18n.t(k); + let settings = null; + + const els = { + token: document.getElementById("token-input"), + password: document.getElementById("unwrap-password"), + btn: document.getElementById("unwrap-btn"), + error: document.getElementById("form-error"), + form: document.getElementById("unwrap-form"), + result: document.getElementById("result-panel"), + items: document.getElementById("items"), + captchaSlot: document.getElementById("captcha-slot"), + }; + + function showError(msg) { + els.error.textContent = msg; + els.error.classList.remove("hidden"); + } + + function clearError() { + els.error.classList.add("hidden"); + } + + function loadCaptcha() { + els.captchaSlot.innerHTML = ""; + const provider = settings.captcha_provider; + if (provider === "turnstile" && settings.turnstile_site_key) { + const div = document.createElement("div"); + div.className = "cf-turnstile"; + div.dataset.sitekey = settings.turnstile_site_key; + els.captchaSlot.appendChild(div); + const s = document.createElement("script"); + s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; + s.async = true; + document.body.appendChild(s); + } else if (provider === "hcaptcha" && settings.hcaptcha_site_key) { + const div = document.createElement("div"); + div.className = "h-captcha"; + div.dataset.sitekey = settings.hcaptcha_site_key; + els.captchaSlot.appendChild(div); + const s = document.createElement("script"); + s.src = "https://js.hcaptcha.com/1/api.js"; + s.async = true; + document.body.appendChild(s); + } + } + + function captchaToken() { + if (settings.captcha_provider === "turnstile" && window.turnstile) { + return window.turnstile.getResponse?.() || ""; + } + if (settings.captcha_provider === "hcaptcha" && window.hcaptcha) { + return window.hcaptcha.getResponse?.() || ""; + } + return ""; + } + + function resolveKey(parsed) { + if (parsed.key) return parsed.key; + if (location.hash && location.hash.length > 1) return location.hash.slice(1); + return null; + } + + function downloadBlob(name, blob) { + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 2000); + } + + 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 pre = document.createElement("pre"); + pre.textContent = item.content || ""; + 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); + } 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
`; + 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); + } + } + + els.btn.addEventListener("click", async () => { + clearError(); + const parsed = window.WrappedCrypto.parseToken(els.token.value); + if (!parsed) { + showError(t("unwrap.unavailable")); + return; + } + const key = resolveKey(parsed); + if (!key) { + showError(t("unwrap.needKey")); + return; + } + + els.btn.disabled = true; + try { + const resp = await fetch(`/api/v1/wraps/${encodeURIComponent(parsed.wrapId)}/unwrap`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + password: els.password.value || null, + captcha_token: captchaToken() || null, + }), + }); + if (resp.status === 403) { + showError(t("unwrap.badPassword")); + return; + } + if (!resp.ok) { + showError(t("unwrap.unavailable")); + return; + } + const data = await resp.json(); + const ciphertext = window.WrappedCrypto.base64ToBytes(data.ciphertext_b64); + let pack; + try { + pack = await window.WrappedCrypto.decryptPackage( + ciphertext, + key, + els.password.value || null + ); + } catch (err) { + if (err.message === "bad_password" || err.message === "password_required") { + showError(t("unwrap.badPassword")); + return; + } + showError(t("common.error")); + return; + } + renderPackage(pack); + els.form.classList.add("hidden"); + els.result.classList.remove("hidden"); + history.replaceState(null, "", location.pathname); + } catch { + showError(t("common.error")); + } finally { + els.btn.disabled = false; + } + }); + + async function init() { + const resp = await fetch("/api/v1/settings"); + 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]; + } + } + + init().catch(() => showError(t("common.error"))); +})(); diff --git a/app/templates/admin_audit.html b/app/templates/admin_audit.html new file mode 100644 index 0000000..6c67a7e --- /dev/null +++ b/app/templates/admin_audit.html @@ -0,0 +1,112 @@ +{% extends "admin_base.html" %} +{% block admin_content %} +
+
+ + + + +
+ +
+ {% if total %} + {{ from_idx }}–{{ to_idx }} + of + {{ total }} + {% else %} + 0 + {% endif %} + events +
+ +
+ + + + + + + + + + + + + + {% for e in events %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
TimeEventOKWrapIPUADetails
{{ e.created_at }}{{ e.event_type }} + {% if e.success %} + yes + {% else %} + no + {% endif %} + {{ e.wrap_id or "—" }}{{ e.ip or "—" }}{{ e.user_agent or "—" }}{{ e.details }}
No events
+
+ + {% if pages > 1 %} + + {% endif %} +
+ +{% endblock %} diff --git a/app/templates/admin_base.html b/app/templates/admin_base.html new file mode 100644 index 0000000..a80b795 --- /dev/null +++ b/app/templates/admin_base.html @@ -0,0 +1,72 @@ + + + + + + {{ title }} · Wrapped Admin + + + + + + + + + + + +
+ + + + Wrapped + Admin console + + +
+ +
+ + +
+
+
+
+

{{ title }}

+ {% block admin_content %}{% endblock %} +
+ + + + + diff --git a/app/templates/admin_danger.html b/app/templates/admin_danger.html new file mode 100644 index 0000000..28abe5b --- /dev/null +++ b/app/templates/admin_danger.html @@ -0,0 +1,44 @@ +{% extends "admin_base.html" %} +{% block admin_content %} +{% if request.query_params.get('purged') %} +

+ Purged + {{ request.query_params.get('wraps', '0') }} + wrap(s) and + {{ request.query_params.get('objects', '0') }} + object(s). +

+{% endif %} +{% if request.query_params.get('purge') == 'confirm' %} +

Type PURGE to confirm forced cleanup.

+{% endif %} +{% if request.query_params.get('purge') == 'error' %} +

Purge failed. Check logs / MinIO connectivity.

+{% endif %} + +
+

Force-delete all wraps from the database and all ciphertext objects in MinIO (wraps/). Audit log is kept. This cannot be undone.

+
+ + +
+
+ +{% endblock %} diff --git a/app/templates/admin_login.html b/app/templates/admin_login.html new file mode 100644 index 0000000..647db26 --- /dev/null +++ b/app/templates/admin_login.html @@ -0,0 +1,53 @@ + + + + + + {{ title }} · Wrapped + + + + + + + + + + + + +
+ +
+ + + + diff --git a/app/templates/admin_settings.html b/app/templates/admin_settings.html new file mode 100644 index 0000000..cab4798 --- /dev/null +++ b/app/templates/admin_settings.html @@ -0,0 +1,140 @@ +{% extends "admin_base.html" %} +{% block admin_content %} +{% if request.query_params.get('saved') %} +

Settings saved.

+{% endif %} + +
+
+ + + + + +
+ +
+
+

Limits

+
+ + + + +
+
+ + + + + + + + + +
+ +
+
+
+ + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..ccb2cba --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,75 @@ + + + + + + {% block title %}{{ title or "Wrapped" }}{% endblock %} + + + + + + + + + {% block head %}{% endblock %} + + + +
+ + + + Wrapped + Упакуй. Отправь. Исчезни. + + +
+ + +
+
+
+ {% block content %}{% endblock %} +
+
+ + +
+ + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/create.html b/app/templates/create.html new file mode 100644 index 0000000..064b676 --- /dev/null +++ b/app/templates/create.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+ + +
+ + + + +
+

Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)

+ +
+
    + +
    +
    + + +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    + +
    +
    + + +
    +{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/app/templates/unwrap.html b/app/templates/unwrap.html new file mode 100644 index 0000000..bad8118 --- /dev/null +++ b/app/templates/unwrap.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block content %} +
    +
    +

    Unwrap

    +

    Reveal once

    +

    Paste a wrapped token or open a share link. Ciphertext is fetched once and deleted from the server.

    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + +
    +{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..3ec706d --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + + client_max_body_size 64m; + + location / { + proxy_pass http://app:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + # Overwrite client-supplied XFF so spoofing via the edge is not possible. + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 120s; + } +} diff --git a/deploy/nginx.dev.conf b/deploy/nginx.dev.conf new file mode 100644 index 0000000..b75efae --- /dev/null +++ b/deploy/nginx.dev.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name _; + + # When another proxy sits in front (CI, tunnel), honour its client IP. + set_real_ip_from 10.0.0.0/8; + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 192.168.0.0/16; + set_real_ip_from 127.0.0.0/8; + set_real_ip_from fc00::/7; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + + client_max_body_size 64m; + + location / { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_pass http://app:8000; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c4554c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,101 @@ +services: + proxy: + image: nginx:1.27-alpine + container_name: wrapped-proxy + restart: unless-stopped + ports: + - "${APP_PORT:-8000}:80" + volumes: + - ./deploy/nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - app + + app: + build: + context: . + dockerfile: Dockerfile + container_name: wrapped-app + restart: unless-stopped + expose: + - "8000" + env_file: + - .env + environment: + DATABASE_URL: postgresql+asyncpg://wrapped:wrapped@postgres:5432/wrapped + S3_ENDPOINT_URL: http://minio:9000 + S3_ACCESS_KEY: wrappedminio + S3_SECRET_KEY: wrappedminio123 + S3_BUCKET: wrapped + S3_USE_SSL: "false" + S3_CREATE_BUCKET: "true" + APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8000} + # Trust docker / compose networks so nginx X-Real-IP / X-Forwarded-For are used. + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16} + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_started + minio-init: + condition: service_completed_successfully + volumes: + - ./app:/app/app:ro + command: > + sh -c "alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + --proxy-headers --forwarded-allow-ips='*'" + + postgres: + image: postgres:16-alpine + container_name: wrapped-postgres + restart: unless-stopped + environment: + POSTGRES_USER: wrapped + POSTGRES_PASSWORD: wrapped + POSTGRES_DB: wrapped + # Not published by default (host :5432 often busy). Set POSTGRES_PORT to expose. + ports: + - "${POSTGRES_PORT:-127.0.0.1:5433}:5432" + volumes: + - wrapped_pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wrapped -d wrapped"] + interval: 5s + timeout: 5s + retries: 10 + + minio: + image: minio/minio:latest + container_name: wrapped-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: wrappedminio + MINIO_ROOT_PASSWORD: wrappedminio123 + ports: + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - wrapped_minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + + minio-init: + image: minio/mc:latest + container_name: wrapped-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 wrappedminio wrappedminio123 && + mc mb --ignore-existing local/wrapped && + exit 0 + " + +volumes: + wrapped_pg_data: + wrapped_minio_data: diff --git a/docs/screenshots/create-dark.png b/docs/screenshots/create-dark.png new file mode 100644 index 0000000..128babe Binary files /dev/null and b/docs/screenshots/create-dark.png differ diff --git a/docs/screenshots/create-filled-dark.png b/docs/screenshots/create-filled-dark.png new file mode 100644 index 0000000..fea9cce Binary files /dev/null and b/docs/screenshots/create-filled-dark.png differ diff --git a/docs/screenshots/create-filled-light.png b/docs/screenshots/create-filled-light.png new file mode 100644 index 0000000..4889254 Binary files /dev/null and b/docs/screenshots/create-filled-light.png differ diff --git a/docs/screenshots/create-light.png b/docs/screenshots/create-light.png new file mode 100644 index 0000000..5d32e78 Binary files /dev/null and b/docs/screenshots/create-light.png differ diff --git a/docs/screenshots/token-issued-dark.png b/docs/screenshots/token-issued-dark.png new file mode 100644 index 0000000..c3664ed Binary files /dev/null and b/docs/screenshots/token-issued-dark.png differ diff --git a/docs/screenshots/token-issued-light.png b/docs/screenshots/token-issued-light.png new file mode 100644 index 0000000..9fddf36 Binary files /dev/null and b/docs/screenshots/token-issued-light.png differ diff --git a/docs/screenshots/unwrap-dark.png b/docs/screenshots/unwrap-dark.png new file mode 100644 index 0000000..a65cdc9 Binary files /dev/null and b/docs/screenshots/unwrap-dark.png differ diff --git a/docs/screenshots/unwrap-light.png b/docs/screenshots/unwrap-light.png new file mode 100644 index 0000000..264074a Binary files /dev/null and b/docs/screenshots/unwrap-light.png differ diff --git a/helm/wrapped/Chart.yaml b/helm/wrapped/Chart.yaml new file mode 100644 index 0000000..ad0bfdc --- /dev/null +++ b/helm/wrapped/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: wrapped +description: Zero-knowledge one-time encrypted drop (Wrapped) +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/helm/wrapped/templates/_helpers.tpl b/helm/wrapped/templates/_helpers.tpl new file mode 100644 index 0000000..5cebc6e --- /dev/null +++ b/helm/wrapped/templates/_helpers.tpl @@ -0,0 +1,18 @@ +{{- define "wrapped.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "wrapped.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name (include "wrapped.name" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "wrapped.labels" -}} +app.kubernetes.io/name: {{ include "wrapped.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} diff --git a/helm/wrapped/templates/configmap.yaml b/helm/wrapped/templates/configmap.yaml new file mode 100644 index 0000000..02518e2 --- /dev/null +++ b/helm/wrapped/templates/configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "wrapped.fullname" . }}-config + labels: + {{- include "wrapped.labels" . | nindent 4 }} +data: + APP_NAME: "Wrapped" + APP_ENV: {{ .Values.app.env | quote }} + APP_BASE_URL: {{ .Values.app.baseUrl | quote }} + LOG_LEVEL: {{ .Values.app.logLevel | quote }} + DOCS_ENABLED: {{ .Values.app.docsEnabled | quote }} + S3_ENDPOINT_URL: {{ .Values.external.s3.endpointUrl | quote }} + S3_BUCKET: {{ .Values.external.s3.bucket | quote }} + S3_REGION: {{ .Values.external.s3.region | quote }} + S3_USE_SSL: {{ .Values.external.s3.useSsl | quote }} + S3_CREATE_BUCKET: {{ .Values.external.s3.createBucket | quote }} + # Trust ingress / mesh peers so audit stores the real client IP from X-Forwarded-For. + TRUSTED_PROXIES: {{ .Values.app.trustedProxies | quote }} + # Peers allowed to set X-Forwarded-For / X-Real-IP (audit shows real client IP). + # "*" is correct when only Ingress / mesh reaches the pod. + TRUSTED_PROXIES: {{ .Values.app.trustedProxies | default "*" | quote }} diff --git a/helm/wrapped/templates/deployment.yaml b/helm/wrapped/templates/deployment.yaml new file mode 100644 index 0000000..44261ca --- /dev/null +++ b/helm/wrapped/templates/deployment.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "wrapped.fullname" . }} + labels: + {{- include "wrapped.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "wrapped.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "wrapped.labels" . | nindent 8 }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + containers: + - name: wrapped + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 8000 + command: + - sh + - -c + - > + alembic upgrade head && + uvicorn app.main:app + --host 0.0.0.0 + --port 8000 + --proxy-headers + --forwarded-allow-ips={{ .Values.app.trustedProxies | default "*" | quote }} + envFrom: + - secretRef: + name: {{ include "wrapped.fullname" . }}-secret + - configMapRef: + name: {{ include "wrapped.fullname" . }}-config + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/wrapped/templates/ingress.yaml b/helm/wrapped/templates/ingress.yaml new file mode 100644 index 0000000..d523d86 --- /dev/null +++ b/helm/wrapped/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "wrapped.fullname" . }} + labels: + {{- include "wrapped.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "wrapped.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/wrapped/templates/secret.yaml b/helm/wrapped/templates/secret.yaml new file mode 100644 index 0000000..e43b4bb --- /dev/null +++ b/helm/wrapped/templates/secret.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "wrapped.fullname" . }}-secret + labels: + {{- include "wrapped.labels" . | nindent 4 }} +type: Opaque +stringData: + APP_SECRET_KEY: {{ .Values.app.secretKey | quote }} + ADMIN_USERNAME: {{ .Values.app.adminUsername | quote }} + ADMIN_PASSWORD: {{ .Values.app.adminPassword | quote }} + DATABASE_URL: {{ .Values.external.databaseUrl | quote }} + S3_ACCESS_KEY: {{ .Values.external.s3.accessKey | quote }} + S3_SECRET_KEY: {{ .Values.external.s3.secretKey | quote }} + TURNSTILE_SECRET_KEY: {{ .Values.app.turnstileSecretKey | quote }} + HCAPTCHA_SECRET_KEY: {{ .Values.app.hcaptchaSecretKey | quote }} diff --git a/helm/wrapped/templates/service.yaml b/helm/wrapped/templates/service.yaml new file mode 100644 index 0000000..ce91be3 --- /dev/null +++ b/helm/wrapped/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "wrapped.fullname" . }} + labels: + {{- include "wrapped.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ include "wrapped.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/helm/wrapped/values.yaml b/helm/wrapped/values.yaml new file mode 100644 index 0000000..cf105b2 --- /dev/null +++ b/helm/wrapped/values.yaml @@ -0,0 +1,57 @@ +replicaCount: 1 + +image: + repository: inecs/wrapped + tag: "0.1.0" + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 8000 + +ingress: + enabled: false + className: "" + annotations: + # Pass real client IP to the app (nginx ingress). + nginx.ingress.kubernetes.io/use-forwarded-headers: "true" + nginx.ingress.kubernetes.io/compute-full-forwarded-for: "true" + nginx.ingress.kubernetes.io/enable-real-ip: "true" + hosts: + - host: wrapped.local + paths: + - path: / + pathType: Prefix + tls: [] + +resources: {} + +# Use existing Postgres / MinIO (recommended for production release) +external: + databaseUrl: "postgresql+asyncpg://wrapped:wrapped@postgres:5432/wrapped" + s3: + endpointUrl: "http://minio:9000" + accessKey: "wrappedminio" + secretKey: "wrappedminio123" + bucket: "wrapped" + region: "us-east-1" + useSsl: false + createBucket: false + +app: + env: production + baseUrl: "https://wrapped.local" + secretKey: "change-me-in-prod" + adminUsername: admin + adminPassword: "change-me" + logLevel: INFO + # Swagger / ReDoc / openapi.json — disabled for k8s by default + docsEnabled: false + # Trust Ingress / any peer for X-Forwarded-For (ClusterIP is not public). + trustedProxies: "*" + turnstileSecretKey: "" + hcaptchaSecretKey: "" +podAnnotations: {} +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..31c9664 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "wrapped" +version = "0.1.0" +description = "Zero-knowledge one-time encrypted drop service" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "sqlalchemy[asyncio]>=2.0.36", + "asyncpg>=0.30.0", + "alembic>=1.14.0", + "pydantic-settings>=2.6.0", + "python-multipart>=0.0.17", + "httpx>=0.28.0", + "aiobotocore>=2.15.0", + "argon2-cffi>=23.1.0", + "jinja2>=3.1.4", + "itsdangerous>=2.2.0", + "orjson>=3.10.0", +] + +[project.optional-dependencies] +dev = [ + "ruff>=0.8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +line-length = 100 +target-version = "py312"