Init
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.pytest_cache
|
||||
dist
|
||||
*.tgz
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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:-*}"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
| Светлая тема | Тёмная тема |
|
||||
|:------------:|:-----------:|
|
||||
|  |  |
|
||||
|
||||
### Создание с текстом и файлами
|
||||
|
||||
Текст с подсветкой синтаксиса, вложения (изображения, документы), TTL и опциональный пароль.
|
||||
|
||||
| Светлая тема | Тёмная тема |
|
||||
|:------------:|:-----------:|
|
||||
|  |  |
|
||||
|
||||
### Токен выдан
|
||||
|
||||
После создания — ссылка `/w/<id>#<key>` и `wrapped_v1`-токен. Ключ только во фрагменте URL.
|
||||
|
||||
| Светлая тема | Тёмная тема |
|
||||
|:------------:|:-----------:|
|
||||
|  |  |
|
||||
|
||||
### Unwrap (открытие)
|
||||
|
||||
Одноразовое открытие: ciphertext удаляется на сервере, превью — только в текущей сессии браузера.
|
||||
|
||||
| Светлая тема | Тёмная тема |
|
||||
|:------------:|:-----------:|
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт (разработка)
|
||||
|
||||
Нужны 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 / на ваше усмотрение.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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/*",
|
||||
]
|
||||
@@ -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]
|
||||
@@ -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)]
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 501 B |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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 |
@@ -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;
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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")));
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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")));
|
||||
})();
|
||||
@@ -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 }}&event_type={{ event_type|urlencode }}&wrap_id={{ wrap_id|urlencode }}">
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||
<span data-i18n="admin.audit.prev">Prev</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn" aria-disabled="true" disabled>
|
||||
<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 }}&event_type={{ event_type|urlencode }}&wrap_id={{ wrap_id|urlencode }}">
|
||||
<span data-i18n="admin.audit.next">Next</span>
|
||||
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn" aria-disabled="true" disabled>
|
||||
<span 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 %}
|
||||
@@ -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>
|
||||
@@ -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 & 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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 197 KiB |
@@ -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"
|
||||
@@ -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 -}}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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: {}
|
||||
@@ -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"
|
||||