This commit is contained in:
Sergey Antropoff
2026-07-17 15:57:36 +03:00
commit e4240a7be5
72 changed files with 6227 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.env
.venv
venv
__pycache__
*.pyc
.mypy_cache
.ruff_cache
.pytest_cache
dist
*.tgz
.DS_Store
+47
View File
@@ -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
+19
View File
@@ -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/
+26
View File
@@ -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:-*}"]
+105
View File
@@ -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-<tag>
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
+445
View File
@@ -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/<id>#<key>` или токен `wrapped_v1.<id>.<key>`. Ключ шифрования в 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/<id>#<key>` и `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 / на ваше усмотрение.
+42
View File
@@ -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
+59
View File
@@ -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()
+19
View File
@@ -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
+123
View File
@@ -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")
View File
View File
+428
View File
@@ -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
]
+331
View File
@@ -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,
)
+52
View File
@@ -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()
+20
View File
@@ -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
+150
View File
@@ -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()
+152
View File
@@ -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/*",
]
+82
View File
@@ -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]
View File
+93
View File
@@ -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)]
+97
View File
@@ -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))
+59
View File
@@ -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"
+47
View File
@@ -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
+139
View File
@@ -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
+19
View File
@@ -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
+29
View File
@@ -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()
+72
View File
@@ -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,
}
+89
View File
@@ -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()
+17
View File
@@ -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"]
+75
View File
@@ -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}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 501 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="g" x1="2" y1="2" x2="30" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#3dd6c6"/>
<stop offset="1" stop-color="#6ea8ff"/>
</linearGradient>
</defs>
<rect x="1" y="1" width="30" height="30" rx="8" fill="url(#g)"/>
<!-- shield (halved style) -->
<path d="M16 6.4 24 9.5v6.2c0 4.5-3.4 8.3-8 9.9-4.6-1.6-8-5.4-8-9.9V9.5L16 6.4z" fill="#041018"/>
<path d="M16 7.5v17.2c3.7-1.4 6.2-4.7 6.2-8V10.2L16 7.5z" fill="#0a2030"/>
<path d="M16 7.2v17.9" stroke="#3dd6c6" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 654 B

+25
View File
@@ -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;
});
})();
+32
View File
@@ -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);
})();
+304
View File
@@ -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 = `<span>${f.name} <small>(${f.type || "file"} · ${f.size} B)</small></span>`;
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 `<option value="${o.value}" ${o.value === pick ? "selected" : ""}>${label}</option>`;
})
.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")));
})();
+199
View File
@@ -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,
};
})();
+422
View File
@@ -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 };
})();
+86
View File
@@ -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 = `
<div class="modal-backdrop" data-modal-cancel></div>
<div class="modal-panel" role="document">
<h2 class="modal-title" id="confirm-modal-title"></h2>
<p class="modal-message" id="confirm-modal-message"></p>
<div class="modal-actions">
<button type="button" class="btn" data-modal-cancel id="confirm-modal-cancel"></button>
<button type="button" class="btn danger" data-modal-ok id="confirm-modal-ok"></button>
</div>
</div>
`;
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<boolean>}
*/
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 };
})();
+30
View File
@@ -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);
});
})();
+188
View File
@@ -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 = `<div><strong>text</strong> · <span class="mono">${item.language || "plaintext"}</span></div>`;
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 = `<div><strong>${item.name}</strong> · <span class="mono">${item.mime}</span> · ${bytes.length} B</div>`;
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")));
})();
+112
View File
@@ -0,0 +1,112 @@
{% extends "admin_base.html" %}
{% block admin_content %}
<section class="audit-page">
<form class="audit-filters" method="get" action="/admin/audit">
<input type="hidden" name="page" value="1" />
<label class="audit-field">
<span data-i18n="admin.audit.eventType">Event type</span>
<select name="event_type" id="audit-event-type">
<option value="" {% if not event_type %}selected{% endif %} data-i18n="admin.audit.allEvents">All events</option>
{% for t in event_types %}
<option value="{{ t }}" {% if event_type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</label>
<label class="audit-field audit-field-grow">
<span data-i18n="admin.audit.wrapId">Wrap ID</span>
<input name="wrap_id" value="{{ wrap_id }}" class="mono" placeholder="…" autocomplete="off" />
</label>
<button class="btn primary audit-filter-btn" type="submit" data-i18n="admin.audit.filter">Filter</button>
</form>
<div class="audit-meta">
{% if total %}
<span class="audit-count">{{ from_idx }}{{ to_idx }}</span>
<span data-i18n="admin.audit.of">of</span>
<span class="audit-count">{{ total }}</span>
{% else %}
<span class="audit-count">0</span>
{% endif %}
<span data-i18n="admin.audit.events">events</span>
</div>
<div class="audit-table-wrap">
<table class="audit-table">
<thead>
<tr>
<th data-i18n="admin.audit.col.time">Time</th>
<th data-i18n="admin.audit.col.event">Event</th>
<th data-i18n="admin.audit.col.ok">OK</th>
<th data-i18n="admin.audit.col.wrap">Wrap</th>
<th>IP</th>
<th>UA</th>
<th data-i18n="admin.audit.col.details">Details</th>
</tr>
</thead>
<tbody>
{% for e in events %}
<tr class="{% if not e.success %}audit-row-fail{% endif %}">
<td class="mono audit-time">{{ e.created_at }}</td>
<td><span class="audit-event-pill">{{ e.event_type }}</span></td>
<td>
{% if e.success %}
<span class="audit-status ok"><i class="fa-solid fa-check" aria-hidden="true"></i> <span data-i18n="admin.audit.yes">yes</span></span>
{% else %}
<span class="audit-status fail"><i class="fa-solid fa-xmark" aria-hidden="true"></i> <span data-i18n="admin.audit.no">no</span></span>
{% endif %}
</td>
<td class="mono">{{ e.wrap_id or "—" }}</td>
<td class="mono">{{ e.ip or "—" }}</td>
<td class="ua" title="{{ e.user_agent or '' }}">{{ e.user_agent or "—" }}</td>
<td class="audit-details"><code>{{ e.details }}</code></td>
</tr>
{% else %}
<tr>
<td colspan="7" class="audit-empty" data-i18n="admin.audit.empty">No events</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
<nav class="pagination" aria-label="Pagination">
{% if has_prev %}
<a class="btn" href="/admin/audit?page={{ page - 1 }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span data-i18n="admin.audit.prev">Prev</span>
</a>
{% else %}
<span class="btn" aria-disabled="true" disabled>
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span data-i18n="admin.audit.prev">Prev</span>
</span>
{% endif %}
<span class="pagination-status">
<span data-i18n="admin.audit.page">Page</span>
{{ page }}
<span data-i18n="admin.audit.of">of</span>
{{ pages }}
</span>
{% if has_next %}
<a class="btn" href="/admin/audit?page={{ page + 1 }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<span data-i18n="admin.audit.next">Next</span>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</a>
{% else %}
<span class="btn" aria-disabled="true" disabled>
<span data-i18n="admin.audit.next">Next</span>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</span>
{% endif %}
</nav>
{% endif %}
</section>
<script>
document.getElementById("audit-event-type")?.addEventListener("change", (e) => {
e.target.form?.requestSubmit();
});
</script>
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title }} · Wrapped Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body class="admin-body">
<div class="bg-grid" aria-hidden="true"></div>
<header class="admin-top">
<a class="brand" href="/admin">
<span class="brand-mark" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i>
</span>
<span class="brand-text">
<span class="brand-name">Wrapped</span>
<span class="brand-tagline" data-i18n="admin.brand.tagline">Admin console</span>
</span>
</a>
<div class="admin-top-right">
<nav class="admin-nav" aria-label="Admin">
<a href="/admin/settings" class="admin-nav-link {% if active == 'settings' %}active{% endif %}">
<i class="fa-solid fa-sliders" aria-hidden="true"></i>
<span data-i18n="admin.nav.settings">Settings</span>
</a>
<a href="/admin/audit" class="admin-nav-link {% if active == 'audit' %}active{% endif %}">
<i class="fa-solid fa-list" aria-hidden="true"></i>
<span data-i18n="admin.nav.audit">Audit</span>
</a>
<a href="/admin/danger" class="admin-nav-link admin-nav-danger {% if active == 'danger' %}active{% endif %}">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
<span data-i18n="admin.nav.danger">Danger</span>
</a>
<a href="/" class="admin-nav-link" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span data-i18n="admin.nav.site">Site</span>
</a>
<form method="post" action="/admin/logout" class="inline">
<button class="admin-nav-link admin-nav-logout" type="submit">
<i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i>
<span data-i18n="admin.nav.logout">Logout</span>
</button>
</form>
</nav>
<div class="top-actions">
<button type="button" class="icon-btn" id="lang-toggle" title="Language" aria-label="Language">
<span id="lang-label">RU</span>
</button>
<button type="button" class="icon-btn" id="theme-toggle" title="Theme" aria-label="Theme">
<svg id="theme-icon-sun" class="icon hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg id="theme-icon-moon" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 14.5A8.5 8.5 0 1 1 9.5 3a7 7 0 0 0 11.5 11.5z"/></svg>
</button>
</div>
</div>
</header>
<main class="admin-shell">
<h1 data-i18n="{% if active == 'audit' %}admin.audit.title{% elif active == 'danger' %}admin.danger.title{% else %}admin.settings.title{% endif %}">{{ title }}</h1>
{% block admin_content %}{% endblock %}
</main>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/modal.js"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{% extends "admin_base.html" %}
{% block admin_content %}
{% if request.query_params.get('purged') %}
<p class="ok">
<span data-i18n="admin.flash.purgedPrefix">Purged</span>
{{ request.query_params.get('wraps', '0') }}
<span data-i18n="admin.flash.purgedWraps">wrap(s) and</span>
{{ request.query_params.get('objects', '0') }}
<span data-i18n="admin.flash.purgedObjects">object(s).</span>
</p>
{% endif %}
{% if request.query_params.get('purge') == 'confirm' %}
<p class="error" data-i18n="admin.flash.purgeConfirm">Type PURGE to confirm forced cleanup.</p>
{% endif %}
{% if request.query_params.get('purge') == 'error' %}
<p class="error" data-i18n="admin.flash.purgeError">Purge failed. Check logs / MinIO connectivity.</p>
{% endif %}
<section class="admin-section danger-zone danger-page">
<p class="hint" data-i18n="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.</p>
<form method="post" action="/admin/purge" class="purge-form" id="purge-form">
<label>
<span data-i18n="admin.danger.confirmLabel">Type PURGE to confirm</span>
<input name="confirm" type="text" autocomplete="off" placeholder="PURGE" required />
</label>
<button class="btn danger" type="submit" data-i18n="admin.danger.submit">Clear all wraps &amp; files</button>
</form>
</section>
<script>
document.getElementById("purge-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.target;
const ok = await window.WrappedModal.confirm({
title: window.WrappedI18n?.t("admin.danger.confirmTitle") || "Danger zone",
message: window.WrappedI18n?.t("admin.danger.confirmDialog")
|| "Delete ALL wraps and files? This cannot be undone.",
confirmLabel: window.WrappedI18n?.t("admin.danger.confirmOk") || "Delete everything",
cancelLabel: window.WrappedI18n?.t("modal.confirm.cancel") || "Cancel",
danger: true,
});
if (ok) form.submit();
});
</script>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title }} · Wrapped</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body class="admin-body">
<div class="bg-grid" aria-hidden="true"></div>
<div class="top-actions admin-login-toggles">
<button type="button" class="icon-btn" id="lang-toggle" title="Language" aria-label="Language">
<span id="lang-label">RU</span>
</button>
<button type="button" class="icon-btn" id="theme-toggle" title="Theme" aria-label="Theme">
<svg id="theme-icon-sun" class="icon hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg id="theme-icon-moon" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 14.5A8.5 8.5 0 1 1 9.5 3a7 7 0 0 0 11.5 11.5z"/></svg>
</button>
</div>
<main class="admin-login-shell">
<form class="admin-card login-card" method="post" action="/admin/login">
<div class="login-head">
<span class="brand-mark" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i>
</span>
<div>
<div class="brand-name">Wrapped</div>
<div class="login-sub" data-i18n="admin.login.sub">Admin</div>
</div>
</div>
{% if error %}<p class="error login-error" {% if error_key %}data-i18n="{{ error_key }}"{% endif %}>{{ error }}</p>{% endif %}
<label>
<span data-i18n="admin.login.username">Username</span>
<input name="username" autocomplete="username" required autofocus />
</label>
<label>
<span data-i18n="admin.login.password">Password</span>
<input name="password" type="password" autocomplete="current-password" required />
</label>
<button class="btn primary" type="submit" data-i18n="admin.login.submit">Sign in</button>
</form>
</main>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
{% extends "admin_base.html" %}
{% block admin_content %}
{% if request.query_params.get('saved') %}
<p class="ok" data-i18n="admin.flash.saved">Settings saved.</p>
{% endif %}
<div class="admin-tabs" id="admin-tabs">
<div class="admin-tablist" role="tablist">
<button type="button" class="admin-tab active" role="tab" data-tab="limits" data-i18n="admin.tab.limits">Limits</button>
<button type="button" class="admin-tab" role="tab" data-tab="rate" data-i18n="admin.tab.rate">Rate limits</button>
<button type="button" class="admin-tab" role="tab" data-tab="mime" data-i18n="admin.tab.mime">MIME</button>
<button type="button" class="admin-tab" role="tab" data-tab="password" data-i18n="admin.tab.password">Password</button>
<button type="button" class="admin-tab" role="tab" data-tab="captcha" data-i18n="admin.tab.captcha">CAPTCHA</button>
</div>
<form method="post" action="/admin/settings" class="admin-form">
<section class="admin-tabpanel active" data-panel="limits" role="tabpanel">
<h2 data-i18n="admin.limits.title">Limits</h2>
<div class="grid-2">
<label>
<span data-i18n="admin.limits.maxUpload">Max upload (MB)</span>
<input name="max_upload_mb" type="number" min="1" step="1"
value="{{ (settings.max_upload_bytes / 1048576)|round|int }}" />
</label>
<label>
<span data-i18n="admin.limits.defaultTtl">Default TTL (hours)</span>
<input name="default_ttl_hours" type="number" min="1" step="1"
value="{{ (settings.default_ttl_seconds / 3600)|round|int }}" />
</label>
<label>
<span data-i18n="admin.limits.maxTtl">Max TTL (hours)</span>
<input name="max_ttl_hours" type="number" min="1" step="1"
value="{{ (settings.max_ttl_seconds / 3600)|round|int }}" />
</label>
<label>
<span data-i18n="admin.limits.auditRetention">Audit retention (days)</span>
<input name="audit_retention_days" type="number" value="{{ settings.audit_retention_days }}" />
</label>
</div>
</section>
<section class="admin-tabpanel" data-panel="rate" role="tabpanel" hidden>
<h2 data-i18n="admin.rate.title">Rate limits (per IP / minute)</h2>
<div class="grid-3">
<label>
<span data-i18n="admin.rate.create">Create</span>
<input name="rate_limit_create_per_minute" type="number" value="{{ settings.rate_limit_create_per_minute }}" />
</label>
<label>
<span data-i18n="admin.rate.unwrap">Unwrap</span>
<input name="rate_limit_unwrap_per_minute" type="number" value="{{ settings.rate_limit_unwrap_per_minute }}" />
</label>
<label>
<span data-i18n="admin.rate.adminLogin">Admin login</span>
<input name="rate_limit_admin_login_per_minute" type="number" value="{{ settings.rate_limit_admin_login_per_minute }}" />
</label>
</div>
</section>
<section class="admin-tabpanel" data-panel="mime" role="tabpanel" hidden>
<h2 data-i18n="admin.mime.title">MIME allowlist</h2>
<p class="hint" data-i18n="admin.mime.hint">One pattern per line. Supports exact types and wildcards like image/*.</p>
<textarea name="mime_allowlist" rows="14">{% for m in settings.mime_allowlist %}{{ m }}
{% endfor %}</textarea>
<details class="helper">
<summary data-i18n="admin.mime.examples">Examples / suggested defaults</summary>
<pre class="mono">{% for m in mime_examples %}{{ m }}
{% endfor %}</pre>
</details>
</section>
<section class="admin-tabpanel" data-panel="password" role="tabpanel" hidden>
<h2 data-i18n="admin.passwordMode.title">Password mode</h2>
{% for mode in password_modes %}
<label class="radio-block">
<input type="radio" name="password_mode" value="{{ mode.value }}" {% if settings.password_mode == mode %}checked{% endif %} />
<span>
<strong>{{ mode.value }}</strong>
<small data-i18n="admin.passwordMode.{{ mode.value }}">{{ password_mode_help[mode.value] }}</small>
</span>
</label>
{% endfor %}
</section>
<section class="admin-tabpanel" data-panel="captcha" role="tabpanel" hidden>
<h2 data-i18n="admin.captcha.title">CAPTCHA</h2>
<label>
<span data-i18n="admin.captcha.provider">Provider</span>
<select name="captcha_provider">
{% for p in captcha_providers %}
<option value="{{ p.value }}" {% if settings.captcha_provider == p %}selected{% endif %}>{{ p.value }}</option>
{% endfor %}
</select>
</label>
<div class="grid-2">
<label>
<span data-i18n="admin.captcha.turnstileSite">Turnstile site key</span>
<input name="turnstile_site_key" value="{{ settings.turnstile_site_key }}" />
</label>
<label>
<span data-i18n="admin.captcha.hcaptchaSite">hCaptcha site key</span>
<input name="hcaptcha_site_key" value="{{ settings.hcaptcha_site_key }}" />
</label>
</div>
<p class="hint">
<span data-i18n="admin.captcha.secrets">Secrets:</span>
Turnstile {% if turnstile_secret_set %}<span class="ok" data-i18n="admin.captcha.set">set</span>{% else %}<span class="error" data-i18n="admin.captcha.missing">missing</span>{% endif %}
· hCaptcha {% if hcaptcha_secret_set %}<span class="ok" data-i18n="admin.captcha.set">set</span>{% else %}<span class="error" data-i18n="admin.captcha.missing">missing</span>{% endif %}
<span data-i18n="admin.captcha.viaEnv">(via env TURNSTILE_SECRET_KEY / HCAPTCHA_SECRET_KEY)</span>
</p>
<details class="helper" open>
<summary data-i18n="admin.captcha.helperTitle">How to get CAPTCHA tokens</summary>
<div class="helper-body">
<h3>Cloudflare Turnstile</h3>
<ol>
<li data-i18n="admin.captcha.turnstile.1">Open Cloudflare Turnstile dashboard</li>
<li data-i18n="admin.captcha.turnstile.2">Create a site widget for your domain (use localhost for dev)</li>
<li data-i18n="admin.captcha.turnstile.3">Copy Site Key into the field above</li>
<li data-i18n="admin.captcha.turnstile.4">Copy Secret Key into env TURNSTILE_SECRET_KEY and restart</li>
<li data-i18n="admin.captcha.turnstile.5">Set provider to turnstile</li>
</ol>
<h3>hCaptcha</h3>
<ol>
<li data-i18n="admin.captcha.hcaptcha.1">Open hCaptcha dashboard</li>
<li data-i18n="admin.captcha.hcaptcha.2">New site → copy Sitekey here</li>
<li data-i18n="admin.captcha.hcaptcha.3">Secret into env HCAPTCHA_SECRET_KEY, restart</li>
<li data-i18n="admin.captcha.hcaptcha.4">Set provider to hcaptcha</li>
</ol>
</div>
</details>
</section>
<div class="admin-tab-actions" id="settings-save-bar">
<button class="btn primary" type="submit" data-i18n="admin.settings.save">Save settings</button>
</div>
</form>
</div>
<script src="/static/js/admin-tabs.js"></script>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{{ title or "Wrapped" }}{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
{% block head %}{% endblock %}
</head>
<body>
<div class="bg-grid" aria-hidden="true"></div>
<header class="topbar">
<a class="brand" href="/">
<span class="brand-mark" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i>
</span>
<span class="brand-text">
<span class="brand-name">Wrapped</span>
<span class="brand-tagline" data-i18n="brand.tagline">Упакуй. Отправь. Исчезни.</span>
</span>
</a>
<div class="top-actions">
<button type="button" class="icon-btn" id="lang-toggle" title="Language" aria-label="Language">
<span id="lang-label">RU</span>
</button>
<button type="button" class="icon-btn" id="theme-toggle" title="Theme" aria-label="Theme">
<svg id="theme-icon-sun" class="icon hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg id="theme-icon-moon" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 14.5A8.5 8.5 0 1 1 9.5 3a7 7 0 0 0 11.5 11.5z"/></svg>
</button>
</div>
</header>
<main class="shell">
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<ul class="footer-pillars">
<li>
<i class="fa-solid fa-user-secret" aria-hidden="true"></i>
<span>
<strong data-i18n="footer.pillar.zk">Zero-knowledge</strong>
<small data-i18n="footer.pillar.zk.hint">Сервер не видит содержимое</small>
</span>
</li>
<li>
<i class="fa-solid fa-fire" aria-hidden="true"></i>
<span>
<strong data-i18n="footer.pillar.once">Одноразово</strong>
<small data-i18n="footer.pillar.once.hint">После открытия — удаление</small>
</span>
</li>
<li>
<i class="fa-solid fa-lock" aria-hidden="true"></i>
<span>
<strong data-i18n="footer.pillar.encrypted">Зашифровано</strong>
<small data-i18n="footer.pillar.encrypted.hint">Ключ только в вашей ссылке</small>
</span>
</li>
</ul>
<p class="footer-copy">
© <span data-i18n="footer.copy.author">Сергей Антропов</span>
· <a href="https://devops.org.ru" target="_blank" rel="noopener noreferrer">devops.org.ru</a>
</p>
</footer>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block content %}
<section class="hero-panel create-panel">
<div class="composer">
<div class="field-row">
<label for="text-language" data-i18n="create.language">Language</label>
<select id="text-language">
<option value="plaintext" data-i18n="lang.plaintext">Обычный текст</option>
<option value="markdown" data-i18n="lang.markdown">Markdown</option>
<option value="json" data-i18n="lang.json">JSON</option>
<option value="yaml" data-i18n="lang.yaml">YAML</option>
<option value="python" data-i18n="lang.python">Python</option>
<option value="javascript" data-i18n="lang.javascript">JavaScript</option>
<option value="typescript" data-i18n="lang.typescript">TypeScript</option>
<option value="go" data-i18n="lang.go">Go</option>
<option value="rust" data-i18n="lang.rust">Rust</option>
<option value="sql" data-i18n="lang.sql">SQL</option>
<option value="bash" data-i18n="lang.bash">Bash</option>
<option value="html" data-i18n="lang.html">HTML</option>
<option value="css" data-i18n="lang.css">CSS</option>
</select>
</div>
<label class="sr-only" for="payload-text" data-i18n="create.text">Text</label>
<textarea id="payload-text" class="code-input" spellcheck="false" data-i18n-placeholder="create.textPlaceholder" placeholder="Paste secrets, configs, notes…"></textarea>
<div id="dropzone" class="dropzone" tabindex="0">
<p data-i18n="create.drop">Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)</p>
<input id="file-input" type="file" multiple hidden />
</div>
<ul id="file-list" class="file-list"></ul>
<div class="grid-2">
<div class="field">
<label for="ttl-seconds" data-i18n="create.ttl">Time to live</label>
<select id="ttl-seconds"></select>
</div>
<div class="field">
<label for="password" data-i18n="create.password">Password (optional)</label>
<div class="input-with-action">
<input id="password" type="password" autocomplete="new-password" data-i18n-placeholder="create.passwordPlaceholder" placeholder="Extra unlock secret" />
<button type="button" class="field-icon-btn" id="generate-password" aria-describedby="generate-password-tip">
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
<span class="sr-only" data-i18n="create.passwordGenerate">Generate password</span>
<span class="ui-tooltip" id="generate-password-tip" role="tooltip" data-i18n="create.passwordGenerateTip">Generate a strong password</span>
</button>
</div>
</div>
</div>
<div id="captcha-slot" class="captcha-slot"></div>
<p id="form-error" class="error hidden"></p>
<div class="actions actions-center">
<button type="button" id="wrap-btn" class="btn primary">
<span data-i18n="create.submit">Create wrapped token</span>
</button>
</div>
</div>
<div id="success-panel" class="success-panel hidden">
<h2 data-i18n="create.successTitle">Token issued</h2>
<p class="warn" data-i18n="create.successWarn">One-time unwrap. After someone opens it, ciphertext is destroyed on the server.</p>
<label data-i18n="create.shareLink">Share link</label>
<div class="copy-row">
<input id="share-link" readonly />
<button type="button" class="btn" id="copy-link" data-i18n="common.copy">Copy</button>
</div>
<label data-i18n="create.token">Wrapped token</label>
<div class="copy-row">
<input id="share-token" class="mono" readonly />
<button type="button" class="btn" id="copy-token" data-i18n="common.copy">Copy</button>
</div>
<p class="meta" id="expires-meta"></p>
<button type="button" class="btn ghost" id="create-another" data-i18n="create.another">Create another</button>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/js/crypto.js"></script>
<script src="/static/js/create.js"></script>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block content %}
<section class="hero-panel unwrap-panel">
<div class="panel-head">
<p class="eyebrow" data-i18n="unwrap.eyebrow">Unwrap</p>
<h1 data-i18n="unwrap.title">Reveal once</h1>
<p class="lede" data-i18n="unwrap.lede">Paste a wrapped token or open a share link. Ciphertext is fetched once and deleted from the server.</p>
</div>
<div class="composer" id="unwrap-form">
<div class="field">
<label for="token-input" data-i18n="unwrap.token">Wrapped token or ID</label>
<input id="token-input" class="mono" value="{{ wrap_id }}" data-i18n-placeholder="unwrap.tokenPlaceholder" placeholder="wrapped_v1.… или ID" />
</div>
<div class="field">
<label for="unwrap-password" data-i18n="unwrap.password">Password (if set)</label>
<input id="unwrap-password" type="password" autocomplete="current-password" />
</div>
<div id="captcha-slot" class="captcha-slot"></div>
<p id="form-error" class="error hidden"></p>
<button type="button" id="unwrap-btn" class="btn primary" data-i18n="unwrap.submit">Unwrap</button>
</div>
<div id="result-panel" class="result-panel hidden">
<p class="warn" data-i18n="unwrap.destroyed">Server copy destroyed. Preview lives only in this browser session.</p>
<div id="items" class="items"></div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/js/crypto.js"></script>
<script src="/static/js/unwrap.js"></script>
{% endblock %}
+18
View File
@@ -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;
}
}
+25
View File
@@ -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;
}
}
+101
View File
@@ -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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

+6
View File
@@ -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"
+18
View File
@@ -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 -}}
+22
View File
@@ -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 }}
+71
View File
@@ -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 }}
+35
View File
@@ -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 }}
+16
View File
@@ -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 }}
+16
View File
@@ -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 }}
+57
View File
@@ -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: {}
+37
View File
@@ -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"