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"
|
||||||