Compare commits

..

12 Commits

Author SHA1 Message Date
Sergey Antropoff 6fc20e0dc4 Добавлена связка Build → Deploy: после push образа триггерится Helm upgrade. 2026-07-27 15:17:12 +03:00
Sergey Antropoff dd573103db Добавлен Jenkinsfile.deploy для Helm upgrade wrapped в k3s; обновлён README. 2026-07-27 15:02:57 +03:00
Sergey Antropoff eacb808e12 Добавлен Jenkinsfile для multi-arch push в Harbor и Docker Hub; обновлён README. 2026-07-27 14:37:13 +03:00
Sergey Antropoff 9fda64d83e Исправлена прокрутка длинных строк в превью unwrap на десктопе и мобильном. 2026-07-27 12:52:42 +03:00
Sergey Antropoff 3eae2a212d Исключён каталог .cursor из репозитория через .gitignore.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 13:25:58 +03:00
Sergey Antropoff 743e14fc1a Исправлен push multi-arch релиза; обновлена документация с разделом «Что нового». 2026-07-23 13:15:31 +03:00
Sergey Antropoff 436e82f19e Перевести make release на multi-arch сборку через Buildx (amd64/arm64). 2026-07-23 12:57:17 +03:00
Sergey Antropoff 013a55edeb Добавить копирование текста и двухосевую прокрутку в превью unwrap. 2026-07-23 12:25:29 +03:00
Sergey Antropoff d3c23e3c3c Обновить README под статистику, аудит и парольный UX. 2026-07-19 00:13:45 +03:00
Sergey Antropoff 8361b8e3f2 Добавить статистику и очистку аудита; доработать UI расшифровки.
- Страница /admin/stats: MinIO, pending/consumed, загрузки, audit-метрики
- Кнопка «Очистить» в аудите с подтверждением
- Красивые callout’ы и ошибки пароля; пустой пароль не тратит попытку
- Убрать автоопределение языка; «Расшифруй» в RU UI
2026-07-18 23:50:44 +03:00
Sergey Antropoff 91719740b9 Улучшить unwrap, UI и админку; обновить документацию.
- Неверный пароль не сжигает wrap сразу: Argon2-гейт до consume и лимит попыток (по умолчанию 3) в настройках
- Подсветка синтаксиса, автоопределение языка, спиннеры, размеры файлов, пагинация audit
- make push (git, Ctrl-D) и актуальный README
2026-07-18 10:17:05 +03:00
Sergey Antropoff e4240a7be5 Init 2026-07-17 15:57:36 +03:00
43 changed files with 401 additions and 2481 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ RUN apt-get update \
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md VERSION ./ COPY pyproject.toml README.md ./
COPY app ./app COPY app ./app
COPY alembic ./alembic COPY alembic ./alembic
COPY alembic.ini ./ COPY alembic.ini ./
Vendored
+54 -79
View File
@@ -1,23 +1,24 @@
// Wrapped — CI: multi-arch push (Harbor + Docker Hub), затем асинхронный Deploy // Wrapped — CI: multi-arch (amd64+arm64) build & push to Harbor + Docker Hub
// //
// Ресурсы k3s: Builder и Deploy не должны держать два DinD-пода сразу. // Триггер: push в main (webhook Gitea → Jenkins Multibranch / Pipeline from SCM).
// 1) стадии сборки на agent label 'docker' (DinD) // После успешного Build & push → trigger job DEPLOY_JOB (Jenkinsfile.deploy).
// 2) при успехе — trigger Deploy с wait:false и agent none → Builder-под умирает
// 3) Deploy стартует отдельно уже без Builder
// //
// Версия образа = VERSION из коммита (без auto-bump). Ручной bump: make bump-patch. // Credentials в Jenkins (Manage Credentials → System → Global):
// harbor-devops-tools-push-pull-access — Username with password (Harbor devops-tools)
// docker-hub — Username with password (Docker Hub)
// //
// Credentials (Global): // Agent: cloud kubernetes, pod template default (labels docker / dind)
// harbor-devops-tools-push-pull-access — Harbor devops-tools (robot)
// docker-hub — Docker Hub (inecs)
// ssh-gitea-key — SCM checkout Gitea
// gitea-jenkins-token — Gitea PAT; для статусов коммита: write:repository
// k3s-kubeconfig — в job Deploy (Jenkinsfile.deploy)
// //
// Deploy job: devops-tools/wrapped/wrapped-deploy/main // Образы:
// hub.antropoff.ru/devops-tools/wrapped:<sha|0.1.0|latest>
// inecs/wrapped:<sha|0.1.0|latest>
//
// Связка Build → Deploy:
// Multibranch Deploy job, Script Path = Jenkinsfile.deploy
// путь по умолчанию: «Devops Tools/Wrapped Deploy/main» (поменяй DEPLOY_JOB при другом имени)
pipeline { pipeline {
agent none agent { label 'docker' }
options { options {
buildDiscarder(logRotator(numToKeepStr: '20')) buildDiscarder(logRotator(numToKeepStr: '20'))
@@ -30,20 +31,14 @@ pipeline {
HARBOR_REGISTRY = 'hub.antropoff.ru' HARBOR_REGISTRY = 'hub.antropoff.ru'
HARBOR_IMAGE = 'hub.antropoff.ru/devops-tools/wrapped' HARBOR_IMAGE = 'hub.antropoff.ru/devops-tools/wrapped'
DOCKERHUB_IMAGE = 'inecs/wrapped' DOCKERHUB_IMAGE = 'inecs/wrapped'
IMAGE_VERSION = '0.1.0'
RELEASE_TAG = "${env.GIT_COMMIT?.take(7) ?: 'dev'}"
BUILDX_BUILDER = "jenkins-wrapped-${env.BUILD_NUMBER}" BUILDX_BUILDER = "jenkins-wrapped-${env.BUILD_NUMBER}"
DEPLOY_JOB = 'devops-tools/wrapped/wrapped-deploy/main' // Multibranch: Folder/Job/branch — как в UI «Devops Tools » Wrapped Deploy » main»
DEPLOY_JOB = 'Devops Tools/Wrapped Deploy/main'
TZ = 'Europe/Moscow' TZ = 'Europe/Moscow'
} }
stages {
stage('Build image') {
when {
anyOf {
branch 'main'
branch 'master'
}
}
agent { label 'docker' }
stages { stages {
stage('Checkout') { stage('Checkout') {
steps { steps {
@@ -51,20 +46,13 @@ pipeline {
} }
} }
stage('Version') {
steps {
script {
// Без git в DinD-контейнере: dubious ownership → exit 128
env.IMAGE_VERSION = readFile('VERSION').trim()
if (!env.IMAGE_VERSION) {
error('VERSION file is empty')
}
echo "IMAGE_VERSION from VERSION → ${env.IMAGE_VERSION}"
}
}
}
stage('Build & push') { stage('Build & push') {
when {
anyOf {
branch 'main'
branch 'master'
}
}
steps { steps {
withCredentials([ withCredentials([
usernamePassword( usernamePassword(
@@ -82,9 +70,6 @@ pipeline {
sh ''' sh '''
set -eux set -eux
test -n "${IMAGE_VERSION}"
echo "Building tags: ${IMAGE_VERSION}, latest (no short-sha)"
echo "$HARBOR_PASS" | docker login "$HARBOR_REGISTRY" -u "$HARBOR_USER" --password-stdin echo "$HARBOR_PASS" | docker login "$HARBOR_REGISTRY" -u "$HARBOR_USER" --password-stdin
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USER" --password-stdin echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USER" --password-stdin
@@ -92,32 +77,35 @@ pipeline {
docker buildx create --name "$BUILDX_BUILDER" --driver docker-container --use docker buildx create --name "$BUILDX_BUILDER" --driver docker-container --use
docker buildx inspect --bootstrap >/dev/null docker buildx inspect --bootstrap >/dev/null
# Arch-слои только в Harbor (промежуточные теги), не в Docker Hub # Per-arch images → both registries (Harbor is happier than a single multi-arch --push)
docker buildx build \ docker buildx build \
--platform linux/amd64 \ --platform linux/amd64 \
--provenance=false --sbom=false --push \ --provenance=false --sbom=false --push \
-t "${HARBOR_IMAGE}:${IMAGE_VERSION}-amd64" \ -t "${HARBOR_IMAGE}:${RELEASE_TAG}-amd64" \
-t "${DOCKERHUB_IMAGE}:${RELEASE_TAG}-amd64" \
-f Dockerfile . -f Dockerfile .
docker buildx build \ docker buildx build \
--platform linux/arm64 \ --platform linux/arm64 \
--provenance=false --sbom=false --push \ --provenance=false --sbom=false --push \
-t "${HARBOR_IMAGE}:${IMAGE_VERSION}-arm64" \ -t "${HARBOR_IMAGE}:${RELEASE_TAG}-arm64" \
-t "${DOCKERHUB_IMAGE}:${RELEASE_TAG}-arm64" \
-f Dockerfile . -f Dockerfile .
# Публичные теги: только SemVer + latest # Multi-arch manifests: :sha, :0.1.0, :latest on each registry
for IMAGE in "$HARBOR_IMAGE" "$DOCKERHUB_IMAGE"; do for IMAGE in "$HARBOR_IMAGE" "$DOCKERHUB_IMAGE"; do
docker buildx imagetools create \ docker buildx imagetools create \
-t "${IMAGE}:${RELEASE_TAG}" \
-t "${IMAGE}:${IMAGE_VERSION}" \ -t "${IMAGE}:${IMAGE_VERSION}" \
-t "${IMAGE}:latest" \ -t "${IMAGE}:latest" \
"${HARBOR_IMAGE}:${IMAGE_VERSION}-amd64" \ "${IMAGE}:${RELEASE_TAG}-amd64" \
"${HARBOR_IMAGE}:${IMAGE_VERSION}-arm64" "${IMAGE}:${RELEASE_TAG}-arm64"
done done
echo "--- Harbor ---" echo "--- Harbor ---"
docker buildx imagetools inspect "${HARBOR_IMAGE}:${IMAGE_VERSION}" | sed -n '1,40p' docker buildx imagetools inspect "${HARBOR_IMAGE}:${RELEASE_TAG}" | sed -n '1,40p'
echo "--- Docker Hub ---" echo "--- Docker Hub ---"
docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${IMAGE_VERSION}" | sed -n '1,40p' docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${RELEASE_TAG}" | sed -n '1,40p'
docker buildx rm "$BUILDX_BUILDER" || true docker buildx rm "$BUILDX_BUILDER" || true
''' '''
@@ -125,29 +113,7 @@ pipeline {
} }
} }
} }
}
post {
always {
script {
try {
container('docker') {
sh 'docker buildx rm "$BUILDX_BUILDER" 2>/dev/null || true'
}
} catch (Ignored) {
// pod may already be gone
}
}
}
success {
echo "✓ Build OK: ${DOCKERHUB_IMAGE}:{${IMAGE_VERSION},latest}"
}
failure {
echo "✗ Build/push не удались — Deploy не запускается"
}
}
}
// Без DinD: только очередь Deploy, Builder-под уже освобождён
stage('Trigger deploy') { stage('Trigger deploy') {
when { when {
anyOf { anyOf {
@@ -155,21 +121,19 @@ pipeline {
branch 'master' branch 'master'
} }
} }
agent none
steps { steps {
script { script {
echo "Build finished — triggering ${DEPLOY_JOB} → ${DOCKERHUB_IMAGE}:${IMAGE_VERSION} (wait: false)" echo "Triggering ${DEPLOY_JOB} → ${DOCKERHUB_IMAGE}:${IMAGE_VERSION}"
build( build(
job: env.DEPLOY_JOB, job: env.DEPLOY_JOB,
wait: false, wait: true,
propagate: false, propagate: true,
parameters: [ parameters: [
string(name: 'IMAGE_REPOSITORY', value: env.DOCKERHUB_IMAGE), string(name: 'IMAGE_REPOSITORY', value: env.DOCKERHUB_IMAGE),
string(name: 'IMAGE_TAG', value: env.IMAGE_VERSION), string(name: 'IMAGE_TAG', value: env.IMAGE_VERSION),
string(name: 'IMAGE_PULL_POLICY', value: 'Always'), string(name: 'IMAGE_PULL_POLICY', value: 'Always'),
] ]
) )
echo "Deploy queued; Builder pipeline exits and releases cluster resources."
} }
} }
} }
@@ -177,12 +141,23 @@ pipeline {
post { post {
success { success {
echo "✓ Version: ${IMAGE_VERSION}" echo "✓ Harbor: ${HARBOR_IMAGE}:{${RELEASE_TAG},${IMAGE_VERSION},latest}"
echo "✓ Image: ${DOCKERHUB_IMAGE}:{${IMAGE_VERSION},latest}" echo "✓ Docker Hub: ${DOCKERHUB_IMAGE}:{${RELEASE_TAG},${IMAGE_VERSION},latest}"
echo "✓ Deploy: ${DEPLOY_JOB} triggered (async)" echo "✓ Deploy: ${DEPLOY_JOB} (${DOCKERHUB_IMAGE}:${IMAGE_VERSION})"
} }
failure { failure {
echo "✗ Сборка не удалась — см. лог Build image" echo "✗ Сборка, push или trigger deploy не удались — см. лог стадий"
}
always {
script {
try {
container('docker') {
sh 'docker buildx rm "$BUILDX_BUILDER" 2>/dev/null || true'
}
} catch (Ignored) {
// agent may already be gone
}
}
} }
} }
} }
+5 -11
View File
@@ -7,8 +7,8 @@
// Job: отдельный Multibranch Pipeline, Script Path = Jenkinsfile.deploy // Job: отдельный Multibranch Pipeline, Script Path = Jenkinsfile.deploy
// Имя по умолчанию (см. DEPLOY_JOB в Jenkinsfile): «Devops Tools/Wrapped Deploy» // Имя по умолчанию (см. DEPLOY_JOB в Jenkinsfile): «Devops Tools/Wrapped Deploy»
// Триггер: вручную (Build with Parameters) или автоматически из Jenkinsfile после Build & push // Триггер: вручную (Build with Parameters) или автоматически из Jenkinsfile после Build & push
// Credentials (Global): // Credentials:
// k3s-kubeconfig — Secret file (kubeconfig к K3S) // k3s-kubeconfig — Secret file (kubeconfig), как у devops.org.ru
// //
// Agent: cloud kubernetes, label docker (pod template с container docker) // Agent: cloud kubernetes, label docker (pod template с container docker)
// //
@@ -34,8 +34,8 @@ pipeline {
) )
string( string(
name: 'IMAGE_TAG', name: 'IMAGE_TAG',
defaultValue: '', defaultValue: '0.1.0',
description: 'Тег образа (SemVer из CI). Пусто = cat VERSION из репозитория' description: 'Тег образа (0.1.0 / latest / short-sha после CI build)'
) )
choice( choice(
name: 'IMAGE_PULL_POLICY', name: 'IMAGE_PULL_POLICY',
@@ -112,15 +112,9 @@ pipeline {
exit 1 exit 1
fi fi
# IMAGE_TAG пустой → из VERSION (автоверсионирование)
if [ -z "${IMAGE_TAG}" ]; then
IMAGE_TAG="$(tr -d '[:space:]' < VERSION)"
export IMAGE_TAG
fi
test -n "${IMAGE_TAG}"
echo "Upgrading ${HELM_RELEASE}: ${IMAGE_REPOSITORY}:${IMAGE_TAG} (${IMAGE_PULL_POLICY})" echo "Upgrading ${HELM_RELEASE}: ${IMAGE_REPOSITORY}:${IMAGE_TAG} (${IMAGE_PULL_POLICY})"
# Как addon: helm upgrade … --atomic --wait # Как addon: helm upgrade --install … --atomic --wait
# --reuse-values сохраняет Ingress/секреты/DB/S3 с кластера # --reuse-values сохраняет Ingress/секреты/DB/S3 с кластера
helm upgrade "$HELM_RELEASE" "$HELM_CHART" \ helm upgrade "$HELM_RELEASE" "$HELM_CHART" \
--namespace "$HELM_NAMESPACE" \ --namespace "$HELM_NAMESPACE" \
+7 -28
View File
@@ -1,9 +1,8 @@
.PHONY: help env build up down restart logs shell migrate revision release buildx-setup helm-package helm-lint clean ps push bump-patch bump-minor version-commit .PHONY: help env build up down restart logs shell migrate revision release buildx-setup helm-package helm-lint clean ps push
COMPOSE ?= docker compose COMPOSE ?= docker compose
IMAGE_NAME ?= wrapped IMAGE_NAME ?= wrapped
# SemVer from VERSION (source of truth); override: make release IMAGE_TAG=1.2.3 IMAGE_TAG ?= 0.1.0
IMAGE_TAG ?= $(shell cat VERSION 2>/dev/null || echo 0.1.0)
# Docker Hub namespace → image: $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) # Docker Hub namespace → image: $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
# Example: inecs/wrapped:0.1.0 # Example: inecs/wrapped:0.1.0
RELEASE_REGISTRY ?= inecs RELEASE_REGISTRY ?= inecs
@@ -28,21 +27,17 @@ help:
@echo " make shell Shell into app container" @echo " make shell Shell into app container"
@echo " make migrate Run alembic upgrade head" @echo " make migrate Run alembic upgrade head"
@echo " make revision m=\"msg\" Create alembic revision" @echo " make revision m=\"msg\" Create alembic revision"
@echo " make bump-patch VERSION +0.0.1 (pyproject + Helm)"
@echo " make bump-minor VERSION +0.1.0"
@echo " make release Multi-arch build & push $(FULL_IMAGE)" @echo " make release Multi-arch build & push $(FULL_IMAGE)"
@echo " platforms: $(RELEASE_PLATFORMS)" @echo " platforms: $(RELEASE_PLATFORMS)"
@echo " make push git add/commit (prompt) + push (без auto-bump)" @echo " make push git add ., commit (prompt), push origin"
@echo " make helm-lint Lint Helm chart" @echo " make helm-lint Lint Helm chart"
@echo " make helm-package Package Helm chart" @echo " make helm-package Package Helm chart"
@echo " make clean Remove containers, volumes, local image" @echo " make clean Remove containers, volumes, local image"
@echo "" @echo ""
@echo "Version: $$(cat VERSION 2>/dev/null || echo 0.1.0)"
@echo "Release examples:" @echo "Release examples:"
@echo " make release # tag from VERSION" @echo " make release IMAGE_TAG=0.1.0"
@echo " make release IMAGE_TAG=1.2.3" @echo " make release IMAGE_TAG=0.1.0 PUSH=0"
@echo " make release PUSH=0" @echo " make release IMAGE_TAG=0.1.0 RELEASE_PLATFORMS=linux/arm64"
@echo " make release RELEASE_PLATFORMS=linux/arm64"
env: env:
@test -f .env || cp .env.example .env @test -f .env || cp .env.example .env
@@ -136,23 +131,7 @@ clean:
-docker buildx rm $(BUILDX_BUILDER) 2>/dev/null || true -docker buildx rm $(BUILDX_BUILDER) 2>/dev/null || true
rm -rf dist rm -rf dist
# Ручной SemVer (не вызывается из make push). # Same flow as proxmox_api_simulator: stage all, multiline commit via Ctrl-D, push origin.
bump-patch:
@PYTHONPATH=. python3 scripts/bump_version.py patch
@echo "VERSION → $$(cat VERSION)"
bump-minor:
@PYTHONPATH=. python3 scripts/bump_version.py minor
@echo "VERSION → $$(cat VERSION)"
version-commit:
@git add VERSION pyproject.toml helm/wrapped/Chart.yaml helm/wrapped/values.yaml
@if git diff --cached --quiet; then \
echo "Version unchanged — no version commit"; \
else \
git commit -m "Bump version to $$(cat VERSION)."; \
fi
push: push:
@set -e; \ @set -e; \
git add .; \ git add .; \
+12 -37
View File
@@ -27,7 +27,7 @@
| **Расшифровка** | Прокрутка превью текста по вертикали и горизонтали; кнопка **Копировать** рядом со скачиванием | | **Расшифровка** | Прокрутка превью текста по вертикали и горизонтали; кнопка **Копировать** рядом со скачиванием |
| **Пароль** | Argon2-проверка *до* выдачи ciphertext; лимит попыток; пустой пароль попытку не тратит; структурированные ошибки в UI | | **Пароль** | Argon2-проверка *до* выдачи ciphertext; лимит попыток; пустой пароль попытку не тратит; структурированные ошибки в UI |
| **Админка** | Стартовая **Статистика** (MinIO, wraps, audit); очистка audit с подтверждением; пагинация журнала | | **Админка** | Стартовая **Статистика** (MinIO, wraps, audit); очистка audit с подтверждением; пагинация журнала |
| **Релиз** | Авто-SemVer (`VERSION`); Jenkins CI+CD: multi-arch → Harbor/Hub; Helm upgrade в k3s | | **Релиз** | `make release` / Jenkins CI+CD: multi-arch → Harbor и Docker Hub; Helm upgrade в k3s (`Jenkinsfile.deploy`, как addon-wrapped) |
Подробности — в разделах ниже (безопасность, админка, сборка образа). Подробности — в разделах ниже (безопасность, админка, сборка образа).
@@ -338,25 +338,16 @@ services:
Ключ в `#fragment` не уходит на сервер в запросе страницы. Ключ в `#fragment` не уходит на сервер в запросе страницы.
### UX (v0.1.3+)
- После создания: **QR** на share-link, иконки **скачать QR (PNG)** и **Web Share** (если есть `navigator.share`) под QR; отдельные кнопки Copy для ссылки / токена / пароля; пароль не советуется слать в той же переписке.
- Create: счётчик размера `≈ used / max`; **число открытий** 1–3 (потолок в админке); опционально **«доступно с»** (дата + время); человеческие TTL (в т.ч. «до вечера»).
- Unwrap: Enter в поле пароля; focus+select при ошибках пароля; zip all; trust-строка; экран **ещё недоступно** до `available_from`; при N>1 ciphertext остаётся до последнего открытия.
- Картинки: lightbox. Тема: `prefers-color-scheme` при первом визите; haptic на Copy. PWA manifest.
- Страница [`/verify`](/verify) — как проверить ZK-модель.
- UI EN/RU. Статика с `?v=версия.mtime`.
--- ---
## Админка ## Админка
Логин: `/admin/login`. После входа — **Статистика** (`/admin/stats`). На мобиле меню — через гамбургер. Логин: `/admin/login`. После входа — **Статистика** (`/admin/stats`).
| Раздел | URL | Что делает | | Раздел | URL | Что делает |
|--------|-----|------------| |--------|-----|------------|
| **Статистика** | `/admin/stats` | MinIO pending ciphertext; успешные unwrap vs сожжения паролем; pending / uploads; таблица статусов (count/size/items); with password; 24h/7d creates; audit create/unwrap; разбивка fail-причин unwrap (all-time и 24h) | | **Статистика** | `/admin/stats` | MinIO: объём и число объектов нерасшифрованного ciphertext; расшифровано / pending; загрузки и items за всё время; таблица по статусам wraps; с паролем; создано за 24ч/7д; счётчики audit (create/unwrap ok/fail) |
| **Настройки** | `/admin/settings` | Limits (upload, TTL, retention audit), rate limits, MIME allowlist (в т.ч. YAML / `application/octet-stream` для kubeconfig и неизвестных типов), пароль, CAPTCHA | | **Настройки** | `/admin/settings` | Limits (upload, TTL, retention audit), rate limits, MIME allowlist, пароль (режим + лимит попыток), CAPTCHA |
| **Аудит** | `/admin/audit` | Фильтры, пагинация (10/25/50/100), номера страниц, кнопка **Очистить** (с подтверждением; пишется событие `admin.audit_clear`) | | **Аудит** | `/admin/audit` | Фильтры, пагинация (10/25/50/100), номера страниц, кнопка **Очистить** (с подтверждением; пишется событие `admin.audit_clear`) |
| **Опасная зона** | `/admin/danger` | Полная очистка wraps и объектов в MinIO (`PURGE`) | | **Опасная зона** | `/admin/danger` | Полная очистка wraps и объектов в MinIO (`PURGE`) |
@@ -441,30 +432,14 @@ docker buildx build \
| Harbor | `hub.antropoff.ru/devops-tools/wrapped` | | Harbor | `hub.antropoff.ru/devops-tools/wrapped` |
| Docker Hub | `inecs/wrapped` | | Docker Hub | `inecs/wrapped` |
Публичные теги на каждый реестр: `:<semver>` (из `VERSION` в коммите) и `:latest`. Short-sha больше не пушится. Промежуточные `:<semver>-amd64` / `-arm64` остаются только в Harbor для сборки multi-arch manifest. Теги на каждый реестр: `:<short-sha>`, `:0.1.0`, `:latest` (плюс служебные `:<sha>-amd64` / `:<sha>-arm64`).
### Версионирование Credentials (как в актуальном `Jenkinsfile`):
Источник правды — файл [`VERSION`](VERSION) (SemVer). Синхронизируется в `pyproject.toml` и Helm (`Chart.yaml`, `values.yaml` image.tag).
| Где | Поведение |
|-----|-----------|
| UI (модалка «?») | бейдж `vX.Y.Z` |
| `make bump-patch` / `bump-minor` | ручной bump (по желанию) |
| `make push` | commit/push **без** auto-bump |
| Jenkins (`Jenkinsfile`) | читает `VERSION` из коммита **без** доп. bump → те же теги в образе и на кластере |
Что в `VERSION` запушили — то и уйдёт в Harbor/Hub/деплой (и в футер модалки внутри образа).
Credentials (Global, как в job’ах):
| ID | Тип | Назначение | | ID | Тип | Назначение |
|----|-----|------------| |----|-----|------------|
| `ssh-gitea-key` | SSH Username with private key | SCM checkout | | `harbor-devops-tools-push-pull-access` | Username/password | Harbor `devops-tools` |
| `gitea-jenkins-token` | Secret text (Gitea PAT) | SCM/API Gitea. Для commit status из Multibranch нужен scope **`write:repository`** (сейчас при read-only в логе: `Could not send notifications` / 403/405). Checkout достаточно `read:repository` | | `docker-hub` | Username/password | Docker Hub |
| `harbor-devops-tools-push-pull-access` | Username/password | Harbor `devops-tools` (robot) |
| `docker-hub` | Username/password | Docker Hub `inecs` |
| `k3s-kubeconfig` | Secret file | Helm/kubectl deploy в K3S |
Agent: label `docker` (pod template с container `docker` / DinD). Agent: label `docker` (pod template с container `docker` / DinD).
@@ -496,14 +471,14 @@ kubectl -n wrapped rollout status deployment/wrapped
| Параметр job | Умолч. | Смысл | | Параметр job | Умолч. | Смысл |
|--------------|--------|--------| |--------------|--------|--------|
| `IMAGE_REPOSITORY` | `inecs/wrapped` | как `wrapped_image_repository` | | `IMAGE_REPOSITORY` | `inecs/wrapped` | как `wrapped_image_repository` |
| `IMAGE_TAG` | из `VERSION` / CI | SemVer образа (`Always` подтянет новый) | | `IMAGE_TAG` | `0.1.0` | как `wrapped_image_tag` (`latest` / short-sha после build) |
| `IMAGE_PULL_POLICY` | `Always` | как в addon | | `IMAGE_PULL_POLICY` | `Always` | как в addon |
Credential: `k3s-kubeconfig` (Secret file) — kubeconfig к K3S. Credential: `k3s-kubeconfig` (Secret file) — тот же, что у devops.org.ru.
Рекомендуемая связка job’ов уже в [`Jenkinsfile`](Jenkinsfile): после успешного **Build image** (DinD) стадия **Trigger deploy** с `wait: false` и `agent none` — под Builder освобождается, затем отдельно стартует Deploy. Так на кластере не нужны два DinD одновременно. Рекомендуемая связка job’ов уже в [`Jenkinsfile`](Jenkinsfile): после успешного **Build & push** стадия **Trigger deploy** вызывает Multibranch job `Devops Tools/Wrapped Deploy/main` с параметрами `inecs/wrapped` / `0.1.0` / `Always` (`wait: true`). Если имя folder/job другое — поменяй `DEPLOY_JOB` в `Jenkinsfile`.
Ручной запуск Deploy: **Build with Parameters** (пустой `IMAGE_TAG``cat VERSION`). Ручной запуск Deploy по-прежнему: **Build with Parameters**.
### Git (`make push`) ### Git (`make push`)
-1
View File
@@ -1 +0,0 @@
0.1.4
@@ -1,57 +0,0 @@
"""max_opens / available_from for wraps; max_opens_limit in settings
Revision ID: 003_opens_available_from
Revises: 002_password_attempts
Create Date: 2026-07-29
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "003_opens_available_from"
down_revision: Union[str, None] = "002_password_attempts"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"app_settings",
sa.Column(
"max_opens_limit",
sa.Integer(),
nullable=False,
server_default="3",
),
)
op.add_column(
"wraps",
sa.Column(
"max_opens",
sa.Integer(),
nullable=False,
server_default="1",
),
)
op.add_column(
"wraps",
sa.Column(
"opens_used",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"wraps",
sa.Column("available_from", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("wraps", "available_from")
op.drop_column("wraps", "opens_used")
op.drop_column("wraps", "max_opens")
op.drop_column("app_settings", "max_opens_limit")
-4
View File
@@ -210,9 +210,6 @@ async def settings_save(
row.password_max_attempts = min( row.password_max_attempts = min(
50, max(1, as_int("password_max_attempts", row.password_max_attempts or 3)) 50, max(1, as_int("password_max_attempts", row.password_max_attempts or 3))
) )
row.max_opens_limit = min(
10, max(1, as_int("max_opens_limit", getattr(row, "max_opens_limit", None) or 3))
)
row.turnstile_site_key = str(form.get("turnstile_site_key") or "").strip() row.turnstile_site_key = str(form.get("turnstile_site_key") or "").strip()
row.hcaptcha_site_key = str(form.get("hcaptcha_site_key") or "").strip() row.hcaptcha_site_key = str(form.get("hcaptcha_site_key") or "").strip()
@@ -411,7 +408,6 @@ async def admin_settings_api(
"captcha_provider": row.captcha_provider.value, "captcha_provider": row.captcha_provider.value,
"password_mode": row.password_mode.value, "password_mode": row.password_mode.value,
"password_max_attempts": row.password_max_attempts, "password_max_attempts": row.password_max_attempts,
"max_opens_limit": getattr(row, "max_opens_limit", None) or 3,
"audit_retention_days": row.audit_retention_days, "audit_retention_days": row.audit_retention_days,
"rate_limit_create_per_minute": row.rate_limit_create_per_minute, "rate_limit_create_per_minute": row.rate_limit_create_per_minute,
"rate_limit_unwrap_per_minute": row.rate_limit_unwrap_per_minute, "rate_limit_unwrap_per_minute": row.rate_limit_unwrap_per_minute,
+7 -75
View File
@@ -84,11 +84,6 @@ async def create_wrap(
if body.ttl_seconds > settings.max_ttl_seconds: if body.ttl_seconds > settings.max_ttl_seconds:
raise HTTPException(status_code=400, detail="ttl_too_large") raise HTTPException(status_code=400, detail="ttl_too_large")
opens_limit = max(1, min(10, int(getattr(settings, "max_opens_limit", None) or 3)))
max_opens = int(body.max_opens or 1)
if max_opens < 1 or max_opens > opens_limit:
raise HTTPException(status_code=400, detail="max_opens_invalid")
try: try:
ciphertext = base64.b64decode(body.ciphertext_b64, validate=True) ciphertext = base64.b64decode(body.ciphertext_b64, validate=True)
except Exception as exc: except Exception as exc:
@@ -129,18 +124,6 @@ async def create_wrap(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=body.ttl_seconds) expires_at = now + timedelta(seconds=body.ttl_seconds)
available_from = body.available_from
if available_from is not None:
if available_from.tzinfo is None:
available_from = available_from.replace(tzinfo=timezone.utc)
else:
available_from = available_from.astimezone(timezone.utc)
# Allow ~2 minutes of clock skew into the past
if available_from < now - timedelta(minutes=2):
raise HTTPException(status_code=400, detail="available_from_past")
if available_from >= expires_at:
raise HTTPException(status_code=400, detail="available_from_after_expiry")
await storage.put_bytes(object_key, ciphertext) await storage.put_bytes(object_key, ciphertext)
wrap = Wrap( wrap = Wrap(
@@ -154,9 +137,6 @@ async def create_wrap(
password_hash=password_hash, password_hash=password_hash,
password_mode=settings.password_mode, password_mode=settings.password_mode,
expires_at=expires_at, expires_at=expires_at,
available_from=available_from,
max_opens=max_opens,
opens_used=0,
creator_ip=ip, creator_ip=ip,
creator_ua=(meta["user_agent"] or "")[:512] or None, creator_ua=(meta["user_agent"] or "")[:512] or None,
) )
@@ -176,8 +156,6 @@ async def create_wrap(
"ttl_seconds": body.ttl_seconds, "ttl_seconds": body.ttl_seconds,
"has_password": body.has_password, "has_password": body.has_password,
"password_mode": settings.password_mode.value, "password_mode": settings.password_mode.value,
"max_opens": max_opens,
"available_from": available_from.isoformat() if available_from else None,
}, },
) )
@@ -186,8 +164,6 @@ async def create_wrap(
expires_at=expires_at, expires_at=expires_at,
password_mode=settings.password_mode.value, password_mode=settings.password_mode.value,
share_path=f"/w/{wrap_id}", share_path=f"/w/{wrap_id}",
max_opens=max_opens,
available_from=available_from,
) )
@@ -261,8 +237,7 @@ async def unwrap(
fail("unavailable") fail("unavailable")
assert wrap is not None assert wrap is not None
now = datetime.now(timezone.utc) if wrap.expires_at <= datetime.now(timezone.utc):
if wrap.expires_at <= now:
wrap.status = WrapStatus.expired wrap.status = WrapStatus.expired
await delete_wrap_object(db, wrap) await delete_wrap_object(db, wrap)
await db.commit() await db.commit()
@@ -276,26 +251,6 @@ async def unwrap(
) )
fail("unavailable") fail("unavailable")
if wrap.available_from is not None and wrap.available_from > now:
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={
"reason": "not_yet_available",
"available_from": wrap.available_from.isoformat(),
},
)
raise HTTPException(
status_code=403,
detail={
"code": "not_yet_available",
"available_from": wrap.available_from.isoformat(),
},
)
if wrap.has_password and wrap.password_hash: if wrap.has_password and wrap.password_hash:
max_attempts = max(1, int(settings.password_max_attempts or 3)) max_attempts = max(1, int(settings.password_max_attempts or 3))
# Empty password: ask to enter it, do not burn an attempt. # Empty password: ask to enter it, do not burn an attempt.
@@ -376,25 +331,23 @@ async def unwrap(
}, },
) )
# Atomic open: increment opens_used while still under max_opens # Atomic consume
from sqlalchemy import update from sqlalchemy import update
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
max_opens = max(1, int(wrap.max_opens or 1))
upd = await db.execute( upd = await db.execute(
update(Wrap) update(Wrap)
.where( .where(
Wrap.id == wrap_id, Wrap.id == wrap_id,
Wrap.status == WrapStatus.pending, Wrap.status == WrapStatus.pending,
Wrap.expires_at > now, Wrap.expires_at > now,
Wrap.opens_used < Wrap.max_opens,
) )
.values(opens_used=Wrap.opens_used + 1) .values(status=WrapStatus.consumed, consumed_at=now)
.returning(Wrap.id, Wrap.opens_used, Wrap.max_opens) .returning(Wrap.id)
) )
row = upd.one_or_none() consumed = upd.scalar_one_or_none()
await db.commit() await db.commit()
if not row: if not consumed:
await write_audit( await write_audit(
db, db,
event_type="wrap.unwrap", event_type="wrap.unwrap",
@@ -405,11 +358,6 @@ async def unwrap(
) )
fail("unavailable") fail("unavailable")
opens_used = int(row.opens_used)
max_opens = max(1, int(row.max_opens or 1))
opens_remaining = max(0, max_opens - opens_used)
destroyed = opens_remaining == 0
try: try:
data = await storage.get_bytes(wrap.object_key) data = await storage.get_bytes(wrap.object_key)
except Exception: except Exception:
@@ -422,16 +370,7 @@ async def unwrap(
details={"reason": "storage_error"}, details={"reason": "storage_error"},
) )
raise HTTPException(status_code=500, detail="storage_error") from None raise HTTPException(status_code=500, detail="storage_error") from None
finally:
if destroyed:
result = await db.execute(select(Wrap).where(Wrap.id == wrap_id))
wrap_row = result.scalar_one_or_none()
if wrap_row and wrap_row.status == WrapStatus.pending:
wrap_row.status = WrapStatus.consumed
wrap_row.consumed_at = now
await delete_wrap_object(db, wrap_row)
await db.commit()
else:
await delete_wrap_object(db, wrap) await delete_wrap_object(db, wrap)
await write_audit( await write_audit(
@@ -444,9 +383,6 @@ async def unwrap(
"size_bytes": wrap.size_bytes, "size_bytes": wrap.size_bytes,
"item_count": wrap.item_count, "item_count": wrap.item_count,
"content_types": wrap.content_types, "content_types": wrap.content_types,
"opens_used": opens_used,
"max_opens": max_opens,
"destroyed": destroyed,
}, },
) )
@@ -457,8 +393,4 @@ async def unwrap(
has_password=wrap.has_password, has_password=wrap.has_password,
password_mode=wrap.password_mode.value, password_mode=wrap.password_mode.value,
size_bytes=wrap.size_bytes, size_bytes=wrap.size_bytes,
max_opens=max_opens,
opens_used=opens_used,
opens_remaining=opens_remaining,
destroyed=destroyed,
) )
+2 -24
View File
@@ -14,8 +14,6 @@ from app.config import get_settings
from app.db import SessionLocal from app.db import SessionLocal
from app.services.settings_service import get_or_create_settings from app.services.settings_service import get_or_create_settings
from app.services.storage import storage from app.services.storage import storage
from app.static_url import static_url
from app.version import get_app_version, get_app_version_label
OPENAPI_TAGS = [ OPENAPI_TAGS = [
{ {
@@ -75,10 +73,9 @@ def custom_openapi(app: FastAPI):
def create_app() -> FastAPI: def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
docs_on = settings.is_docs_enabled docs_on = settings.is_docs_enabled
app_version = get_app_version()
app = FastAPI( app = FastAPI(
title=settings.app_name, title=settings.app_name,
version=app_version, version="0.1.0",
description=( description=(
"Wrapped — zero-knowledge one-time encrypted drop.\n\n" "Wrapped — zero-knowledge one-time encrypted drop.\n\n"
"Public wrap APIs are anonymous. Admin APIs require HTTP Basic." "Public wrap APIs are anonymous. Admin APIs require HTTP Basic."
@@ -100,21 +97,10 @@ def create_app() -> FastAPI:
app.include_router(admin.api_router) app.include_router(admin.api_router)
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
# Callables so Jinja re-reads VERSION (dev mount / after bump) on each render
templates.env.globals["app_version"] = get_app_version
templates.env.globals["app_version_label"] = get_app_version_label
templates.env.globals["static_url"] = static_url
admin.templates.env.globals["app_version"] = get_app_version
admin.templates.env.globals["app_version_label"] = get_app_version_label
admin.templates.env.globals["static_url"] = static_url
@app.get("/health", tags=["System"], summary="Health check", include_in_schema=docs_on) @app.get("/health", tags=["System"], summary="Health check", include_in_schema=docs_on)
async def health(): async def health():
return { return {"status": "ok", "service": settings.app_name}
"status": "ok",
"service": settings.app_name,
"version": get_app_version(),
}
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
async def index(request: Request): async def index(request: Request):
@@ -140,14 +126,6 @@ def create_app() -> FastAPI:
{"title": "Unwrap", "page": "unwrap", "wrap_id": ""}, {"title": "Unwrap", "page": "unwrap", "wrap_id": ""},
) )
@app.get("/verify", include_in_schema=False)
async def verify_page(request: Request):
return templates.TemplateResponse(
request,
"verify.html",
{"title": "Verify", "page": "verify"},
)
@app.exception_handler(HTTPException) @app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException): async def http_exception_handler(request: Request, exc: HTTPException):
if ( if (
-9
View File
@@ -65,8 +65,6 @@ class AppSettings(Base):
) )
# Wrong unwrap passwords allowed before the wrap is burned (default 3). # Wrong unwrap passwords allowed before the wrap is burned (default 3).
password_max_attempts: Mapped[int] = mapped_column(Integer, default=3) password_max_attempts: Mapped[int] = mapped_column(Integer, default=3)
# Max allowed max_opens on create (UI shows 1…min(3, limit)).
max_opens_limit: Mapped[int] = mapped_column(Integer, default=3)
audit_retention_days: Mapped[int] = mapped_column(Integer, default=90) audit_retention_days: Mapped[int] = mapped_column(Integer, default=90)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
@@ -98,9 +96,6 @@ class Wrap(Base):
DateTime(timezone=True), server_default=func.now(), index=True DateTime(timezone=True), server_default=func.now(), index=True
) )
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
available_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
max_opens: Mapped[int] = mapped_column(Integer, default=1)
opens_used: Mapped[int] = mapped_column(Integer, default=0)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=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_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
creator_ua: Mapped[str | None] = mapped_column(String(512), nullable=True) creator_ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
@@ -143,10 +138,6 @@ DEFAULT_MIME_ALLOWLIST = [
"text/javascript", "text/javascript",
"application/json", "application/json",
"application/xml", "application/xml",
"application/yaml",
"application/x-yaml",
"text/yaml",
"application/octet-stream",
"application/pdf", "application/pdf",
"application/zip", "application/zip",
"application/x-zip-compressed", "application/x-zip-compressed",
-10
View File
@@ -17,7 +17,6 @@ class PublicSettingsOut(BaseModel):
password_mode: str password_mode: str
password_mode_description: dict[str, str] password_mode_description: dict[str, str]
password_max_attempts: int password_max_attempts: int
max_opens_limit: int
class WrapCreateRequest(BaseModel): class WrapCreateRequest(BaseModel):
@@ -28,8 +27,6 @@ class WrapCreateRequest(BaseModel):
has_password: bool = False has_password: bool = False
password: str | None = None password: str | None = None
captcha_token: str | None = None captcha_token: str | None = None
max_opens: int = Field(default=1, ge=1, le=10)
available_from: datetime | None = None
class WrapCreateResponse(BaseModel): class WrapCreateResponse(BaseModel):
@@ -37,8 +34,6 @@ class WrapCreateResponse(BaseModel):
expires_at: datetime expires_at: datetime
password_mode: str password_mode: str
share_path: str share_path: str
max_opens: int = 1
available_from: datetime | None = None
class UnwrapRequest(BaseModel): class UnwrapRequest(BaseModel):
@@ -53,10 +48,6 @@ class UnwrapResponse(BaseModel):
has_password: bool has_password: bool
password_mode: str password_mode: str
size_bytes: int size_bytes: int
max_opens: int = 1
opens_used: int = 1
opens_remaining: int = 0
destroyed: bool = True
class AdminLoginRequest(BaseModel): class AdminLoginRequest(BaseModel):
@@ -77,7 +68,6 @@ class AdminSettingsUpdate(BaseModel):
hcaptcha_site_key: str | None = None hcaptcha_site_key: str | None = None
password_mode: str | None = None password_mode: str | None = None
password_max_attempts: int | None = Field(default=None, ge=1, le=50) password_max_attempts: int | None = Field(default=None, ge=1, le=50)
max_opens_limit: int | None = Field(default=None, ge=1, le=10)
audit_retention_days: int | None = None audit_retention_days: int | None = None
-1
View File
@@ -70,5 +70,4 @@ def public_settings_payload(row: AppSettings) -> dict:
"password_mode": row.password_mode.value, "password_mode": row.password_mode.value,
"password_mode_description": PASSWORD_MODE_HELP, "password_mode_description": PASSWORD_MODE_HELP,
"password_max_attempts": max(1, int(row.password_max_attempts or 3)), "password_max_attempts": max(1, int(row.password_max_attempts or 3)),
"max_opens_limit": max(1, min(10, int(getattr(row, "max_opens_limit", None) or 3))),
} }
+1 -54
View File
@@ -117,51 +117,6 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]:
else: else:
bucket["fail"] += int(count or 0) bucket["fail"] += int(count or 0)
reason_col = AuditEvent.details.op("->>")("reason")
password_burns = int(
(
await db.execute(
select(func.count())
.select_from(AuditEvent)
.where(
AuditEvent.event_type == "wrap.unwrap",
AuditEvent.success.is_(False),
reason_col == "password_attempts_exceeded",
)
)
).scalar_one()
or 0
)
async def unwrap_fail_reasons(since: datetime | None = None) -> list[dict[str, Any]]:
filters = [
AuditEvent.event_type == "wrap.unwrap",
AuditEvent.success.is_(False),
]
if since is not None:
filters.append(AuditEvent.created_at >= since)
# One expression for SELECT + GROUP BY (PG rejects duplicate binds as unequal)
reason_key = func.coalesce(reason_col, "unknown")
rows = (
await db.execute(
select(
reason_key,
func.count(AuditEvent.id),
)
.where(*filters)
.group_by(reason_key)
.order_by(func.count(AuditEvent.id).desc())
)
).all()
return [
{"reason": str(reason or "unknown"), "count": int(count or 0)}
for reason, count in rows
]
unwrap_fail_reasons_all = await unwrap_fail_reasons()
unwrap_fail_reasons_24h = await unwrap_fail_reasons(day_ago)
# MinIO live objects under wraps/ # MinIO live objects under wraps/
try: try:
minio = await storage.prefix_stats("wraps/") minio = await storage.prefix_stats("wraps/")
@@ -173,7 +128,6 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]:
pending = by_status["pending"] pending = by_status["pending"]
consumed = by_status["consumed"] consumed = by_status["consumed"]
expired = by_status["expired"] expired = by_status["expired"]
audit_unwraps_ok = audit.get("wrap.unwrap", {}).get("ok", 0)
return { return {
"wraps_total": int(total_wraps or 0), "wraps_total": int(total_wraps or 0),
@@ -183,8 +137,6 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]:
"wraps_with_password": int(with_password or 0), "wraps_with_password": int(with_password or 0),
"items_total": int(total_items or 0), "items_total": int(total_items or 0),
"items_pending": pending["items"], "items_pending": pending["items"],
"items_consumed": consumed["items"],
"items_expired": expired["items"],
"size_total_bytes": int(total_size or 0), "size_total_bytes": int(total_size or 0),
"size_pending_bytes": pending["size_bytes"], "size_pending_bytes": pending["size_bytes"],
"size_consumed_bytes": consumed["size_bytes"], "size_consumed_bytes": consumed["size_bytes"],
@@ -192,18 +144,13 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]:
"size_total_human": format_bytes(int(total_size or 0)), "size_total_human": format_bytes(int(total_size or 0)),
"size_pending_human": format_bytes(pending["size_bytes"]), "size_pending_human": format_bytes(pending["size_bytes"]),
"size_consumed_human": format_bytes(consumed["size_bytes"]), "size_consumed_human": format_bytes(consumed["size_bytes"]),
"size_expired_human": format_bytes(expired["size_bytes"]),
"created_24h": created_24h, "created_24h": created_24h,
"created_7d": created_7d, "created_7d": created_7d,
"consumed_24h": consumed_24h, "consumed_24h": consumed_24h,
"audit_creates_ok": audit.get("wrap.create", {}).get("ok", 0), "audit_creates_ok": audit.get("wrap.create", {}).get("ok", 0),
"audit_creates_fail": audit.get("wrap.create", {}).get("fail", 0), "audit_creates_fail": audit.get("wrap.create", {}).get("fail", 0),
"audit_unwraps_ok": audit_unwraps_ok, "audit_unwraps_ok": audit.get("wrap.unwrap", {}).get("ok", 0),
"audit_unwraps_fail": audit.get("wrap.unwrap", {}).get("fail", 0), "audit_unwraps_fail": audit.get("wrap.unwrap", {}).get("fail", 0),
"unwraps_success": audit_unwraps_ok,
"password_burns": password_burns,
"unwrap_fail_reasons": unwrap_fail_reasons_all,
"unwrap_fail_reasons_24h": unwrap_fail_reasons_24h,
"minio_objects": int(minio.get("objects") or 0), "minio_objects": int(minio.get("objects") or 0),
"minio_bytes": int(minio.get("bytes") or 0), "minio_bytes": int(minio.get("bytes") or 0),
"minio_human": format_bytes(int(minio.get("bytes") or 0)), "minio_human": format_bytes(int(minio.get("bytes") or 0)),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

+100 -614
View File
@@ -113,7 +113,6 @@ html[data-theme="light"] body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 0.75rem; padding: 0.85rem 0.75rem;
max-width: none; max-width: none;
width: 100%; width: 100%;
@@ -178,8 +177,7 @@ html[data-theme="light"] body {
50% { filter: saturate(1.2); transform: scale(1.04); } 50% { filter: saturate(1.2); transform: scale(1.04); }
} }
.top-actions { display: flex; gap: 0.5rem; overflow: visible; position: relative; z-index: 5; } .top-actions { display: flex; gap: 0.5rem; }
.topbar { overflow: visible; }
.icon-btn { .icon-btn {
min-width: 42px; min-width: 42px;
@@ -203,10 +201,6 @@ html[data-theme="dark"] .icon-btn:hover {
box-shadow: none; box-shadow: none;
} }
.icon { width: 18px; height: 18px; } .icon { width: 18px; height: 18px; }
.icon-btn i {
font-size: 1.05rem;
line-height: 1;
}
.hidden { display: none !important; } .hidden { display: none !important; }
.shell { .shell {
@@ -253,56 +247,7 @@ html[data-theme="dark"] .icon-btn:hover {
line-height: 1.15; line-height: 1.15;
} }
.verify-sections { .lede { color: var(--muted); margin: 0 0 1.5rem; max-width: 42rem; }
display: grid;
gap: 1rem;
margin: 0 0 1.25rem;
}
.verify-block h2 {
margin: 0 0 0.35rem;
font-size: 1.05rem;
}
.verify-block p {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.datetime-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
}
.datetime-split .input-with-action > input[type="date"],
.datetime-split .input-with-action > input[type="time"] {
width: 100%;
min-width: 0;
padding-right: 2.75rem;
color-scheme: dark;
}
html[data-theme="light"] .datetime-split .input-with-action > input[type="date"],
html[data-theme="light"] .datetime-split .input-with-action > input[type="time"] {
color-scheme: light;
}
.datetime-split .input-with-action > input[type="date"]::-webkit-calendar-picker-indicator,
.datetime-split .input-with-action > input[type="time"]::-webkit-calendar-picker-indicator {
opacity: 0;
position: absolute;
right: 0;
width: 2.5rem;
height: 100%;
cursor: pointer;
}
.field-label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
font-size: 0.88rem;
}
@media (max-width: 720px) {
.datetime-split {
grid-template-columns: 1fr;
}
}
.composer, .success-panel, .result-panel { .composer, .success-panel, .result-panel {
display: grid; display: grid;
@@ -313,14 +258,6 @@ html[data-theme="light"] .datetime-split .input-with-action > input[type="time"]
.result-panel > .success-callout { .result-panel > .success-callout {
justify-self: stretch; justify-self: stretch;
} }
.result-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.result-panel > .success-trust {
margin: 0;
}
.success-panel { .success-panel {
justify-items: stretch; justify-items: stretch;
text-align: left; text-align: left;
@@ -332,165 +269,6 @@ html[data-theme="light"] .datetime-split .input-with-action > input[type="time"]
.success-panel > .btn.ghost { .success-panel > .btn.ghost {
justify-self: center; justify-self: center;
} }
.success-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1.25rem 1.5rem;
align-items: start;
min-width: 0;
}
.success-main {
display: grid;
gap: 0.55rem;
min-width: 0;
}
.success-main > label {
margin-top: 0.35rem;
}
.success-password-block {
display: grid;
gap: 0.45rem;
margin-bottom: 0.35rem;
}
.success-password-badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
width: fit-content;
max-width: 100%;
margin: 0;
padding: 0.4rem 0.7rem;
border-radius: 10px;
border: 1px solid rgba(61, 214, 198, 0.35);
background: rgba(61, 214, 198, 0.1);
color: var(--accent-2);
font-size: 0.82rem;
font-weight: 650;
}
.success-password-hint {
margin: 0;
font-size: 0.84rem;
line-height: 1.4;
}
.size-meter {
margin: 0;
font-variant-numeric: tabular-nums;
}
.size-meter.is-warn {
color: #e8a838;
}
.size-meter.is-over {
color: var(--danger);
}
.success-qr {
display: grid;
justify-items: center;
gap: 0.45rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
min-width: 0;
overflow: visible;
}
.share-qr {
width: 180px;
height: 180px;
display: grid;
place-items: center;
background: #fff;
border-radius: 12px;
padding: 0.5rem;
box-sizing: border-box;
}
.share-qr img,
.share-qr canvas {
display: block;
max-width: 100%;
height: auto;
}
.success-qr-hint {
margin: 0;
text-align: center;
font-size: 0.78rem;
max-width: 11rem;
line-height: 1.35;
}
.success-qr-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
margin-top: 0.15rem;
}
.qr-action-btn {
position: relative;
width: 2.35rem;
height: 2.35rem;
border: 1px solid var(--line);
border-radius: 10px;
background: transparent;
color: var(--muted);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.qr-action-btn:hover,
.qr-action-btn:focus-visible {
color: var(--accent-2);
background: rgba(75, 134, 240, 0.1);
border-color: rgba(75, 134, 240, 0.35);
outline: none;
}
.qr-action-btn:active {
transform: scale(0.96);
}
.qr-action-btn .ui-tooltip {
left: 50%;
right: auto;
bottom: calc(100% + 0.5rem);
transform: translateX(-50%) translateY(4px);
white-space: nowrap;
}
.qr-action-btn .ui-tooltip::after {
left: 50%;
right: auto;
transform: translateX(-50%);
}
.qr-action-btn:hover .ui-tooltip,
.qr-action-btn:focus-visible .ui-tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%);
}
.success-panel > .expires-meta {
width: 100%;
justify-self: stretch;
margin-top: 0.15rem;
}
@media (max-width: 860px) {
.success-layout {
grid-template-columns: 1fr;
}
.success-qr {
order: -1;
justify-self: center;
width: min(100%, 280px);
}
.share-qr {
width: min(220px, 70vw);
height: min(220px, 70vw);
}
.copy-row {
grid-template-columns: 1fr;
}
.copy-row .btn {
width: 100%;
}
}
.success-callout { .success-callout {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -659,28 +437,12 @@ input, select, textarea, .code-input {
} }
.lang-modal-panel { .lang-modal-panel {
width: min(480px, 100%); width: min(480px, 100%);
display: flex;
flex-direction: column;
overflow: hidden;
}
.lang-modal-panel .modal-title,
.lang-modal-panel .modal-message,
.lang-modal-panel .modal-actions {
flex-shrink: 0;
}
.lang-modal-panel .modal-message {
margin-bottom: 0.75rem;
} }
.lang-grid { .lang-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem; gap: 0.45rem;
margin-bottom: 0.85rem; margin-bottom: 1rem;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
} }
@media (max-width: 520px) { @media (max-width: 520px) {
.lang-grid { grid-template-columns: 1fr; } .lang-grid { grid-template-columns: 1fr; }
@@ -854,7 +616,6 @@ body.busy-open { overflow: hidden; }
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
align-items: center;
padding: 0.55rem 0.75rem; padding: 0.55rem 0.75rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: 10px;
@@ -866,22 +627,6 @@ body.busy-open { overflow: hidden; }
color: var(--danger); color: var(--danger);
cursor: pointer; cursor: pointer;
} }
.success-meta-badges {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.success-meta-badge {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.65rem;
border-radius: 10px;
border: 1px solid rgba(110, 168, 255, 0.28);
background: rgba(110, 168, 255, 0.1);
color: var(--text);
font-size: 0.8rem;
font-weight: 600;
}
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; } .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem; } .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem; }
@@ -1039,24 +784,108 @@ html[data-theme="light"] .form-alert-attempts {
text-align: center; text-align: center;
color: var(--muted); color: var(--muted);
font-size: 0.85rem; font-size: 0.85rem;
padding: 0.75rem 1.25rem 2rem; padding: 0.5rem 1.25rem 2.25rem;
max-width: 760px; max-width: 760px;
margin: 0 auto; margin: 0 auto;
} }
.footer-pillars {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.55rem 0.7rem;
}
.footer-pillars li {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.55rem 0.85rem;
border: 1px solid rgba(110, 168, 255, 0.22);
border-radius: 12px;
background: linear-gradient(160deg, rgba(110, 168, 255, 0.1), rgba(13, 20, 32, 0.35));
color: var(--text);
text-align: left;
backdrop-filter: blur(8px);
max-width: 240px;
}
html[data-theme="light"] .footer-pillars li {
background: linear-gradient(160deg, rgba(47, 111, 237, 0.1), rgba(255, 255, 255, 0.85));
border-color: rgba(47, 111, 237, 0.2);
}
.footer-pillars i {
color: var(--accent-2);
font-size: 0.95rem;
width: 1.1rem;
text-align: center;
flex-shrink: 0;
}
.footer-pillars span {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.footer-pillars strong {
font-size: 0.8rem;
font-weight: 650;
letter-spacing: 0.01em;
line-height: 1.2;
}
.footer-pillars small {
font-size: 0.7rem;
color: var(--muted);
font-weight: 500;
line-height: 1.25;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.site-footer { .site-footer {
max-width: 100%; max-width: 100%;
padding: 0.5rem 0.85rem 1.5rem; padding: 0.35rem 0.6rem 1.5rem;
}
.footer-pillars {
flex-wrap: nowrap;
gap: 0.3rem;
width: 100%;
}
.footer-pillars li {
flex: 1 1 0;
max-width: none;
min-width: 0;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.2rem;
padding: 0.4rem 0.3rem;
border-radius: 9px;
text-align: center;
}
.footer-pillars i {
font-size: 0.72rem;
width: auto;
}
.footer-pillars span {
align-items: center;
gap: 0.05rem;
}
.footer-pillars strong {
font-size: 0.62rem;
line-height: 1.15;
}
.footer-pillars small {
font-size: 0.55rem;
line-height: 1.2;
} }
.footer-copy { .footer-copy {
margin-top: 0.65rem;
font-size: 0.72rem; font-size: 0.72rem;
} }
} }
.footer-copy { .footer-copy {
margin: 0; margin: 0.85rem 0 0;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--muted); color: var(--muted);
text-align: center;
} }
.footer-copy a { .footer-copy a {
color: var(--accent-2); color: var(--accent-2);
@@ -1067,113 +896,6 @@ html[data-theme="light"] .form-alert-attempts {
text-decoration: underline; text-decoration: underline;
} }
.about-modal-panel {
width: min(624px, 100%);
}
.about-modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
margin-bottom: 0.15rem;
}
.about-modal-head .modal-title {
margin: 0;
flex: 1;
min-width: 0;
}
.about-version {
flex-shrink: 0;
margin-top: 0.15rem;
padding: 0.28rem 0.65rem;
border-radius: 999px;
border: 1px solid rgba(110, 168, 255, 0.28);
background: linear-gradient(160deg, rgba(110, 168, 255, 0.16), rgba(61, 214, 198, 0.08));
color: var(--accent-2);
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1;
white-space: nowrap;
}
html[data-theme="light"] .about-version {
border-color: rgba(47, 111, 237, 0.25);
background: linear-gradient(160deg, rgba(47, 111, 237, 0.12), rgba(14, 160, 140, 0.08));
color: var(--accent);
}
.about-modal-body {
display: grid;
gap: 0.75rem;
margin: 0.85rem 0 1.1rem;
}
.about-modal-body p {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
line-height: 1.5;
}
.about-verify-link {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.25rem;
padding: 0.75rem 0.85rem;
border-radius: 12px;
border: 1px solid rgba(61, 214, 198, 0.28);
background: linear-gradient(135deg, rgba(61, 214, 198, 0.1), rgba(110, 168, 255, 0.08));
color: var(--text);
text-decoration: none;
transition: border-color 0.15s, background 0.15s, transform 0.15s;
}
.about-verify-link:hover,
.about-verify-link:focus-visible {
border-color: rgba(110, 168, 255, 0.45);
background: linear-gradient(135deg, rgba(61, 214, 198, 0.14), rgba(110, 168, 255, 0.14));
outline: none;
transform: translateY(-1px);
}
.about-verify-icon {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
display: grid;
place-items: center;
border-radius: 10px;
background: rgba(61, 214, 198, 0.14);
color: var(--accent);
font-size: 0.95rem;
}
.about-verify-text {
display: grid;
gap: 0.15rem;
min-width: 0;
flex: 1;
}
.about-verify-text strong {
font-size: 0.92rem;
font-weight: 650;
color: var(--text);
}
.about-verify-text span {
font-size: 0.78rem;
color: var(--muted);
line-height: 1.35;
}
.about-verify-arrow {
flex-shrink: 0;
color: var(--accent-2);
font-size: 0.85rem;
opacity: 0.85;
}
html[data-theme="light"] .about-verify-link {
border-color: rgba(47, 111, 237, 0.22);
background: linear-gradient(135deg, rgba(14, 160, 140, 0.08), rgba(47, 111, 237, 0.08));
}
.about-modal-panel .modal-actions {
justify-content: flex-end;
}
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 1px; height: 1px; width: 1px; height: 1px;
@@ -1255,110 +977,6 @@ html[data-theme="light"] .about-verify-link {
transform: none; transform: none;
} }
.item-card img,
.item-preview-img {
display: block;
max-width: 100%;
width: auto;
height: auto;
border-radius: 10px;
margin-top: 0.65rem;
cursor: zoom-in;
}
.item-preview-hint {
margin: 0.4rem 0 0;
font-size: 0.78rem;
}
.unwrap-state {
display: grid;
justify-items: center;
gap: 0.75rem;
text-align: center;
padding: 1.5rem 0.5rem 0.5rem;
min-width: 0;
}
.unwrap-state.hidden {
display: none;
}
.unwrap-state-icon {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: 18px;
border: 1px solid rgba(110, 168, 255, 0.25);
background: linear-gradient(160deg, rgba(110, 168, 255, 0.14), rgba(13, 20, 32, 0.35));
color: var(--accent-2);
font-size: 1.45rem;
}
html[data-theme="light"] .unwrap-state-icon {
background: linear-gradient(160deg, rgba(47, 111, 237, 0.12), rgba(255, 255, 255, 0.9));
}
.unwrap-state h2 {
margin: 0;
font-size: 1.35rem;
font-weight: 650;
max-width: 22rem;
line-height: 1.25;
}
.unwrap-state .lede {
margin: 0;
max-width: 28rem;
}
.unwrap-state .btn {
margin-top: 0.35rem;
}
.image-lightbox {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
grid-template-rows: auto 1fr auto;
align-items: center;
justify-items: center;
gap: 0.75rem;
padding: max(0.75rem, env(safe-area-inset-top, 0px))
max(0.75rem, env(safe-area-inset-right, 0px))
max(0.75rem, env(safe-area-inset-bottom, 0px))
max(0.75rem, env(safe-area-inset-left, 0px));
background: rgba(6, 10, 18, 0.88);
backdrop-filter: blur(6px);
}
.image-lightbox.hidden {
display: none;
}
.image-lightbox-close {
justify-self: end;
width: 42px;
height: 42px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(20, 28, 42, 0.7);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.image-lightbox img {
max-width: min(100%, 960px);
max-height: min(78dvh, 78vh);
width: auto;
height: auto;
object-fit: contain;
border-radius: 12px;
cursor: default;
}
.image-lightbox-caption {
margin: 0;
color: rgba(255, 255, 255, 0.85);
font-size: 0.9rem;
text-align: center;
max-width: 90vw;
word-break: break-word;
}
.items { .items {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@@ -1412,6 +1030,11 @@ html[data-theme="light"] .item-card pre,
html[data-theme="light"] .item-card .item-text-pre { html[data-theme="light"] .item-card .item-text-pre {
background: rgba(16, 32, 56, 0.05); background: rgba(16, 32, 56, 0.05);
} }
.item-card img {
max-width: 100%;
border-radius: 8px;
margin-top: 0.6rem;
}
/* Admin */ /* Admin */
.admin-body { min-height: 100vh; } .admin-body { min-height: 100vh; }
@@ -1481,15 +1104,6 @@ html[data-theme="light"] .item-card .item-text-pre {
margin: 0; margin: 0;
font-size: 0.82rem; font-size: 0.82rem;
} }
.admin-top-tools {
display: flex;
align-items: center;
gap: 0.45rem;
flex-shrink: 0;
}
.admin-nav-toggle {
display: none;
}
.admin-top-right { .admin-top-right {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1515,10 +1129,6 @@ html[data-theme="light"] .item-card .item-text-pre {
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
} }
.admin-nav-logout-form {
display: inline;
margin: 0;
}
.admin-nav-link { .admin-nav-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1562,113 +1172,6 @@ html[data-theme="light"] .item-card .item-text-pre {
html[data-theme="light"] .admin-nav { html[data-theme="light"] .admin-nav {
box-shadow: 0 8px 24px rgba(20, 40, 70, 0.08); box-shadow: 0 8px 24px rgba(20, 40, 70, 0.08);
} }
@media (max-width: 1015px) {
.admin-top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
"brand tools"
"nav nav";
align-items: center;
gap: 0.55rem 0.65rem;
padding: 0.7rem 0.65rem;
}
.admin-top > .brand {
grid-area: brand;
min-width: 0;
}
.admin-top-tools {
grid-area: tools;
}
.admin-top .brand-tagline {
max-width: 42vw;
}
.admin-nav-toggle {
display: grid;
}
.admin-nav {
grid-area: nav;
display: none;
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
width: 100%;
max-height: min(70dvh, 28rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 0.4rem;
gap: 0.2rem;
}
.admin-top.is-nav-open .admin-nav {
display: flex;
}
.admin-nav-link {
width: 100%;
justify-content: flex-start;
padding: 0.7rem 0.85rem;
font-size: 0.92rem;
}
.admin-nav-logout-form {
display: block;
width: 100%;
}
.admin-nav-logout-form .admin-nav-link {
width: 100%;
}
.admin-shell {
padding: 0 0.65rem 2rem;
}
.admin-shell > h1 {
font-size: 1.35rem;
margin-bottom: 0.75rem;
}
.admin-tablist {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
.admin-tab {
flex: 0 0 auto;
}
.admin-tabpanel {
padding: 0.9rem;
}
.audit-filters {
display: grid;
grid-template-columns: 1fr;
align-items: stretch;
}
.audit-field,
.audit-field-grow {
min-width: 0;
width: 100%;
}
.audit-filter-btn {
width: 100%;
height: auto;
min-height: 42px;
}
.audit-table {
min-width: 720px;
}
.admin-login-toggles {
top: max(0.75rem, env(safe-area-inset-top, 0px));
right: max(0.75rem, env(safe-area-inset-right, 0px));
}
}
@media (min-width: 1016px) {
.admin-top > .brand {
flex: 0 0 auto;
}
.admin-nav {
margin-left: auto;
}
}
.danger-page { .danger-page {
max-width: 520px; max-width: 520px;
} }
@@ -1789,9 +1292,12 @@ html[data-theme="light"] .admin-nav {
} }
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem; gap: 0.75rem;
} }
@media (max-width: 1100px) {
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 620px) { @media (max-width: 620px) {
.stats-grid { grid-template-columns: 1fr; } .stats-grid { grid-template-columns: 1fr; }
} }
@@ -2167,11 +1673,6 @@ body.modal-open {
display: grid; display: grid;
place-items: center; place-items: center;
padding: 1.25rem; padding: 1.25rem;
/* iOS: безопасные отступы от notch / home indicator */
padding: max(0.75rem, env(safe-area-inset-top, 0px))
max(0.75rem, env(safe-area-inset-right, 0px))
max(0.75rem, env(safe-area-inset-bottom, 0px))
max(0.75rem, env(safe-area-inset-left, 0px));
} }
.modal-root.hidden { .modal-root.hidden {
display: none; display: none;
@@ -2188,10 +1689,6 @@ html[data-theme="light"] .modal-backdrop {
.modal-panel { .modal-panel {
position: relative; position: relative;
width: min(420px, 100%); width: min(420px, 100%);
max-height: calc(100vh - 1.5rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 16px; border-radius: 16px;
@@ -2199,17 +1696,6 @@ html[data-theme="light"] .modal-backdrop {
padding: 1.35rem 1.4rem 1.25rem; padding: 1.35rem 1.4rem 1.25rem;
animation: modal-in 0.18s ease-out; animation: modal-in 0.18s ease-out;
} }
@supports (height: 100dvh) {
.modal-panel {
max-height: calc(100dvh - 1.5rem);
}
}
/* Список языков: скролл внутри сетки, заголовок и Cancel остаются на экране */
.modal-panel.lang-modal-panel {
overflow: hidden;
display: flex;
flex-direction: column;
}
@keyframes modal-in { @keyframes modal-in {
from { opacity: 0; transform: translateY(8px) scale(0.98); } from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: none; } to { opacity: 1; transform: none; }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

-36
View File
@@ -1,36 +0,0 @@
(() => {
const modal = document.getElementById("about-modal");
const toggle = document.getElementById("about-toggle");
if (!modal || !toggle) return;
function syncToggleLabels() {
const label = window.WrappedI18n?.t("about.open") || "About Wrapped";
toggle.setAttribute("title", label);
toggle.setAttribute("aria-label", label);
}
function openModal() {
modal.classList.remove("hidden");
document.body.classList.add("modal-open");
modal.querySelector("[data-about-close].btn")?.focus?.();
}
function closeModal() {
modal.classList.add("hidden");
document.body.classList.remove("modal-open");
toggle.focus?.();
}
toggle.addEventListener("click", openModal);
modal.addEventListener("click", (e) => {
if (e.target.closest("[data-about-close]")) closeModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !modal.classList.contains("hidden")) closeModal();
});
if (window.WrappedI18n?.onChange) {
window.WrappedI18n.onChange(syncToggleLabels);
}
syncToggleLabels();
})();
-52
View File
@@ -1,52 +0,0 @@
(() => {
const top = document.getElementById("admin-top");
const toggle = document.getElementById("admin-nav-toggle");
const nav = document.getElementById("admin-nav");
if (!top || !toggle || !nav) return;
const mq = window.matchMedia("(max-width: 1015px)");
const syncLabel = () => {
const open = top.classList.contains("is-nav-open");
const key = open ? "admin.nav.closeMenu" : "admin.nav.openMenu";
const label = window.WrappedI18n?.t(key) || (open ? "Close menu" : "Menu");
toggle.setAttribute("aria-label", label);
toggle.setAttribute("title", label);
};
const setOpen = (open) => {
top.classList.toggle("is-nav-open", open);
toggle.setAttribute("aria-expanded", open ? "true" : "false");
const icon = toggle.querySelector("i");
if (icon) {
icon.className = open ? "fa-solid fa-xmark" : "fa-solid fa-bars";
}
syncLabel();
};
toggle.addEventListener("click", () => {
setOpen(!top.classList.contains("is-nav-open"));
});
nav.addEventListener("click", (e) => {
if (!mq.matches) return;
if (e.target.closest("a.admin-nav-link")) setOpen(false);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && top.classList.contains("is-nav-open")) {
setOpen(false);
toggle.focus();
}
});
mq.addEventListener("change", () => {
if (!mq.matches) setOpen(false);
else syncLabel();
});
if (window.WrappedI18n?.onChange) {
window.WrappedI18n.onChange(syncLabel);
}
syncLabel();
})();
+10 -252
View File
@@ -1,6 +1,5 @@
(() => { (() => {
const t = (k, vars) => window.WrappedI18n.t(k, vars); const t = (k, vars) => window.WrappedI18n.t(k, vars);
/** @type {File[]} */
const files = []; const files = [];
let settings = null; let settings = null;
let captchaWidgetId = null; let captchaWidgetId = null;
@@ -15,11 +14,6 @@
highlight: document.getElementById("code-highlight"), highlight: document.getElementById("code-highlight"),
editor: document.getElementById("code-editor"), editor: document.getElementById("code-editor"),
ttl: document.getElementById("ttl-seconds"), ttl: document.getElementById("ttl-seconds"),
maxOpens: document.getElementById("max-opens"),
availableFromDate: document.getElementById("available-from-date"),
availableFromTime: document.getElementById("available-from-time"),
availableFromDateBtn: document.getElementById("available-from-date-btn"),
availableFromTimeBtn: document.getElementById("available-from-time-btn"),
password: document.getElementById("password"), password: document.getElementById("password"),
generatePassword: document.getElementById("generate-password"), generatePassword: document.getElementById("generate-password"),
dropzone: document.getElementById("dropzone"), dropzone: document.getElementById("dropzone"),
@@ -31,15 +25,8 @@
composer: document.querySelector(".composer"), composer: document.querySelector(".composer"),
shareLink: document.getElementById("share-link"), shareLink: document.getElementById("share-link"),
shareToken: document.getElementById("share-token"), shareToken: document.getElementById("share-token"),
sharePassword: document.getElementById("share-password"),
passwordBlock: document.getElementById("success-password-block"),
successMetaBadges: document.getElementById("success-meta-badges"),
shareQr: document.getElementById("share-qr"),
expiresMeta: document.getElementById("expires-meta"), expiresMeta: document.getElementById("expires-meta"),
captchaSlot: document.getElementById("captcha-slot"), captchaSlot: document.getElementById("captcha-slot"),
sizeMeter: document.getElementById("size-meter"),
shareNative: document.getElementById("share-native"),
downloadQr: document.getElementById("download-qr"),
}; };
function showError(msg) { function showError(msg) {
@@ -113,39 +100,11 @@
document.body.classList.remove("modal-open"); document.body.classList.remove("modal-open");
} }
function estimatePayloadBytes() {
const textBytes = new TextEncoder().encode(els.text?.value || "").length;
const fileBytes = files.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
return textBytes + fileBytes;
}
function refreshSizeMeter() {
if (!els.sizeMeter || !settings) return;
const used = estimatePayloadBytes();
const max = Number(settings.max_upload_bytes) || 0;
if (!max) {
els.sizeMeter.textContent = "";
els.sizeMeter.classList.remove("is-warn", "is-over");
return;
}
const fmt = window.WrappedUI.formatBytes;
let text = t("create.sizeMeter", {
used: fmt(used),
max: fmt(max),
});
const near = used > max * 0.85 && used <= max;
const over = used > max;
if (near) text = `${text} · ${t("create.sizeNearLimit")}`;
els.sizeMeter.textContent = text;
els.sizeMeter.classList.toggle("is-over", over);
els.sizeMeter.classList.toggle("is-warn", near);
}
function renderFiles() { function renderFiles() {
els.fileList.innerHTML = ""; els.fileList.innerHTML = "";
files.forEach((f, idx) => { files.forEach((f, idx) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.innerHTML = `<span>${escapeHtml(f.name)} <small>(${escapeHtml(f.type || "file")} · ${window.WrappedUI.formatBytes(f.size)})</small></span>`; li.innerHTML = `<span>${f.name} <small>(${f.type || "file"} · ${window.WrappedUI.formatBytes(f.size)})</small></span>`;
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.textContent = "×"; btn.textContent = "×";
@@ -156,15 +115,6 @@
li.appendChild(btn); li.appendChild(btn);
els.fileList.appendChild(li); els.fileList.appendChild(li);
}); });
refreshSizeMeter();
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }
function addFiles(list) { function addFiles(list) {
@@ -179,26 +129,15 @@
} }
let lastExpiresAt = null; let lastExpiresAt = null;
let lastAvailableFrom = null;
let lastMaxOpens = 1;
function secondsUntilEvening() {
const now = new Date();
const target = new Date(now);
target.setHours(20, 0, 0, 0);
if (target <= now) target.setDate(target.getDate() + 1);
return Math.max(60, Math.round((target.getTime() - now.getTime()) / 1000));
}
function fillTtl() { function fillTtl() {
if (!settings || !els.ttl) return; if (!settings || !els.ttl) return;
const max = settings.max_ttl_seconds; const max = settings.max_ttl_seconds;
const def = settings.default_ttl_seconds; const def = settings.default_ttl_seconds;
const selected = els.ttl.value ? Number(els.ttl.value) : def; const selected = els.ttl.value ? Number(els.ttl.value) : def;
const evening = secondsUntilEvening();
const options = [ const options = [
{ key: "create.ttl.1h", value: 3600 }, { key: "create.ttl.1h", value: 3600 },
{ key: "create.ttl.evening", value: evening }, { key: "create.ttl.6h", value: 6 * 3600 },
{ key: "create.ttl.24h", value: 24 * 3600 }, { key: "create.ttl.24h", value: 24 * 3600 },
{ key: "create.ttl.3d", value: 3 * 24 * 3600 }, { key: "create.ttl.3d", value: 3 * 24 * 3600 },
{ key: "create.ttl.7d", value: 7 * 24 * 3600 }, { key: "create.ttl.7d", value: 7 * 24 * 3600 },
@@ -218,58 +157,6 @@
.join(""); .join("");
} }
function fillMaxOpens() {
if (!els.maxOpens || !settings) return;
const limit = Math.max(1, Math.min(10, Number(settings.max_opens_limit) || 3));
const uiMax = Math.min(3, limit);
const selected = els.maxOpens.value ? Number(els.maxOpens.value) : 1;
const pick = selected >= 1 && selected <= uiMax ? selected : 1;
els.maxOpens.innerHTML = Array.from({ length: uiMax }, (_, i) => i + 1)
.map(
(n) =>
`<option value="${n}" ${n === pick ? "selected" : ""}>${t("create.maxOpensOption", { n })}</option>`
)
.join("");
}
function availableFromToIso() {
const date = (els.availableFromDate?.value || "").trim();
if (!date) return null;
const time = (els.availableFromTime?.value || "").trim() || "00:00";
const d = new Date(`${date}T${time}`);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}
function formatLocalDateTime(iso) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return new Intl.DateTimeFormat(window.WrappedI18n.locale?.() || undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(d);
}
function refreshSuccessBadges() {
if (!els.successMetaBadges) return;
els.successMetaBadges.innerHTML = "";
if (lastMaxOpens > 1) {
const b = document.createElement("span");
b.className = "success-meta-badge";
b.textContent = t("create.opensBadge", { n: lastMaxOpens });
els.successMetaBadges.appendChild(b);
}
if (lastAvailableFrom) {
const b = document.createElement("span");
b.className = "success-meta-badge";
b.textContent = t("create.availableFromBadge", {
datetime: formatLocalDateTime(lastAvailableFrom),
});
els.successMetaBadges.appendChild(b);
}
}
function refreshExpiresMeta() { function refreshExpiresMeta() {
if (lastExpiresAt && els.expiresMeta) { if (lastExpiresAt && els.expiresMeta) {
els.expiresMeta.innerHTML = window.WrappedI18n.formatExpiresHtml els.expiresMeta.innerHTML = window.WrappedI18n.formatExpiresHtml
@@ -317,12 +204,9 @@
const resp = await fetch("/api/v1/settings"); const resp = await fetch("/api/v1/settings");
settings = await resp.json(); settings = await resp.json();
fillTtl(); fillTtl();
fillMaxOpens();
loadCaptcha(); loadCaptcha();
setLanguage("plaintext", { silent: true }); setLanguage("plaintext", { silent: true });
refreshHighlight(); refreshHighlight();
refreshSizeMeter();
syncShareControls();
} }
els.dropzone.addEventListener("click", () => els.fileInput.click()); els.dropzone.addEventListener("click", () => els.fileInput.click());
@@ -359,7 +243,6 @@
els.text.addEventListener("input", () => { els.text.addEventListener("input", () => {
refreshHighlight(); refreshHighlight();
refreshSizeMeter();
}); });
els.text.addEventListener("scroll", () => { els.text.addEventListener("scroll", () => {
const pre = els.highlight.parentElement; const pre = els.highlight.parentElement;
@@ -377,105 +260,19 @@
} }
}); });
function renderShareQr(link) {
if (!els.shareQr) return;
els.shareQr.innerHTML = "";
if (!link || typeof QRCode === "undefined") return;
const size = window.matchMedia("(max-width: 860px)").matches ? 200 : 164;
// eslint-disable-next-line no-new
new QRCode(els.shareQr, {
text: link,
width: size,
height: size,
colorDark: "#0d1420",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M,
});
}
function syncShareControls() {
if (els.shareNative) {
const canShare = typeof navigator.share === "function";
els.shareNative.classList.toggle("hidden", !canShare);
els.shareNative.setAttribute("aria-label", t("create.share"));
}
if (els.downloadQr) {
els.downloadQr.setAttribute("aria-label", t("create.downloadQr"));
}
}
function showSuccess({ link, token, password, expiresAt, availableFrom, maxOpens }) {
els.shareLink.value = link;
els.shareToken.value = token;
lastExpiresAt = expiresAt;
lastAvailableFrom = availableFrom || null;
lastMaxOpens = maxOpens || 1;
refreshExpiresMeta();
refreshSuccessBadges();
if (password) {
els.sharePassword.value = password;
els.passwordBlock.classList.remove("hidden");
} else {
els.sharePassword.value = "";
els.passwordBlock.classList.add("hidden");
}
renderShareQr(link);
syncShareControls();
els.composer.classList.add("hidden");
els.success.classList.remove("hidden");
els.success.scrollIntoView({ behavior: "smooth", block: "start" });
}
async function copyFrom(input, btn) { async function copyFrom(input, btn) {
try {
await window.WrappedUI.copyText(input.value);
} catch {
await navigator.clipboard.writeText(input.value); await navigator.clipboard.writeText(input.value);
}
const old = btn.textContent; const old = btn.textContent;
btn.textContent = t("common.copied"); btn.textContent = t("common.copied");
setTimeout(() => (btn.textContent = old), 1200); setTimeout(() => (btn.textContent = old), 1200);
} }
function downloadQrPng() {
if (!els.shareQr) return;
const canvas = els.shareQr.querySelector("canvas");
const img = els.shareQr.querySelector("img");
let href = "";
if (canvas && canvas.toDataURL) {
href = canvas.toDataURL("image/png");
} else if (img?.src) {
href = img.src;
}
if (!href) return;
const a = document.createElement("a");
a.href = href;
a.download = "wrapped-qr.png";
a.click();
}
document.getElementById("copy-link").addEventListener("click", () => { document.getElementById("copy-link").addEventListener("click", () => {
copyFrom(els.shareLink, document.getElementById("copy-link")); copyFrom(els.shareLink, document.getElementById("copy-link"));
}); });
document.getElementById("copy-token").addEventListener("click", () => { document.getElementById("copy-token").addEventListener("click", () => {
copyFrom(els.shareToken, document.getElementById("copy-token")); copyFrom(els.shareToken, document.getElementById("copy-token"));
}); });
document.getElementById("copy-password")?.addEventListener("click", () => {
copyFrom(els.sharePassword, document.getElementById("copy-password"));
});
els.shareNative?.addEventListener("click", async () => {
if (typeof navigator.share !== "function" || !els.shareLink.value) return;
try {
await navigator.share({
title: t("create.shareTitle"),
text: t("create.shareText"),
url: els.shareLink.value,
});
} catch {
/* user cancelled or unsupported */
}
});
els.downloadQr?.addEventListener("click", downloadQrPng);
document.getElementById("create-another").addEventListener("click", () => { document.getElementById("create-another").addEventListener("click", () => {
location.reload(); location.reload();
}); });
@@ -512,8 +309,6 @@
} }
const password = els.password.value || ""; const password = els.password.value || "";
const maxOpens = Number(els.maxOpens?.value || 1);
const availableFromIso = availableFromToIso();
els.wrapBtn.disabled = true; els.wrapBtn.disabled = true;
window.WrappedUI.showBusy(t("create.working")); window.WrappedUI.showBusy(t("create.working"));
try { try {
@@ -534,10 +329,9 @@
content_types: contentTypes, content_types: contentTypes,
item_count: items.length, item_count: items.length,
has_password: Boolean(password), has_password: Boolean(password),
// Always send password when set so server can gate wrong guesses.
password: password || null, password: password || null,
captcha_token: captchaToken() || null, captcha_token: captchaToken() || null,
max_opens: maxOpens,
available_from: availableFromIso,
}; };
const resp = await fetch("/api/v1/wraps", { const resp = await fetch("/api/v1/wraps", {
method: "POST", method: "POST",
@@ -546,33 +340,18 @@
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
const detail = err.detail; showError(err.detail || t("common.error"));
if (detail === "available_from_past") {
showError(t("create.availableFromPast"));
return;
}
if (detail === "available_from_after_expiry") {
showError(t("create.availableFromAfterExpiry"));
return;
}
if (detail === "max_opens_invalid") {
showError(t("create.maxOpensInvalid"));
return;
}
showError(typeof detail === "string" ? detail : t("common.error"));
return; return;
} }
const data = await resp.json(); const data = await resp.json();
const token = window.WrappedCrypto.buildToken(data.wrap_id, keyB64url); const token = window.WrappedCrypto.buildToken(data.wrap_id, keyB64url);
const link = `${location.origin}${data.share_path}#${keyB64url}`; const link = `${location.origin}${data.share_path}#${keyB64url}`;
showSuccess({ els.shareLink.value = link;
link, els.shareToken.value = token;
token, lastExpiresAt = data.expires_at;
password, refreshExpiresMeta();
expiresAt: data.expires_at, els.composer.classList.add("hidden");
availableFrom: data.available_from, els.success.classList.remove("hidden");
maxOpens: data.max_opens || maxOpens,
});
} catch { } catch {
showError(t("common.error")); showError(t("common.error"));
} finally { } finally {
@@ -597,31 +376,10 @@
els.password.select(); els.password.select();
}); });
function openPicker(input) {
if (!input) return;
try {
if (typeof input.showPicker === "function") {
input.showPicker();
return;
}
} catch {
/* ignore */
}
input.focus();
input.click();
}
els.availableFromDateBtn?.addEventListener("click", () => openPicker(els.availableFromDate));
els.availableFromTimeBtn?.addEventListener("click", () => openPicker(els.availableFromTime));
if (window.WrappedI18n.onChange) { if (window.WrappedI18n.onChange) {
window.WrappedI18n.onChange(() => { window.WrappedI18n.onChange(() => {
fillTtl(); fillTtl();
fillMaxOpens();
refreshExpiresMeta(); refreshExpiresMeta();
refreshSuccessBadges();
refreshSizeMeter();
syncShareControls();
setLanguage(els.lang.value, { silent: true }); setLanguage(els.lang.value, { silent: true });
refreshHighlight(); refreshHighlight();
if (els.langChipText) els.langChipText.textContent = langLabel(els.lang.value); if (els.langChipText) els.langChipText.textContent = langLabel(els.lang.value);
+2 -5
View File
@@ -176,17 +176,14 @@
return b64decode(b64); return b64decode(b64);
} }
async function fileToItem(file, label) { async function fileToItem(file) {
const buf = new Uint8Array(await file.arrayBuffer()); const buf = new Uint8Array(await file.arrayBuffer());
const item = { return {
type: "file", type: "file",
name: file.name || "file", name: file.name || "file",
mime: file.type || "application/octet-stream", mime: file.type || "application/octet-stream",
data_b64: b64encode(buf), data_b64: b64encode(buf),
}; };
const trimmed = (label || "").trim();
if (trimmed) item.label = trimmed.slice(0, 120);
return item;
} }
window.WrappedCrypto = { window.WrappedCrypto = {
+18 -192
View File
@@ -10,14 +10,13 @@
"footer.cap.media.hint": "Screenshots, archives, docs", "footer.cap.media.hint": "Screenshots, archives, docs",
"footer.cap.browser": "Encrypted in-browser", "footer.cap.browser": "Encrypted in-browser",
"footer.cap.browser.hint": "Only ciphertext reaches the server", "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", "footer.copy.author": "Sergey Antropov",
"about.open": "About Wrapped",
"about.title": "About Wrapped",
"about.p1": "Wrapped is a one-time secure drop for text, images and files.",
"about.p2": "Send a note or code snippet, and attachments too: documents, archives, screenshots (drag-and-drop, file picker, or paste from the clipboard). Both text and files are encrypted in the browser before upload — only ciphertext reaches the server.",
"about.p3": "The server never sees plaintext: encrypted data stays on the server until the recipient opens the link with the key and decrypts the package.",
"about.p4": "After a successful unwrap the server copy is destroyed. The encryption key lives in the URL fragment (#…) and is not sent to the server with the page request. Optionally, a wrap can also be protected with a password.",
"about.close": "Got it",
"brand.tagline": "Wrap. Send. Vanish.", "brand.tagline": "Wrap. Send. Vanish.",
"create.eyebrow": "Secure drop", "create.eyebrow": "Secure drop",
"create.title": "Wrap. Send. Vanish.", "create.title": "Wrap. Send. Vanish.",
@@ -39,20 +38,6 @@
"create.successTitle": "Token issued", "create.successTitle": "Token issued",
"create.successWarnTitle": "One-time unwrap", "create.successWarnTitle": "One-time unwrap",
"create.successWarnHint": "After opening, ciphertext is deleted on the server.", "create.successWarnHint": "After opening, ciphertext is deleted on the server.",
"create.passwordProtected": "Password protected",
"create.sharePassword": "Password",
"create.passwordSeparateHint": "Do not send the password in the same chat as the link.",
"create.qrHint": "Scan to open the share link",
"create.share": "Share",
"create.shareTitle": "Wrapped link",
"create.shareText": "One-time secure link from Wrapped",
"create.downloadQr": "Download QR",
"create.checkLink": "Copy the share link",
"create.checkPassword": "Send the password in a separate message",
"create.checkExpires": "Note the expiry time",
"create.trustKey": "The encryption key lives only in the link #fragment — the server never sees it.",
"create.sizeMeter": "≈ {used} / {max}",
"create.sizeNearLimit": "Approaching size limit",
"create.shareLink": "Share link", "create.shareLink": "Share link",
"create.token": "Wrapped token", "create.token": "Wrapped token",
"create.another": "Create another", "create.another": "Create another",
@@ -66,50 +51,6 @@
"create.ttl.24h": "24 hours", "create.ttl.24h": "24 hours",
"create.ttl.3d": "3 days", "create.ttl.3d": "3 days",
"create.ttl.7d": "7 days", "create.ttl.7d": "7 days",
"create.ttl.evening": "Until evening (20:00)",
"create.maxOpens": "Opens",
"create.maxOpensOption": "{n}",
"create.maxOpensInvalid": "Invalid number of opens.",
"create.availableFrom": "Available from (optional)",
"create.availableFromHint": "Empty = available immediately",
"create.availableFromDate": "Pick date",
"create.availableFromTime": "Pick time",
"create.availableFromPast": "Available-from must not be in the past.",
"create.availableFromAfterExpiry": "Available-from must be before expiry.",
"create.opensBadge": "{n} opens",
"create.availableFromBadge": "Available from {datetime}",
"create.itemLabel": "Label (optional)",
"create.itemLabelPlaceholder": "e.g. Instructions",
"create.tpl.access.name": "Access / password",
"create.tpl.access.body": "Access details\n\nURL:\nUsername:\nPassword:\n\nNotes:\n",
"create.tpl.code.name": "Code + notes",
"create.tpl.code.body": "One-time code / snippet\n\n```\n\n```\n\nNotes:\n",
"create.tpl.fileNote.name": "File + note",
"create.tpl.fileNote.body": "Attached file(s) — see below.\n\nWhat this is for:\nHow to use:\n",
"unwrap.notYetTitle": "Not available yet",
"unwrap.notYetHint": "This wrap opens at {datetime}.",
"unwrap.notYetHintGeneric": "This wrap is not available yet.",
"unwrap.stillOnServerTitle": "Still on the server",
"unwrap.stillOnServerHint": "{n} open(s) remaining — ciphertext stays until the last unwrap.",
"unwrap.opensRemaining": "{n} open(s) remaining after this session.",
"verify.eyebrow": "Verify",
"verify.title": "How to verify Wrapped",
"verify.lede": "What leaves your browser, what stays on the server, and how the key in #fragment works.",
"verify.s1.title": "Encryption in the browser",
"verify.s1.body": "Text and files are packed and encrypted with Web Crypto (AES-GCM) before upload. The server receives only ciphertext plus metadata (TTL, MIME, size, optional password hash).",
"verify.s2.title": "Key in the URL fragment",
"verify.s2.body": "The share link looks like /w/<id>#<key>. The part after # never reaches the server in the page request. Without that fragment (or the full wrapped token), ciphertext cannot be decrypted.",
"verify.s3.title": "Opens and destruction",
"verify.s3.body": "By default a wrap can be opened once; then ciphertext is deleted. If the sender chose 23 opens, the server keeps ciphertext until the last successful unwrap. Expiry and password lockout still destroy the package.",
"verify.s4.title": "Optional password",
"verify.s4.body": "When a password is set, the server checks an Argon2 hash before releasing ciphertext. Wrong guesses are limited; empty password does not burn an attempt.",
"verify.s5.title": "Available from",
"verify.s5.body": "If \"available from\" is set, unwrap is rejected until that time. After that, normal open/expiry rules apply.",
"verify.createCta": "Create a wrap",
"about.verifyLink": "How to verify",
"about.verifyHint": "What the server sees and how the #key works",
"admin.limits.maxOpens": "Max opens per wrap",
"admin.limits.maxOpensHint": "Ceiling for create UI (110). Default: 3.",
"create.ttl.default": "Default ({seconds} s)", "create.ttl.default": "Default ({seconds} s)",
"lang.plaintext": "Plain text", "lang.plaintext": "Plain text",
"lang.markdown": "Markdown", "lang.markdown": "Markdown",
@@ -136,36 +77,17 @@
"unwrap.password": "Password (if set)", "unwrap.password": "Password (if set)",
"unwrap.submit": "Unwrap", "unwrap.submit": "Unwrap",
"unwrap.working": "Decrypting…", "unwrap.working": "Decrypting…",
"unwrap.workingFetch": "Downloading…",
"unwrap.workingDecrypt": "Decrypting…",
"unwrap.destroyedTitle": "Server copy destroyed", "unwrap.destroyedTitle": "Server copy destroyed",
"unwrap.destroyedHint": "Preview lives only in this browser session.", "unwrap.destroyedHint": "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.needKey": "Missing encryption key. Open the full share link (with #key) or paste the full wrapped token.",
"unwrap.badToken": "Invalid token",
"unwrap.badTokenHint": "Paste a full wrapped token or open a valid share link.",
"unwrap.badPassword": "Wrong password", "unwrap.badPassword": "Wrong password",
"unwrap.badPasswordHint": "Check the password and try again.", "unwrap.badPasswordHint": "Check the password and try again.",
"unwrap.passwordRequired": "Password required", "unwrap.passwordRequired": "Password required",
"unwrap.passwordRequiredHint": "This wrap is password-protected.", "unwrap.passwordRequiredHint": "This wrap is password-protected.",
"unwrap.attemptsLeft": "{n} of {max} attempts left", "unwrap.attemptsLeft": "{n} of {max} attempts left",
"unwrap.passwordLocked": "Too many wrong passwords", "unwrap.passwordLocked": "Too many wrong passwords",
"unwrap.passwordLockedHint": "This wrap has been destroyed after too many failed attempts.", "unwrap.passwordLockedHint": "This wrap has been destroyed.",
"unwrap.unavailable": "Unavailable", "unwrap.unavailable": "Unavailable (already used, expired, or invalid).",
"unwrap.unavailableHint": "Already used, expired, or invalid.",
"unwrap.goneTitle": "This link is no longer available",
"unwrap.goneHint": "Wrapped links are one-time and can expire. The ciphertext is gone from the server.",
"unwrap.rateLimited": "Too many requests",
"unwrap.rateLimitedHint": "Wait a minute and try again.",
"unwrap.captchaFailed": "CAPTCHA failed",
"unwrap.captchaFailedHint": "Complete the CAPTCHA and try again.",
"unwrap.storageError": "Temporary storage error",
"unwrap.storageErrorHint": "Try again in a moment.",
"unwrap.decryptFailed": "Could not decrypt",
"unwrap.decryptFailedHint": "The key in the link may be wrong. Ciphertext was already removed from the server.",
"unwrap.createOwn": "Create your own wrap",
"unwrap.tapPreview": "Tap image to enlarge",
"unwrap.downloadAll": "Download all",
"unwrap.trustKey": "The key was only in the link #fragment and was never sent to the server.",
"common.copy": "Copy", "common.copy": "Copy",
"common.copied": "Copied", "common.copied": "Copied",
"common.download": "Download", "common.download": "Download",
@@ -177,8 +99,6 @@
"admin.nav.danger": "Danger", "admin.nav.danger": "Danger",
"admin.nav.site": "Site", "admin.nav.site": "Site",
"admin.nav.logout": "Logout", "admin.nav.logout": "Logout",
"admin.nav.openMenu": "Menu",
"admin.nav.closeMenu": "Close menu",
"admin.brand.tagline": "Admin console", "admin.brand.tagline": "Admin console",
"admin.settings.title": "Settings", "admin.settings.title": "Settings",
"admin.stats.title": "Statistics", "admin.stats.title": "Statistics",
@@ -186,21 +106,14 @@
"admin.stats.minioTitle": "MinIO (pending ciphertext)", "admin.stats.minioTitle": "MinIO (pending ciphertext)",
"admin.stats.objects": "object(s)", "admin.stats.objects": "object(s)",
"admin.stats.dbPending": "DB pending", "admin.stats.dbPending": "DB pending",
"admin.stats.unwrappedTitle": "Successful unwraps", "admin.stats.unwrappedTitle": "Unwrapped (all time)",
"admin.stats.auditOk": "audit ok", "admin.stats.auditOk": "audit ok",
"admin.stats.dbConsumed": "DB consumed",
"admin.stats.last24h": "24h", "admin.stats.last24h": "24h",
"admin.stats.pendingTitle": "Not unwrapped", "admin.stats.pendingTitle": "Not unwrapped",
"admin.stats.items": "item(s)", "admin.stats.items": "item(s)",
"admin.stats.uploadedTitle": "Uploaded (all time)", "admin.stats.uploadedTitle": "Uploaded (all time)",
"admin.stats.passwordBurnsTitle": "Burned by password",
"admin.stats.passwordBurnsHint": "Destroyed after too many wrong passwords",
"admin.stats.wrapsSection": "Wraps by status", "admin.stats.wrapsSection": "Wraps by status",
"admin.stats.extraSection": "More", "admin.stats.extraSection": "More",
"admin.stats.failReasonsSection": "Unwrap fail reasons (all time)",
"admin.stats.failReasons24hSection": "Unwrap fail reasons (24h)",
"admin.stats.col.reason": "Reason",
"admin.stats.noFailReasons": "No failed unwraps yet.",
"admin.stats.col.status": "Status", "admin.stats.col.status": "Status",
"admin.stats.col.count": "Count", "admin.stats.col.count": "Count",
"admin.stats.col.size": "Size", "admin.stats.col.size": "Size",
@@ -314,14 +227,13 @@
"footer.cap.media.hint": "Скриншоты, архивы, документы", "footer.cap.media.hint": "Скриншоты, архивы, документы",
"footer.cap.browser": "Шифрование в браузере", "footer.cap.browser": "Шифрование в браузере",
"footer.cap.browser.hint": "На сервер уходит только ciphertext", "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": "Сергей Антропов", "footer.copy.author": "Сергей Антропов",
"about.open": "О проекте Wrapped",
"about.title": "О проекте Wrapped",
"about.p1": "Wrapped — сервис одноразовой безопасной передачи текста, изображений и файлов.",
"about.p2": "Можно отправить заметку или код, а также вложения: документы, архивы, скриншоты (drag-and-drop, выбор с диска или вставка из буфера). И текст, и файлы шифруются в браузере до загрузки — на сервер уходит только ciphertext.",
"about.p3": "Сервер никогда не видит plaintext: зашифрованные данные лежат на сервере до тех пор, пока получатель не откроет ссылку с ключом и не расшифрует пакет.",
"about.p4": "После успешной расшифровки копия на сервере уничтожается. Ключ шифрования живёт во фрагменте URL (#…) и не уходит на сервер вместе с запросом страницы. При желании wrap можно дополнительно защитить паролем.",
"about.close": "Понятно",
"brand.tagline": "Упакуй. Отправь. Исчезни.", "brand.tagline": "Упакуй. Отправь. Исчезни.",
"create.eyebrow": "Безопасная передача", "create.eyebrow": "Безопасная передача",
"create.title": "Упакуй. Отправь. Исчезни.", "create.title": "Упакуй. Отправь. Исчезни.",
@@ -343,20 +255,6 @@
"create.successTitle": "Токен выдан", "create.successTitle": "Токен выдан",
"create.successWarnTitle": "Расшифруй один раз", "create.successWarnTitle": "Расшифруй один раз",
"create.successWarnHint": "После открытия ciphertext удаляется на сервере.", "create.successWarnHint": "После открытия ciphertext удаляется на сервере.",
"create.passwordProtected": "Защищено паролем",
"create.sharePassword": "Пароль",
"create.passwordSeparateHint": "Не отправляйте пароль в той же переписке, что и ссылку.",
"create.qrHint": "Отсканируйте, чтобы открыть ссылку",
"create.share": "Поделиться",
"create.shareTitle": "Ссылка Wrapped",
"create.shareText": "Одноразовая защищённая ссылка из Wrapped",
"create.downloadQr": "Скачать QR",
"create.checkLink": "Скопируйте ссылку",
"create.checkPassword": "Отправьте пароль отдельным сообщением",
"create.checkExpires": "Учтите срок действия",
"create.trustKey": "Ключ шифрования только во фрагменте ссылки #… — сервер его не видит.",
"create.sizeMeter": "≈ {used} / {max}",
"create.sizeNearLimit": "Близко к лимиту размера",
"create.shareLink": "Ссылка", "create.shareLink": "Ссылка",
"create.token": "Wrapped-токен", "create.token": "Wrapped-токен",
"create.another": "Создать ещё", "create.another": "Создать ещё",
@@ -370,50 +268,6 @@
"create.ttl.24h": "24 часа", "create.ttl.24h": "24 часа",
"create.ttl.3d": "3 дня", "create.ttl.3d": "3 дня",
"create.ttl.7d": "7 дней", "create.ttl.7d": "7 дней",
"create.ttl.evening": "До вечера (20:00)",
"create.maxOpens": "Открытий",
"create.maxOpensOption": "{n}",
"create.maxOpensInvalid": "Недопустимое число открытий.",
"create.availableFrom": "Доступно с (необязательно)",
"create.availableFromHint": "Пусто = доступно сразу",
"create.availableFromDate": "Выбрать дату",
"create.availableFromTime": "Выбрать время",
"create.availableFromPast": "«Доступно с» не может быть в прошлом.",
"create.availableFromAfterExpiry": "«Доступно с» должно быть раньше срока истечения.",
"create.opensBadge": "{n} открытий",
"create.availableFromBadge": "Доступно с {datetime}",
"create.itemLabel": "Подпись (необязательно)",
"create.itemLabelPlaceholder": "напр. Инструкция",
"create.tpl.access.name": "Доступ / пароль",
"create.tpl.access.body": "Данные доступа\n\nURL:\nЛогин:\nПароль:\n\nЗаметки:\n",
"create.tpl.code.name": "Код + заметка",
"create.tpl.code.body": "Одноразовый код / фрагмент\n\n```\n\n```\n\nЗаметки:\n",
"create.tpl.fileNote.name": "Файл + заметка",
"create.tpl.fileNote.body": "Вложения — см. ниже.\n\nДля чего:\nКак пользоваться:\n",
"unwrap.notYetTitle": "Ещё недоступно",
"unwrap.notYetHint": "Этот wrap откроется {datetime}.",
"unwrap.notYetHintGeneric": "Этот wrap ещё недоступен.",
"unwrap.stillOnServerTitle": "Ещё на сервере",
"unwrap.stillOnServerHint": "Осталось открытий: {n} — ciphertext хранится до последнего unwrap.",
"unwrap.opensRemaining": "После этой сессии останется открытий: {n}.",
"verify.eyebrow": "Проверка",
"verify.title": "Как проверить Wrapped",
"verify.lede": "Что уходит из браузера, что лежит на сервере и как работает ключ в #fragment.",
"verify.s1.title": "Шифрование в браузере",
"verify.s1.body": "Текст и файлы упаковываются и шифруются Web Crypto (AES-GCM) до загрузки. На сервер уходит только ciphertext и метаданные (TTL, MIME, размер, опциональный хеш пароля).",
"verify.s2.title": "Ключ во фрагменте URL",
"verify.s2.body": "Ссылка вида /w/<id>#<key>. Часть после # не уходит на сервер в запросе страницы. Без фрагмента (или полного токена) ciphertext не расшифровать.",
"verify.s3.title": "Открытия и уничтожение",
"verify.s3.body": "По умолчанию wrap открывается один раз — затем ciphertext удаляется. Если отправитель выбрал 2–3 открытия, сервер хранит ciphertext до последнего успешного unwrap. Срок и блокировка пароля по-прежнему уничтожают пакет.",
"verify.s4.title": "Опциональный пароль",
"verify.s4.body": "При пароле сервер проверяет Argon2-хеш до выдачи ciphertext. Число попыток ограничено; пустой пароль попытку не тратит.",
"verify.s5.title": "Доступно с",
"verify.s5.body": "Если задано «доступно с», unwrap отклоняется до этого момента. Дальше действуют обычные правила открытий и срока.",
"verify.createCta": "Создать wrap",
"about.verifyLink": "Как проверить",
"about.verifyHint": "Что видит сервер и как работает ключ в #…",
"admin.limits.maxOpens": "Макс. открытий на wrap",
"admin.limits.maxOpensHint": "Потолок для UI создания (1–10). По умолчанию: 3.",
"create.ttl.default": "По умолчанию ({seconds} с)", "create.ttl.default": "По умолчанию ({seconds} с)",
"lang.plaintext": "Обычный текст", "lang.plaintext": "Обычный текст",
"lang.markdown": "Markdown", "lang.markdown": "Markdown",
@@ -440,36 +294,17 @@
"unwrap.password": "Пароль (если задан)", "unwrap.password": "Пароль (если задан)",
"unwrap.submit": "Расшифровать", "unwrap.submit": "Расшифровать",
"unwrap.working": "Расшифровка…", "unwrap.working": "Расшифровка…",
"unwrap.workingFetch": "Скачивание…",
"unwrap.workingDecrypt": "Расшифровка…",
"unwrap.destroyedTitle": "Копия на сервере уничтожена", "unwrap.destroyedTitle": "Копия на сервере уничтожена",
"unwrap.destroyedHint": "Превью только в этой сессии браузера.", "unwrap.destroyedHint": "Превью только в этой сессии браузера.",
"unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.", "unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.",
"unwrap.badToken": "Неверный токен",
"unwrap.badTokenHint": "Вставьте полный wrapped-токен или откройте корректную ссылку.",
"unwrap.badPassword": "Неверный пароль", "unwrap.badPassword": "Неверный пароль",
"unwrap.badPasswordHint": "Проверьте пароль и попробуйте снова.", "unwrap.badPasswordHint": "Проверьте пароль и попробуйте снова.",
"unwrap.passwordRequired": "Нужен пароль", "unwrap.passwordRequired": "Нужен пароль",
"unwrap.passwordRequiredHint": "Этот wrap защищён паролем.", "unwrap.passwordRequiredHint": "Этот wrap защищён паролем.",
"unwrap.attemptsLeft": "Осталось попыток: {n} из {max}", "unwrap.attemptsLeft": "Осталось попыток: {n} из {max}",
"unwrap.passwordLocked": "Слишком много неверных паролей", "unwrap.passwordLocked": "Слишком много неверных паролей",
"unwrap.passwordLockedHint": "Wrap уничтожен после исчерпания попыток.", "unwrap.passwordLockedHint": "Этот wrap уничтожен.",
"unwrap.unavailable": "Недоступно", "unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).",
"unwrap.unavailableHint": "Уже использовано, истекло или неверно.",
"unwrap.goneTitle": "Ссылка больше недоступна",
"unwrap.goneHint": "Wrapped-ссылки одноразовые и могут истекать. Ciphertext уже удалён с сервера.",
"unwrap.rateLimited": "Слишком много запросов",
"unwrap.rateLimitedHint": "Подождите минуту и попробуйте снова.",
"unwrap.captchaFailed": "CAPTCHA не пройдена",
"unwrap.captchaFailedHint": "Пройдите CAPTCHA и попробуйте снова.",
"unwrap.storageError": "Временная ошибка хранилища",
"unwrap.storageErrorHint": "Попробуйте чуть позже.",
"unwrap.decryptFailed": "Не удалось расшифровать",
"unwrap.decryptFailedHint": "Ключ в ссылке может быть неверным. Ciphertext уже удалён с сервера.",
"unwrap.createOwn": "Создать свой wrap",
"unwrap.tapPreview": "Нажмите на изображение, чтобы увеличить",
"unwrap.downloadAll": "Скачать всё",
"unwrap.trustKey": "Ключ был только во фрагменте ссылки #… и не уходил на сервер.",
"common.copy": "Копировать", "common.copy": "Копировать",
"common.copied": "Скопировано", "common.copied": "Скопировано",
"common.download": "Скачать", "common.download": "Скачать",
@@ -481,8 +316,6 @@
"admin.nav.danger": "Опасная зона", "admin.nav.danger": "Опасная зона",
"admin.nav.site": "Сайт", "admin.nav.site": "Сайт",
"admin.nav.logout": "Выйти", "admin.nav.logout": "Выйти",
"admin.nav.openMenu": "Меню",
"admin.nav.closeMenu": "Закрыть меню",
"admin.brand.tagline": "Консоль администратора", "admin.brand.tagline": "Консоль администратора",
"admin.settings.title": "Настройки", "admin.settings.title": "Настройки",
"admin.stats.title": "Статистика", "admin.stats.title": "Статистика",
@@ -490,21 +323,14 @@
"admin.stats.minioTitle": "MinIO (нерасшифрованный ciphertext)", "admin.stats.minioTitle": "MinIO (нерасшифрованный ciphertext)",
"admin.stats.objects": "объект(ов)", "admin.stats.objects": "объект(ов)",
"admin.stats.dbPending": "в БД pending", "admin.stats.dbPending": "в БД pending",
"admin.stats.unwrappedTitle": "Успешные unwrap", "admin.stats.unwrappedTitle": "Расшифровано (за всё время)",
"admin.stats.auditOk": "audit ok", "admin.stats.auditOk": "audit ok",
"admin.stats.dbConsumed": "в БД consumed",
"admin.stats.last24h": "за 24ч", "admin.stats.last24h": "за 24ч",
"admin.stats.pendingTitle": "Не расшифровано", "admin.stats.pendingTitle": "Не расшифровано",
"admin.stats.items": "элемент(ов)", "admin.stats.items": "элемент(ов)",
"admin.stats.uploadedTitle": "Загружено (за всё время)", "admin.stats.uploadedTitle": "Загружено (за всё время)",
"admin.stats.passwordBurnsTitle": "Сожжено паролем",
"admin.stats.passwordBurnsHint": "Уничтожено после слишком многих неверных паролей",
"admin.stats.wrapsSection": "Wraps по статусу", "admin.stats.wrapsSection": "Wraps по статусу",
"admin.stats.extraSection": "Ещё", "admin.stats.extraSection": "Ещё",
"admin.stats.failReasonsSection": "Причины ошибок unwrap (всё время)",
"admin.stats.failReasons24hSection": "Причины ошибок unwrap (24ч)",
"admin.stats.col.reason": "Причина",
"admin.stats.noFailReasons": "Пока нет неудачных unwrap.",
"admin.stats.col.status": "Статус", "admin.stats.col.status": "Статус",
"admin.stats.col.count": "Кол-во", "admin.stats.col.count": "Кол-во",
"admin.stats.col.size": "Размер", "admin.stats.col.size": "Размер",
+2 -9
View File
@@ -1,14 +1,8 @@
(() => { (() => {
const KEY = "wrapped.theme"; const KEY = "wrapped.theme";
function systemTheme() {
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
function preferred() { function preferred() {
const stored = localStorage.getItem(KEY); return localStorage.getItem(KEY) || "dark";
if (stored === "light" || stored === "dark") return stored;
return systemTheme();
} }
function apply(theme) { function apply(theme) {
@@ -22,8 +16,7 @@
} }
function toggle() { function toggle() {
const current = document.documentElement.getAttribute("data-theme") || preferred(); const next = preferred() === "dark" ? "light" : "dark";
const next = current === "dark" ? "light" : "dark";
localStorage.setItem(KEY, next); localStorage.setItem(KEY, next);
apply(next); apply(next);
} }
+2 -18
View File
@@ -8,13 +8,10 @@
busyEl.className = "busy-overlay hidden"; busyEl.className = "busy-overlay hidden";
busyEl.setAttribute("aria-live", "polite"); busyEl.setAttribute("aria-live", "polite");
busyEl.setAttribute("aria-busy", "true"); busyEl.setAttribute("aria-busy", "true");
const favicon =
document.querySelector('link[rel="icon"][type="image/svg+xml"]')?.href ||
"/static/favicon.svg";
busyEl.innerHTML = ` busyEl.innerHTML = `
<div class="busy-card"> <div class="busy-card">
<span class="busy-logo" aria-hidden="true"> <span class="busy-logo" aria-hidden="true">
<img src="${favicon}" alt="" width="48" height="48" /> <img src="/static/favicon.svg" alt="" width="48" height="48" />
</span> </span>
<p class="busy-text" data-busy-label></p> <p class="busy-text" data-busy-label></p>
</div> </div>
@@ -51,18 +48,5 @@
return `${gb.toFixed(2)} GB`; return `${gb.toFixed(2)} GB`;
} }
function hapticLight() { window.WrappedUI = { showBusy, hideBusy, formatBytes };
try {
navigator.vibrate?.(12);
} catch {
/* ignore */
}
}
async function copyText(text) {
await navigator.clipboard.writeText(text || "");
hapticLight();
}
window.WrappedUI = { showBusy, hideBusy, formatBytes, hapticLight, copyText };
})(); })();
+24 -301
View File
@@ -1,7 +1,6 @@
(() => { (() => {
const t = (k, vars) => window.WrappedI18n.t(k, vars); const t = (k, vars) => window.WrappedI18n.t(k, vars);
let settings = null; let settings = null;
const objectUrls = [];
const els = { const els = {
token: document.getElementById("token-input"), token: document.getElementById("token-input"),
@@ -12,28 +11,11 @@
errorHint: document.getElementById("form-error-hint"), errorHint: document.getElementById("form-error-hint"),
errorAttempts: document.getElementById("form-error-attempts"), errorAttempts: document.getElementById("form-error-attempts"),
form: document.getElementById("unwrap-form"), form: document.getElementById("unwrap-form"),
head: document.getElementById("unwrap-head"),
state: document.getElementById("unwrap-state"),
stateTitle: document.getElementById("unwrap-state-title"),
stateHint: document.getElementById("unwrap-state-hint"),
stateFa: document.getElementById("unwrap-state-fa"),
result: document.getElementById("result-panel"), result: document.getElementById("result-panel"),
items: document.getElementById("items"), items: document.getElementById("items"),
captchaSlot: document.getElementById("captcha-slot"), captchaSlot: document.getElementById("captcha-slot"),
lightbox: document.getElementById("image-lightbox"),
lightboxImg: document.getElementById("lightbox-img"),
lightboxCaption: document.getElementById("lightbox-caption"),
lightboxClose: document.getElementById("lightbox-close"),
downloadAll: document.getElementById("download-all"),
resultCalloutTitle: document.getElementById("result-callout-title"),
resultCalloutHint: document.getElementById("result-callout-hint"),
resultCalloutFa: document.getElementById("result-callout-fa"),
resultOpensHint: document.getElementById("result-opens-hint"),
}; };
let lastPack = null;
let lastWrapId = null;
function showError(title, hint, attempts) { function showError(title, hint, attempts) {
if (!els.error) return; if (!els.error) return;
if (els.errorTitle) els.errorTitle.textContent = title || ""; if (els.errorTitle) els.errorTitle.textContent = title || "";
@@ -55,6 +37,7 @@
els.errorAttempts.classList.add("hidden"); els.errorAttempts.classList.add("hidden");
} }
} }
// Fallback if structured nodes missing
if (!els.errorTitle) els.error.textContent = [title, hint, attempts].filter(Boolean).join(" "); if (!els.errorTitle) els.error.textContent = [title, hint, attempts].filter(Boolean).join(" ");
els.error.classList.remove("hidden"); els.error.classList.remove("hidden");
} }
@@ -73,17 +56,6 @@
} }
} }
function showState({ title, hint, icon = "fa-link-slash" }) {
clearError();
els.form?.classList.add("hidden");
els.head?.classList.add("hidden");
els.result?.classList.add("hidden");
if (els.stateFa) els.stateFa.className = `fa-solid ${icon}`;
if (els.stateTitle) els.stateTitle.textContent = title || "";
if (els.stateHint) els.stateHint.textContent = hint || "";
els.state?.classList.remove("hidden");
}
function attemptsLabel(detail) { function attemptsLabel(detail) {
const n = Number(detail?.attempts_remaining); const n = Number(detail?.attempts_remaining);
const max = Number(detail?.attempts_max); const max = Number(detail?.attempts_max);
@@ -91,41 +63,6 @@
return t("unwrap.attemptsLeft", { n, max }); return t("unwrap.attemptsLeft", { n, max });
} }
function trackUrl(url) {
objectUrls.push(url);
return url;
}
function revokeAllUrls() {
while (objectUrls.length) {
try {
URL.revokeObjectURL(objectUrls.pop());
} catch {
/* ignore */
}
}
}
function closeLightbox() {
if (!els.lightbox) return;
els.lightbox.classList.add("hidden");
document.body.classList.remove("modal-open");
if (els.lightboxImg) {
els.lightboxImg.removeAttribute("src");
els.lightboxImg.alt = "";
}
if (els.lightboxCaption) els.lightboxCaption.textContent = "";
}
function openLightbox(src, caption) {
if (!els.lightbox || !els.lightboxImg) return;
els.lightboxImg.src = src;
els.lightboxImg.alt = caption || "";
if (els.lightboxCaption) els.lightboxCaption.textContent = caption || "";
els.lightbox.classList.remove("hidden");
document.body.classList.add("modal-open");
}
function loadCaptcha() { function loadCaptcha() {
els.captchaSlot.innerHTML = ""; els.captchaSlot.innerHTML = "";
const provider = settings.captcha_provider; const provider = settings.captcha_provider;
@@ -168,11 +105,10 @@
function downloadBlob(name, blob) { function downloadBlob(name, blob) {
const a = document.createElement("a"); const a = document.createElement("a");
const url = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.href = url;
a.download = name; a.download = name;
a.click(); a.click();
setTimeout(() => URL.revokeObjectURL(url), 2000); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
} }
function makeDownloadBtn(onClick) { function makeDownloadBtn(onClick) {
@@ -184,12 +120,6 @@
return btn; return btn;
} }
function focusPassword({ select = false } = {}) {
if (!els.password) return;
els.password.focus();
if (select) els.password.select?.();
}
function makeCopyBtn(getText) { function makeCopyBtn(getText) {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.className = "btn copy-btn"; btn.className = "btn copy-btn";
@@ -199,11 +129,7 @@
btn.innerHTML = label(); btn.innerHTML = label();
btn.addEventListener("click", async () => { btn.addEventListener("click", async () => {
try { try {
if (window.WrappedUI?.copyText) {
await window.WrappedUI.copyText(getText() || "");
} else {
await navigator.clipboard.writeText(getText() || ""); await navigator.clipboard.writeText(getText() || "");
}
btn.innerHTML = `<i class="fa-solid fa-check" aria-hidden="true"></i><span>${t("common.copied")}</span>`; btn.innerHTML = `<i class="fa-solid fa-check" aria-hidden="true"></i><span>${t("common.copied")}</span>`;
setTimeout(() => { setTimeout(() => {
btn.innerHTML = label(); btn.innerHTML = label();
@@ -215,113 +141,17 @@
return btn; return btn;
} }
function itemToBlob(item) { function renderPackage(pack) {
if (item.type === "text") {
const lang = item.language || "plaintext";
return {
name: `wrapped-${lang}.txt`,
blob: new Blob([item.content || ""], { type: "text/plain" }),
};
}
if (item.type === "file") {
const bytes = window.WrappedCrypto.base64ToBytes(item.data_b64);
const mime = item.mime || "application/octet-stream";
return {
name: item.name || "file",
blob: new Blob([bytes], { type: mime }),
};
}
return null;
}
function uniqueZipName(name, used) {
let base = name || "file";
if (!used.has(base)) {
used.add(base);
return base;
}
const dot = base.lastIndexOf(".");
const stem = dot > 0 ? base.slice(0, dot) : base;
const ext = dot > 0 ? base.slice(dot) : "";
let i = 2;
let candidate = `${stem}-${i}${ext}`;
while (used.has(candidate)) {
i += 1;
candidate = `${stem}-${i}${ext}`;
}
used.add(candidate);
return candidate;
}
async function downloadAllZip() {
if (!lastPack || typeof JSZip === "undefined") return;
const items = lastPack.items || [];
if (items.length < 2) return;
const zip = new JSZip();
const used = new Set();
for (const item of items) {
const entry = itemToBlob(item);
if (!entry) continue;
zip.file(uniqueZipName(entry.name, used), entry.blob);
}
const blob = await zip.generateAsync({ type: "blob" });
const id = lastWrapId || "pack";
downloadBlob(`wrapped-${id}.zip`, blob);
}
function syncDownloadAll() {
if (!els.downloadAll) return;
const count = lastPack?.items?.length || 0;
const show = count >= 2 && typeof JSZip !== "undefined";
els.downloadAll.classList.toggle("hidden", !show);
els.downloadAll.setAttribute("aria-label", t("unwrap.downloadAll"));
}
function renderPackage(pack, meta = {}) {
revokeAllUrls();
lastPack = pack;
els.items.innerHTML = ""; els.items.innerHTML = "";
const destroyed = meta.destroyed !== false && !(Number(meta.opens_remaining) > 0);
const opensRemaining = Number(meta.opens_remaining) || 0;
if (els.resultCalloutTitle && els.resultCalloutHint) {
if (destroyed) {
els.resultCalloutTitle.textContent = t("unwrap.destroyedTitle");
els.resultCalloutHint.textContent = t("unwrap.destroyedHint");
if (els.resultCalloutFa) els.resultCalloutFa.className = "fa-solid fa-fire";
} else {
els.resultCalloutTitle.textContent = t("unwrap.stillOnServerTitle");
els.resultCalloutHint.textContent = t("unwrap.stillOnServerHint", { n: opensRemaining });
if (els.resultCalloutFa) els.resultCalloutFa.className = "fa-solid fa-clock";
}
}
if (els.resultOpensHint) {
if (!destroyed && opensRemaining > 0) {
els.resultOpensHint.textContent = t("unwrap.opensRemaining", { n: opensRemaining });
els.resultOpensHint.classList.remove("hidden");
} else {
els.resultOpensHint.textContent = "";
els.resultOpensHint.classList.add("hidden");
}
}
for (const item of pack.items || []) { for (const item of pack.items || []) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "item-card"; card.className = "item-card";
const label = (item.label || "").trim();
if (item.type === "text") { if (item.type === "text") {
const lang = item.language || "plaintext"; const lang = item.language || "plaintext";
const text = item.content || ""; const text = item.content || "";
const head = document.createElement("div"); const head = document.createElement("div");
head.className = "item-card-head"; head.className = "item-card-head";
const metaEl = document.createElement("div"); head.innerHTML = `<div><strong>text</strong> · <span class="mono">${lang}</span></div>`;
const strong = document.createElement("strong");
strong.textContent = label || "text";
metaEl.appendChild(strong);
metaEl.appendChild(document.createTextNode(" · "));
const langEl = document.createElement("span");
langEl.className = "mono";
langEl.textContent = lang;
metaEl.appendChild(langEl);
head.appendChild(metaEl);
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "item-card-actions"; actions.className = "item-card-actions";
actions.appendChild(makeCopyBtn(() => text)); actions.appendChild(makeCopyBtn(() => text));
@@ -340,90 +170,28 @@
window.WrappedHighlight.highlightElement(code, text, lang); window.WrappedHighlight.highlightElement(code, text, lang);
} else if (item.type === "file") { } else if (item.type === "file") {
const bytes = window.WrappedCrypto.base64ToBytes(item.data_b64); const bytes = window.WrappedCrypto.base64ToBytes(item.data_b64);
const mime = item.mime || "application/octet-stream"; const blob = new Blob([bytes], { type: item.mime || "application/octet-stream" });
const blob = new Blob([bytes], { type: mime });
const name = item.name || "file";
const head = document.createElement("div"); const head = document.createElement("div");
head.className = "item-card-head"; head.className = "item-card-head";
const metaEl = document.createElement("div"); head.innerHTML = `<div><strong>${item.name}</strong> · <span class="mono">${item.mime}</span> · ${window.WrappedUI.formatBytes(bytes.length)}</div>`;
const strong = document.createElement("strong"); head.appendChild(makeDownloadBtn(() => downloadBlob(item.name || "file", blob)));
strong.textContent = label || name;
metaEl.appendChild(strong);
if (label) {
metaEl.appendChild(document.createTextNode(" · "));
metaEl.appendChild(document.createTextNode(name));
}
metaEl.appendChild(document.createTextNode(" · "));
const mimeEl = document.createElement("span");
mimeEl.className = "mono";
mimeEl.textContent = mime;
metaEl.appendChild(mimeEl);
metaEl.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`));
head.appendChild(metaEl);
head.appendChild(makeDownloadBtn(() => downloadBlob(name, blob)));
card.appendChild(head); card.appendChild(head);
if (mime.startsWith("image/")) { if ((item.mime || "").startsWith("image/")) {
const url = trackUrl(URL.createObjectURL(blob));
const img = document.createElement("img"); const img = document.createElement("img");
img.alt = label || name; img.alt = item.name;
img.src = url; img.src = URL.createObjectURL(blob);
img.className = "item-preview-img";
img.loading = "lazy";
img.addEventListener("click", () => openLightbox(url, label || name));
card.appendChild(img); card.appendChild(img);
const tip = document.createElement("p");
tip.className = "hint item-preview-hint";
tip.textContent = t("unwrap.tapPreview");
card.appendChild(tip);
} }
} }
els.items.appendChild(card); els.items.appendChild(card);
} }
syncDownloadAll();
} }
function mapHttpError(resp, detail) {
if (resp.status === 429 || detail === "rate_limited") {
showError(t("unwrap.rateLimited"), t("unwrap.rateLimitedHint"));
return;
}
if (detail === "captcha_failed" || resp.status === 400) {
if (detail === "captcha_failed") {
showError(t("unwrap.captchaFailed"), t("unwrap.captchaFailedHint"));
return;
}
}
if (resp.status === 500 || detail === "storage_error") {
showError(t("unwrap.storageError"), t("unwrap.storageErrorHint"));
return;
}
if (resp.status === 410 || detail === "unavailable") {
showState({
title: t("unwrap.goneTitle"),
hint: t("unwrap.goneHint"),
icon: "fa-link-slash",
});
return;
}
showError(t("unwrap.unavailable"), t("unwrap.unavailableHint"));
}
els.lightboxClose?.addEventListener("click", closeLightbox);
els.lightbox?.addEventListener("click", (e) => {
if (e.target === els.lightbox) closeLightbox();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && els.lightbox && !els.lightbox.classList.contains("hidden")) {
closeLightbox();
}
});
window.addEventListener("pagehide", revokeAllUrls);
els.btn.addEventListener("click", async () => { els.btn.addEventListener("click", async () => {
clearError(); clearError();
const parsed = window.WrappedCrypto.parseToken(els.token.value); const parsed = window.WrappedCrypto.parseToken(els.token.value);
if (!parsed) { if (!parsed) {
showError(t("unwrap.badToken"), t("unwrap.badTokenHint")); showError(t("unwrap.unavailable"));
return; return;
} }
const key = resolveKey(parsed); const key = resolveKey(parsed);
@@ -433,7 +201,7 @@
} }
els.btn.disabled = true; els.btn.disabled = true;
window.WrappedUI.showBusy(t("unwrap.workingFetch")); window.WrappedUI.showBusy(t("unwrap.working"));
try { try {
const resp = await fetch(`/api/v1/wraps/${encodeURIComponent(parsed.wrapId)}/unwrap`, { const resp = await fetch(`/api/v1/wraps/${encodeURIComponent(parsed.wrapId)}/unwrap`, {
method: "POST", method: "POST",
@@ -448,27 +216,10 @@
const detail = err.detail; const detail = err.detail;
if (detail && typeof detail === "object") { if (detail && typeof detail === "object") {
if (detail.code === "password_locked") { if (detail.code === "password_locked") {
showState({ showError(
title: t("unwrap.passwordLocked"), t("unwrap.passwordLocked"),
hint: t("unwrap.passwordLockedHint"), t("unwrap.passwordLockedHint")
icon: "fa-shield-halved", );
});
return;
}
if (detail.code === "not_yet_available") {
const when = detail.available_from
? new Intl.DateTimeFormat(window.WrappedI18n.locale?.() || undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(detail.available_from))
: "";
showState({
title: t("unwrap.notYetTitle"),
hint: when
? t("unwrap.notYetHint", { datetime: when })
: t("unwrap.notYetHintGeneric"),
icon: "fa-clock",
});
return; return;
} }
if (detail.code === "password_required") { if (detail.code === "password_required") {
@@ -477,7 +228,7 @@
t("unwrap.passwordRequiredHint"), t("unwrap.passwordRequiredHint"),
attemptsLabel(detail) attemptsLabel(detail)
); );
focusPassword({ select: true }); els.password?.focus();
return; return;
} }
if (detail.code === "bad_password") { if (detail.code === "bad_password") {
@@ -486,21 +237,19 @@
t("unwrap.badPasswordHint"), t("unwrap.badPasswordHint"),
attemptsLabel(detail) attemptsLabel(detail)
); );
focusPassword({ select: true }); els.password?.focus();
els.password?.select?.();
return; return;
} }
} }
showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint")); showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint"));
focusPassword({ select: true });
return; return;
} }
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); showError(t("unwrap.unavailable"));
mapHttpError(resp, err.detail);
return; return;
} }
const data = await resp.json(); const data = await resp.json();
window.WrappedUI.showBusy(t("unwrap.workingDecrypt"));
const ciphertext = window.WrappedCrypto.base64ToBytes(data.ciphertext_b64); const ciphertext = window.WrappedCrypto.base64ToBytes(data.ciphertext_b64);
let pack; let pack;
try { try {
@@ -512,27 +261,17 @@
} catch (err) { } catch (err) {
if (err.message === "password_required") { if (err.message === "password_required") {
showError(t("unwrap.passwordRequired"), t("unwrap.passwordRequiredHint")); showError(t("unwrap.passwordRequired"), t("unwrap.passwordRequiredHint"));
focusPassword({ select: true });
return; return;
} }
if (err.message === "bad_password") { if (err.message === "bad_password") {
showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint")); showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint"));
focusPassword({ select: true });
return; return;
} }
showError(t("unwrap.decryptFailed"), t("unwrap.decryptFailedHint")); showError(t("common.error"));
return; return;
} }
lastWrapId = parsed.wrapId; renderPackage(pack);
renderPackage(pack, {
destroyed: data.destroyed !== false && !(Number(data.opens_remaining) > 0),
opens_remaining: data.opens_remaining,
max_opens: data.max_opens,
opens_used: data.opens_used,
});
els.form.classList.add("hidden"); els.form.classList.add("hidden");
els.head?.classList.add("hidden");
els.state?.classList.add("hidden");
els.result.classList.remove("hidden"); els.result.classList.remove("hidden");
history.replaceState(null, "", location.pathname); history.replaceState(null, "", location.pathname);
} catch { } catch {
@@ -543,23 +282,6 @@
} }
}); });
els.password?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
els.btn?.click();
}
});
els.downloadAll?.addEventListener("click", () => {
downloadAllZip().catch(() => {});
});
if (window.WrappedI18n.onChange) {
window.WrappedI18n.onChange(() => {
syncDownloadAll();
});
}
async function init() { async function init() {
const resp = await fetch("/api/v1/settings"); const resp = await fetch("/api/v1/settings");
settings = await resp.json(); settings = await resp.json();
@@ -573,3 +295,4 @@
init().catch(() => showError(t("common.error"))); init().catch(() => showError(t("common.error")));
})(); })();
-35
View File
@@ -1,35 +0,0 @@
{
"name": "Wrapped",
"short_name": "Wrapped",
"description": "Zero-knowledge one-time encrypted drop for text and files.",
"start_url": "/",
"display": "standalone",
"background_color": "#0b1020",
"theme_color": "#0b1020",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
-25
View File
@@ -1,25 +0,0 @@
from __future__ import annotations
from pathlib import Path
from app.version import get_app_version
_STATIC_ROOT = Path(__file__).resolve().parent / "static"
def static_url(path: str) -> str:
"""URL for a file under app/static with cache-busting query (?v=...).
Stamp = app version + file mtime so CSS/JS updates apply even when VERSION
is unchanged (compose bind-mount / same-tag redeploy).
"""
rel = path.lstrip("/")
if rel.startswith("static/"):
rel = rel[len("static/") :]
stamp = get_app_version()
try:
mtime = int((_STATIC_ROOT / rel).stat().st_mtime)
stamp = f"{stamp}.{mtime}"
except OSError:
pass
return f"/static/{rel}?v={stamp}"
+1 -1
View File
@@ -140,5 +140,5 @@
</section> </section>
{% endblock %} {% endblock %}
{% block admin_scripts %} {% block admin_scripts %}
<script src="{{ static_url('js/admin-audit.js') }}"></script> <script src="/static/js/admin-audit.js"></script>
{% endblock %} {% endblock %}
+11 -25
View File
@@ -7,17 +7,15 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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 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_url('css/app.css') }}" /> <link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml" /> <link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="{{ static_url('favicon.ico') }}" sizes="any" /> <link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="{{ static_url('apple-touch-icon.png') }}" /> <link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="manifest" href="{{ static_url('manifest.webmanifest') }}" />
<meta name="theme-color" content="#0b1020" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" /> <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> </head>
<body class="admin-body"> <body class="admin-body">
<div class="bg-grid" aria-hidden="true"></div> <div class="bg-grid" aria-hidden="true"></div>
<header class="admin-top" id="admin-top"> <header class="admin-top">
<a class="brand" href="/admin"> <a class="brand" href="/admin">
<span class="brand-mark" aria-hidden="true"> <span class="brand-mark" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i> <i class="fa-solid fa-shield-halved"></i>
@@ -27,7 +25,8 @@
<span class="brand-tagline" data-i18n="admin.brand.tagline">Admin console</span> <span class="brand-tagline" data-i18n="admin.brand.tagline">Admin console</span>
</span> </span>
</a> </a>
<nav class="admin-nav" id="admin-nav" aria-label="Admin"> <div class="admin-top-right">
<nav class="admin-nav" aria-label="Admin">
<a href="/admin/stats" class="admin-nav-link {% if active == 'stats' %}active{% endif %}"> <a href="/admin/stats" class="admin-nav-link {% if active == 'stats' %}active{% endif %}">
<i class="fa-solid fa-chart-simple" aria-hidden="true"></i> <i class="fa-solid fa-chart-simple" aria-hidden="true"></i>
<span data-i18n="admin.nav.stats">Stats</span> <span data-i18n="admin.nav.stats">Stats</span>
@@ -48,25 +47,13 @@
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span data-i18n="admin.nav.site">Site</span> <span data-i18n="admin.nav.site">Site</span>
</a> </a>
<form method="post" action="/admin/logout" class="inline admin-nav-logout-form"> <form method="post" action="/admin/logout" class="inline">
<button class="admin-nav-link admin-nav-logout" type="submit"> <button class="admin-nav-link admin-nav-logout" type="submit">
<i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i> <i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i>
<span data-i18n="admin.nav.logout">Logout</span> <span data-i18n="admin.nav.logout">Logout</span>
</button> </button>
</form> </form>
</nav> </nav>
<div class="admin-top-tools">
<button
type="button"
class="icon-btn admin-nav-toggle"
id="admin-nav-toggle"
aria-controls="admin-nav"
aria-expanded="false"
title="Menu"
aria-label="Menu"
>
<i class="fa-solid fa-bars" aria-hidden="true"></i>
</button>
<div class="top-actions"> <div class="top-actions">
<button type="button" class="icon-btn" id="lang-toggle" title="Language" aria-label="Language"> <button type="button" class="icon-btn" id="lang-toggle" title="Language" aria-label="Language">
<span id="lang-label">RU</span> <span id="lang-label">RU</span>
@@ -82,10 +69,9 @@
<h1 data-i18n="{% if active == 'stats' %}admin.stats.title{% elif active == 'audit' %}admin.audit.title{% elif active == 'danger' %}admin.danger.title{% else %}admin.settings.title{% endif %}">{{ title }}</h1> <h1 data-i18n="{% if active == 'stats' %}admin.stats.title{% elif active == 'audit' %}admin.audit.title{% elif active == 'danger' %}admin.danger.title{% else %}admin.settings.title{% endif %}">{{ title }}</h1>
{% block admin_content %}{% endblock %} {% block admin_content %}{% endblock %}
</main> </main>
<script src="{{ static_url('js/i18n.js') }}"></script> <script src="/static/js/i18n.js"></script>
<script src="{{ static_url('js/theme.js') }}"></script> <script src="/static/js/theme.js"></script>
<script src="{{ static_url('js/modal.js') }}"></script> <script src="/static/js/modal.js"></script>
<script src="{{ static_url('js/admin-nav.js') }}"></script>
{% block admin_scripts %}{% endblock %} {% block admin_scripts %}{% endblock %}
</body> </body>
</html> </html>
+6 -8
View File
@@ -7,12 +7,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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 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_url('css/app.css') }}" /> <link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml" /> <link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="{{ static_url('favicon.ico') }}" sizes="any" /> <link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="{{ static_url('apple-touch-icon.png') }}" /> <link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="manifest" href="{{ static_url('manifest.webmanifest') }}" />
<meta name="theme-color" content="#0b1020" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" /> <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> </head>
<body class="admin-body"> <body class="admin-body">
@@ -49,7 +47,7 @@
<button class="btn primary" type="submit" data-i18n="admin.login.submit">Sign in</button> <button class="btn primary" type="submit" data-i18n="admin.login.submit">Sign in</button>
</form> </form>
</main> </main>
<script src="{{ static_url('js/i18n.js') }}"></script> <script src="/static/js/i18n.js"></script>
<script src="{{ static_url('js/theme.js') }}"></script> <script src="/static/js/theme.js"></script>
</body> </body>
</html> </html>
+1 -9
View File
@@ -36,14 +36,6 @@
<span data-i18n="admin.limits.auditRetention">Audit retention (days)</span> <span data-i18n="admin.limits.auditRetention">Audit retention (days)</span>
<input name="audit_retention_days" type="number" value="{{ settings.audit_retention_days }}" /> <input name="audit_retention_days" type="number" value="{{ settings.audit_retention_days }}" />
</label> </label>
<label>
<span data-i18n="admin.limits.maxOpens">Max opens per wrap</span>
<input name="max_opens_limit" type="number" min="1" max="10" step="1"
value="{{ settings.max_opens_limit or 3 }}" />
<small class="hint" data-i18n="admin.limits.maxOpensHint">
Ceiling for create UI (110). Default: 3.
</small>
</label>
</div> </div>
</section> </section>
@@ -158,5 +150,5 @@
</form> </form>
</div> </div>
<script src="{{ static_url('js/admin-tabs.js') }}"></script> <script src="/static/js/admin-tabs.js"></script>
{% endblock %} {% endblock %}
+7 -76
View File
@@ -28,29 +28,18 @@
<article class="stat-card"> <article class="stat-card">
<div class="stat-card-head"> <div class="stat-card-head">
<i class="fa-solid fa-lock-open" aria-hidden="true"></i> <i class="fa-solid fa-lock-open" aria-hidden="true"></i>
<h2 data-i18n="admin.stats.unwrappedTitle">Successful unwraps</h2> <h2 data-i18n="admin.stats.unwrappedTitle">Unwrapped (all time)</h2>
</div> </div>
<p class="stat-value">{{ stats.unwraps_success }}</p> <p class="stat-value">{{ stats.wraps_consumed }}</p>
<p class="stat-sub"> <p class="stat-sub">
<span data-i18n="admin.stats.dbConsumed">DB consumed</span> <span data-i18n="admin.stats.auditOk">audit ok</span>
<span class="stat-num">{{ stats.wraps_consumed }}</span> <span class="stat-num">{{ stats.audit_unwraps_ok }}</span>
· ·
<span data-i18n="admin.stats.last24h">24h</span> <span data-i18n="admin.stats.last24h">24h</span>
<span class="stat-num">{{ stats.consumed_24h }}</span> <span class="stat-num">{{ stats.consumed_24h }}</span>
</p> </p>
</article> </article>
<article class="stat-card">
<div class="stat-card-head">
<i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
<h2 data-i18n="admin.stats.passwordBurnsTitle">Burned by password</h2>
</div>
<p class="stat-value">{{ stats.password_burns }}</p>
<p class="stat-sub" data-i18n="admin.stats.passwordBurnsHint">
Destroyed after too many wrong passwords
</p>
</article>
<article class="stat-card"> <article class="stat-card">
<div class="stat-card-head"> <div class="stat-card-head">
<i class="fa-solid fa-hourglass-half" aria-hidden="true"></i> <i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
@@ -104,13 +93,13 @@
<td data-i18n="admin.stats.status.consumed">consumed</td> <td data-i18n="admin.stats.status.consumed">consumed</td>
<td class="mono">{{ stats.wraps_consumed }}</td> <td class="mono">{{ stats.wraps_consumed }}</td>
<td class="mono">{{ stats.size_consumed_human }}</td> <td class="mono">{{ stats.size_consumed_human }}</td>
<td class="mono">{{ stats.items_consumed }}</td> <td class="mono"></td>
</tr> </tr>
<tr> <tr>
<td data-i18n="admin.stats.status.expired">expired</td> <td data-i18n="admin.stats.status.expired">expired</td>
<td class="mono">{{ stats.wraps_expired }}</td> <td class="mono">{{ stats.wraps_expired }}</td>
<td class="mono">{{ stats.size_expired_human }}</td> <td class="mono"></td>
<td class="mono">{{ stats.items_expired }}</td> <td class="mono"></td>
</tr> </tr>
<tr class="stats-total-row"> <tr class="stats-total-row">
<td data-i18n="admin.stats.total">Total</td> <td data-i18n="admin.stats.total">Total</td>
@@ -153,64 +142,6 @@
</ul> </ul>
</section> </section>
</div> </div>
<div class="stats-sections">
<section class="stats-section">
<h3 data-i18n="admin.stats.failReasonsSection">Unwrap fail reasons (all time)</h3>
<div class="stats-table-wrap">
<table class="stats-table">
<thead>
<tr>
<th data-i18n="admin.stats.col.reason">Reason</th>
<th data-i18n="admin.stats.col.count">Count</th>
</tr>
</thead>
<tbody>
{% if stats.unwrap_fail_reasons %}
{% for row in stats.unwrap_fail_reasons %}
<tr>
<td class="mono">{{ row.reason }}</td>
<td class="mono">{{ row.count }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="2" class="hint" data-i18n="admin.stats.noFailReasons">No failed unwraps yet.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</section>
<section class="stats-section">
<h3 data-i18n="admin.stats.failReasons24hSection">Unwrap fail reasons (24h)</h3>
<div class="stats-table-wrap">
<table class="stats-table">
<thead>
<tr>
<th data-i18n="admin.stats.col.reason">Reason</th>
<th data-i18n="admin.stats.col.count">Count</th>
</tr>
</thead>
<tbody>
{% if stats.unwrap_fail_reasons_24h %}
{% for row in stats.unwrap_fail_reasons_24h %}
<tr>
<td class="mono">{{ row.reason }}</td>
<td class="mono">{{ row.count }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="2" class="hint" data-i18n="admin.stats.noFailReasons">No failed unwraps yet.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</section>
</div>
</section> </section>
{% endblock %} {% endblock %}
{% block admin_scripts %} {% block admin_scripts %}
+31 -44
View File
@@ -7,12 +7,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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 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_url('css/app.css') }}" /> <link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml" /> <link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="{{ static_url('favicon.ico') }}" sizes="any" /> <link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="{{ static_url('apple-touch-icon.png') }}" /> <link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="manifest" href="{{ static_url('manifest.webmanifest') }}" />
<meta name="theme-color" content="#0b1020" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link id="hljs-theme-dark" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css" /> <link id="hljs-theme-dark" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css" />
<link id="hljs-theme-light" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css" disabled /> <link id="hljs-theme-light" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css" disabled />
@@ -38,56 +36,45 @@
<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-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> <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> </button>
<button type="button" class="icon-btn" id="about-toggle" title="О проекте Wrapped" aria-label="О проекте Wrapped">
<i class="fa-solid fa-circle-question" aria-hidden="true"></i>
</button>
</div> </div>
</header> </header>
<main class="shell"> <main class="shell">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="site-footer"> <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"> <p class="footer-copy">
© <span data-i18n="footer.copy.author">Сергей Антропов</span> © <span data-i18n="footer.copy.author">Сергей Антропов</span>
· <a href="https://devops.org.ru" target="_blank" rel="noopener noreferrer">devops.org.ru</a> · <a href="https://devops.org.ru" target="_blank" rel="noopener noreferrer">devops.org.ru</a>
</p> </p>
</footer> </footer>
<div id="about-modal" class="modal-root hidden" role="dialog" aria-modal="true" aria-labelledby="about-modal-title">
<div class="modal-backdrop" data-about-close></div>
<div class="modal-panel about-modal-panel" role="document">
<header class="about-modal-head">
<h2 class="modal-title" id="about-modal-title" data-i18n="about.title">О проекте Wrapped</h2>
<span class="about-version" title="Version">{{ app_version_label() }}</span>
</header>
<div class="about-modal-body">
<p data-i18n="about.p1">Wrapped — сервис одноразовой безопасной передачи текста, изображений и файлов.</p>
<p data-i18n="about.p2">Можно отправить заметку или код, а также вложения: документы, архивы, скриншоты (drag-and-drop, выбор с диска или вставка из буфера). И текст, и файлы шифруются в браузере до загрузки — на сервер уходит только ciphertext.</p>
<p data-i18n="about.p3">Сервер никогда не видит plaintext: зашифрованные данные лежат на сервере до тех пор, пока получатель не откроет ссылку с ключом и не расшифрует пакет.</p>
<p data-i18n="about.p4">После успешной расшифровки копия на сервере уничтожается. Ключ шифрования живёт во фрагменте URL (#…) и не уходит на сервер вместе с запросом страницы. При желании wrap можно дополнительно защитить паролем.</p>
<a class="about-verify-link" href="/verify">
<span class="about-verify-icon" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i>
</span>
<span class="about-verify-text">
<strong data-i18n="about.verifyLink">Как проверить</strong>
<span data-i18n="about.verifyHint">Что видит сервер и как работает ключ в #…</span>
</span>
<i class="fa-solid fa-arrow-right about-verify-arrow" aria-hidden="true"></i>
</a>
</div>
<div class="modal-actions">
<button type="button" class="btn primary" data-about-close data-i18n="about.close">Понятно</button>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="{{ static_url('js/i18n.js') }}"></script> <script src="/static/js/i18n.js"></script>
<script src="{{ static_url('js/theme.js') }}"></script> <script src="/static/js/theme.js"></script>
<script src="{{ static_url('js/ui.js') }}"></script> <script src="/static/js/ui.js"></script>
<script src="{{ static_url('js/about.js') }}"></script> <script src="/static/js/highlight-ui.js"></script>
<script src="{{ static_url('js/highlight-ui.js') }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+4 -76
View File
@@ -24,41 +24,12 @@
<input id="file-input" type="file" multiple hidden /> <input id="file-input" type="file" multiple hidden />
</div> </div>
<ul id="file-list" class="file-list"></ul> <ul id="file-list" class="file-list"></ul>
<p id="size-meter" class="size-meter hint" aria-live="polite"></p>
<div class="grid-2"> <div class="grid-2">
<div class="field"> <div class="field">
<label for="ttl-seconds" data-i18n="create.ttl">Time to live</label> <label for="ttl-seconds" data-i18n="create.ttl">Time to live</label>
<select id="ttl-seconds"></select> <select id="ttl-seconds"></select>
</div> </div>
<div class="field">
<label for="max-opens" data-i18n="create.maxOpens">Opens</label>
<select id="max-opens"></select>
</div>
</div>
<div class="grid-2">
<div class="field">
<span class="field-label" data-i18n="create.availableFrom">Available from (optional)</span>
<div class="datetime-split">
<div class="input-with-action">
<input id="available-from-date" type="date" aria-label="Date" />
<button type="button" class="field-icon-btn" id="available-from-date-btn" aria-describedby="available-from-date-tip">
<i class="fa-regular fa-calendar" aria-hidden="true"></i>
<span class="sr-only" data-i18n="create.availableFromDate">Pick date</span>
<span class="ui-tooltip" id="available-from-date-tip" role="tooltip" data-i18n="create.availableFromDate">Pick date</span>
</button>
</div>
<div class="input-with-action">
<input id="available-from-time" type="time" step="60" aria-label="Time" />
<button type="button" class="field-icon-btn" id="available-from-time-btn" aria-describedby="available-from-time-tip">
<i class="fa-regular fa-clock" aria-hidden="true"></i>
<span class="sr-only" data-i18n="create.availableFromTime">Pick time</span>
<span class="ui-tooltip" id="available-from-time-tip" role="tooltip" data-i18n="create.availableFromTime">Pick time</span>
</button>
</div>
</div>
<p class="hint" data-i18n="create.availableFromHint">Empty = available immediately</p>
</div>
<div class="field"> <div class="field">
<label for="password" data-i18n="create.password">Password (optional)</label> <label for="password" data-i18n="create.password">Password (optional)</label>
<div class="input-with-action"> <div class="input-with-action">
@@ -93,59 +64,17 @@
<span data-i18n="create.successWarnHint">After opening, ciphertext is deleted on the server.</span> <span data-i18n="create.successWarnHint">After opening, ciphertext is deleted on the server.</span>
</span> </span>
</div> </div>
<label data-i18n="create.shareLink">Share link</label>
<div class="success-layout">
<div class="success-main">
<div id="success-password-block" class="success-password-block hidden">
<div class="success-password-badge" role="status">
<i class="fa-solid fa-lock" aria-hidden="true"></i>
<span data-i18n="create.passwordProtected">Password protected</span>
</div>
<label for="share-password" data-i18n="create.sharePassword">Password</label>
<div class="copy-row">
<input id="share-password" class="mono" readonly autocomplete="off" />
<button type="button" class="btn" id="copy-password" data-i18n="common.copy">Copy</button>
</div>
<p class="hint success-password-hint" data-i18n="create.passwordSeparateHint">
Do not send the password in the same chat as the link.
</p>
</div>
<div id="success-meta-badges" class="success-meta-badges"></div>
<label for="share-link" data-i18n="create.shareLink">Share link</label>
<div class="copy-row"> <div class="copy-row">
<input id="share-link" readonly /> <input id="share-link" readonly />
<button type="button" class="btn" id="copy-link" data-i18n="common.copy">Copy</button> <button type="button" class="btn" id="copy-link" data-i18n="common.copy">Copy</button>
</div> </div>
<label data-i18n="create.token">Wrapped token</label>
<label for="share-token" data-i18n="create.token">Wrapped token</label>
<div class="copy-row"> <div class="copy-row">
<input id="share-token" class="mono" readonly /> <input id="share-token" class="mono" readonly />
<button type="button" class="btn" id="copy-token" data-i18n="common.copy">Copy</button> <button type="button" class="btn" id="copy-token" data-i18n="common.copy">Copy</button>
</div> </div>
</div>
<div class="success-qr" aria-label="QR code">
<div id="share-qr" class="share-qr"></div>
<p class="hint success-qr-hint" data-i18n="create.qrHint">Scan to open the share link</p>
<div class="success-qr-actions">
<button type="button" class="qr-action-btn hidden" id="share-native" aria-describedby="share-native-tip">
<i class="fa-solid fa-share-nodes" aria-hidden="true"></i>
<span class="sr-only" data-i18n="create.share">Share</span>
<span class="ui-tooltip" id="share-native-tip" role="tooltip" data-i18n="create.share">Share</span>
</button>
<button type="button" class="qr-action-btn" id="download-qr" aria-describedby="download-qr-tip">
<i class="fa-solid fa-download" aria-hidden="true"></i>
<span class="sr-only" data-i18n="create.downloadQr">Download QR</span>
<span class="ui-tooltip" id="download-qr-tip" role="tooltip" data-i18n="create.downloadQr">Download QR</span>
</button>
</div>
</div>
</div>
<p class="expires-meta" id="expires-meta"></p> <p class="expires-meta" id="expires-meta"></p>
<button type="button" class="btn ghost" id="create-another" data-i18n="create.another">Create another</button> <button type="button" class="btn ghost" id="create-another" data-i18n="create.another">Create another</button>
</div> </div>
</section> </section>
@@ -163,7 +92,6 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="/static/js/crypto.js"></script>
<script src="{{ static_url('js/crypto.js') }}"></script> <script src="/static/js/create.js"></script>
<script src="{{ static_url('js/create.js') }}"></script>
{% endblock %} {% endblock %}
+7 -32
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="hero-panel unwrap-panel"> <section class="hero-panel unwrap-panel">
<div class="panel-head" id="unwrap-head"> <div class="panel-head">
<p class="eyebrow" data-i18n="unwrap.eyebrow">Unwrap</p> <p class="eyebrow" data-i18n="unwrap.eyebrow">Unwrap</p>
<h1 data-i18n="unwrap.title">Reveal once</h1> <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> <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>
@@ -32,46 +32,21 @@
</div> </div>
</div> </div>
<div id="unwrap-state" class="unwrap-state hidden" role="status">
<div class="unwrap-state-icon" aria-hidden="true">
<i class="fa-solid fa-link-slash" id="unwrap-state-fa"></i>
</div>
<h2 id="unwrap-state-title"></h2>
<p class="lede" id="unwrap-state-hint"></p>
<a class="btn primary" href="/" data-i18n="unwrap.createOwn">Create your own wrap</a>
</div>
<div id="result-panel" class="result-panel hidden"> <div id="result-panel" class="result-panel hidden">
<div class="success-callout" role="status" id="result-callout"> <div class="success-callout" role="status">
<span class="success-callout-icon" aria-hidden="true"> <span class="success-callout-icon" aria-hidden="true">
<i class="fa-solid fa-fire" id="result-callout-fa"></i> <i class="fa-solid fa-fire"></i>
</span> </span>
<span class="success-callout-body"> <span class="success-callout-body">
<strong id="result-callout-title" data-i18n="unwrap.destroyedTitle">Server copy destroyed</strong> <strong data-i18n="unwrap.destroyedTitle">Server copy destroyed</strong>
<span id="result-callout-hint" data-i18n="unwrap.destroyedHint">Preview lives only in this browser session.</span> <span data-i18n="unwrap.destroyedHint">Preview lives only in this browser session.</span>
</span> </span>
</div> </div>
<p class="hint success-trust" id="result-opens-hint"></p>
<p class="hint success-trust" data-i18n="unwrap.trustKey">
The key was only in the link #fragment and was never sent to the server.
</p>
<div class="result-actions">
<button type="button" class="btn hidden" id="download-all" data-i18n="unwrap.downloadAll">Download all</button>
</div>
<div id="items" class="items"></div> <div id="items" class="items"></div>
</div> </div>
</section> </section>
<div id="image-lightbox" class="image-lightbox hidden" role="dialog" aria-modal="true" aria-label="Image preview">
<button type="button" class="image-lightbox-close" id="lightbox-close" aria-label="Close">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
</button>
<img id="lightbox-img" alt="" />
<p class="image-lightbox-caption" id="lightbox-caption"></p>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="/static/js/crypto.js"></script>
<script src="{{ static_url('js/crypto.js') }}"></script> <script src="/static/js/unwrap.js"></script>
<script src="{{ static_url('js/unwrap.js') }}"></script>
{% endblock %} {% endblock %}
-38
View File
@@ -1,38 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }} · Wrapped{% endblock %}
{% block content %}
<section class="hero-panel verify-panel">
<div class="panel-head">
<p class="eyebrow" data-i18n="verify.eyebrow">Verify</p>
<h1 data-i18n="verify.title">How to verify Wrapped</h1>
<p class="lede" data-i18n="verify.lede">What leaves your browser, what stays on the server, and how the key in #fragment works.</p>
</div>
<div class="verify-sections">
<article class="verify-block">
<h2 data-i18n="verify.s1.title">Encryption in the browser</h2>
<p data-i18n="verify.s1.body">Text and files are packed and encrypted with Web Crypto (AES-GCM) before upload. The server receives only ciphertext plus metadata (TTL, MIME, size, optional password hash).</p>
</article>
<article class="verify-block">
<h2 data-i18n="verify.s2.title">Key in the URL fragment</h2>
<p data-i18n="verify.s2.body">The share link looks like /w/&lt;id&gt;#&lt;key&gt;. The part after # never reaches the server in the page request. Without that fragment (or the full wrapped token), ciphertext cannot be decrypted.</p>
</article>
<article class="verify-block">
<h2 data-i18n="verify.s3.title">Opens and destruction</h2>
<p data-i18n="verify.s3.body">By default a wrap can be opened once; then ciphertext is deleted. If the sender chose 23 opens, the server keeps ciphertext until the last successful unwrap. Expiry and password lockout still destroy the package.</p>
</article>
<article class="verify-block">
<h2 data-i18n="verify.s4.title">Optional password</h2>
<p data-i18n="verify.s4.body">When a password is set, the server checks an Argon2 hash before releasing ciphertext. Wrong guesses are limited; empty password does not burn an attempt.</p>
</article>
<article class="verify-block">
<h2 data-i18n="verify.s5.title">Available from</h2>
<p data-i18n="verify.s5.body">If “available from” is set, unwrap is rejected until that time. After that, normal open/expiry rules apply.</p>
</article>
</div>
<div class="actions actions-center">
<a class="btn primary" href="/" data-i18n="verify.createCta">Create a wrap</a>
</div>
</section>
{% endblock %}
-128
View File
@@ -1,128 +0,0 @@
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
_VERSION_FILE = Path(__file__).resolve().parents[1] / "VERSION"
_PROJECT_ROOT = _VERSION_FILE.parent
_PYPROJECT_FILE = _PROJECT_ROOT / "pyproject.toml"
_CHART_FILE = _PROJECT_ROOT / "helm" / "wrapped" / "Chart.yaml"
_VALUES_FILE = _PROJECT_ROOT / "helm" / "wrapped" / "values.yaml"
_DEFAULT_VERSION = "0.1.0"
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
def _read_version_file(path: Path) -> str | None:
try:
text = path.read_text(encoding="utf-8").strip()
except OSError:
return None
if _VERSION_RE.match(text):
return text
return None
@lru_cache
def get_app_version() -> str:
for path in (
Path("/app/VERSION"),
_VERSION_FILE,
Path.cwd() / "VERSION",
):
found = _read_version_file(path)
if found:
return found
try:
from importlib.metadata import version
meta = version("wrapped")
if _VERSION_RE.match(meta):
return meta
except Exception:
pass
return _DEFAULT_VERSION
def get_app_version_label() -> str:
clear_version_cache()
return f"v{get_app_version()}"
def parse_version(value: str) -> tuple[int, int, int]:
match = _VERSION_RE.match(value.strip())
if not match:
raise ValueError(f"Invalid semantic version: {value!r}")
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def format_version(major: int, minor: int, patch: int) -> str:
return f"{major}.{minor}.{patch}"
def clear_version_cache() -> None:
get_app_version.cache_clear()
def write_project_version(version: str) -> None:
parse_version(version)
_VERSION_FILE.write_text(f"{version}\n", encoding="utf-8")
pyproject = _PYPROJECT_FILE.read_text(encoding="utf-8")
pyproject, count = re.subn(
r'^version = ".*"$',
f'version = "{version}"',
pyproject,
count=1,
flags=re.MULTILINE,
)
if count != 1:
raise RuntimeError("Failed to update pyproject.toml version")
_PYPROJECT_FILE.write_text(pyproject, encoding="utf-8")
chart = _CHART_FILE.read_text(encoding="utf-8")
chart, chart_count = re.subn(
r"^version: .*$",
f"version: {version}",
chart,
count=1,
flags=re.MULTILINE,
)
chart, app_count = re.subn(
r'^appVersion: ".*"$',
f'appVersion: "{version}"',
chart,
count=1,
flags=re.MULTILINE,
)
if chart_count != 1 or app_count != 1:
raise RuntimeError("Failed to update Chart.yaml version")
_CHART_FILE.write_text(chart, encoding="utf-8")
values = _VALUES_FILE.read_text(encoding="utf-8")
values, values_count = re.subn(
r'^(\s*tag:\s*)".*"$',
rf'\1"{version}"',
values,
count=1,
flags=re.MULTILINE,
)
if values_count != 1:
raise RuntimeError("Failed to update helm values.yaml image.tag")
_VALUES_FILE.write_text(values, encoding="utf-8")
clear_version_cache()
def bump_patch_version() -> str:
major, minor, patch = parse_version(get_app_version())
version = format_version(major, minor, patch + 1)
write_project_version(version)
return version
def bump_minor_version() -> str:
major, minor, patch = parse_version(get_app_version())
version = format_version(major, minor + 1, 0)
write_project_version(version)
return version
-1
View File
@@ -40,7 +40,6 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
volumes: volumes:
- ./app:/app/app:ro - ./app:/app/app:ro
- ./VERSION:/app/VERSION:ro
command: > command: >
sh -c "alembic upgrade head && sh -c "alembic upgrade head &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: wrapped name: wrapped
description: Zero-knowledge one-time encrypted drop (Wrapped) description: Zero-knowledge one-time encrypted drop (Wrapped)
type: application type: application
version: 0.1.4 version: 0.1.0
appVersion: "0.1.4" appVersion: "0.1.0"
+1 -1
View File
@@ -2,7 +2,7 @@ replicaCount: 1
image: image:
repository: inecs/wrapped repository: inecs/wrapped
tag: "0.1.4" tag: "0.1.0"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
service: service:
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "wrapped" name = "wrapped"
version = "0.1.4" version = "0.1.0"
description = "Zero-knowledge one-time encrypted drop service" description = "Zero-knowledge one-time encrypted drop service"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env python3
"""CLI: bump project SemVer (VERSION + pyproject + Helm chart/values)."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.version import bump_minor_version, bump_patch_version # noqa: E402
def main(argv: list[str] | None = None) -> int:
args = argv if argv is not None else sys.argv[1:]
if len(args) != 1 or args[0] not in {"patch", "minor"}:
print("Usage: bump_version.py patch|minor", file=sys.stderr)
return 1
version = bump_patch_version() if args[0] == "patch" else bump_minor_version()
print(version)
return 0
if __name__ == "__main__":
raise SystemExit(main())