Compare commits
8 Commits
fd0bf89e9e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 81afba2362 | |||
| e80b2e1628 | |||
| d410b3c224 | |||
| eef8c4ec57 | |||
| 54749e5e12 | |||
| 4389c0cea4 | |||
| f35144ed28 | |||
| f728d74f34 |
Vendored
+10
-17
@@ -5,7 +5,7 @@
|
|||||||
// 2) при успехе — trigger Deploy с wait:false и agent none → Builder-под умирает
|
// 2) при успехе — trigger Deploy с wait:false и agent none → Builder-под умирает
|
||||||
// 3) Deploy стартует отдельно уже без Builder
|
// 3) Deploy стартует отдельно уже без Builder
|
||||||
//
|
//
|
||||||
// Версия образа = VERSION из коммита. Bump: make bump-patch / make push.
|
// Версия образа = VERSION из коммита (без auto-bump). Ручной bump: make bump-patch.
|
||||||
//
|
//
|
||||||
// Credentials (Global):
|
// Credentials (Global):
|
||||||
// harbor-devops-tools-push-pull-access — Harbor devops-tools (robot)
|
// harbor-devops-tools-push-pull-access — Harbor devops-tools (robot)
|
||||||
@@ -30,7 +30,6 @@ 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'
|
||||||
// RELEASE_TAG — после checkout (с agent none GIT_COMMIT ещё нет → валидация env падает)
|
|
||||||
BUILDX_BUILDER = "jenkins-wrapped-${env.BUILD_NUMBER}"
|
BUILDX_BUILDER = "jenkins-wrapped-${env.BUILD_NUMBER}"
|
||||||
DEPLOY_JOB = 'devops-tools/wrapped/wrapped-deploy/main'
|
DEPLOY_JOB = 'devops-tools/wrapped/wrapped-deploy/main'
|
||||||
TZ = 'Europe/Moscow'
|
TZ = 'Europe/Moscow'
|
||||||
@@ -60,12 +59,7 @@ pipeline {
|
|||||||
if (!env.IMAGE_VERSION) {
|
if (!env.IMAGE_VERSION) {
|
||||||
error('VERSION file is empty')
|
error('VERSION file is empty')
|
||||||
}
|
}
|
||||||
if (!env.GIT_COMMIT) {
|
|
||||||
error('GIT_COMMIT is empty after checkout')
|
|
||||||
}
|
|
||||||
env.RELEASE_TAG = env.GIT_COMMIT.take(7)
|
|
||||||
echo "IMAGE_VERSION from VERSION → ${env.IMAGE_VERSION}"
|
echo "IMAGE_VERSION from VERSION → ${env.IMAGE_VERSION}"
|
||||||
echo "RELEASE_TAG → ${env.RELEASE_TAG}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,7 +83,7 @@ pipeline {
|
|||||||
set -eux
|
set -eux
|
||||||
|
|
||||||
test -n "${IMAGE_VERSION}"
|
test -n "${IMAGE_VERSION}"
|
||||||
echo "Building tags: ${IMAGE_VERSION}, ${RELEASE_TAG}, latest"
|
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
|
||||||
@@ -98,27 +92,26 @@ 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
|
||||||
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}:${RELEASE_TAG}-amd64" \
|
-t "${HARBOR_IMAGE}:${IMAGE_VERSION}-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}:${RELEASE_TAG}-arm64" \
|
-t "${HARBOR_IMAGE}:${IMAGE_VERSION}-arm64" \
|
||||||
-t "${DOCKERHUB_IMAGE}:${RELEASE_TAG}-arm64" \
|
|
||||||
-f Dockerfile .
|
-f Dockerfile .
|
||||||
|
|
||||||
|
# Публичные теги: только SemVer + latest
|
||||||
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" \
|
||||||
"${IMAGE}:${RELEASE_TAG}-amd64" \
|
"${HARBOR_IMAGE}:${IMAGE_VERSION}-amd64" \
|
||||||
"${IMAGE}:${RELEASE_TAG}-arm64"
|
"${HARBOR_IMAGE}:${IMAGE_VERSION}-arm64"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "--- Harbor ---"
|
echo "--- Harbor ---"
|
||||||
@@ -146,7 +139,7 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
success {
|
success {
|
||||||
echo "✓ Build OK: ${DOCKERHUB_IMAGE}:{${RELEASE_TAG},${IMAGE_VERSION},latest}"
|
echo "✓ Build OK: ${DOCKERHUB_IMAGE}:{${IMAGE_VERSION},latest}"
|
||||||
}
|
}
|
||||||
failure {
|
failure {
|
||||||
echo "✗ Build/push не удались — Deploy не запускается"
|
echo "✗ Build/push не удались — Deploy не запускается"
|
||||||
@@ -185,7 +178,7 @@ pipeline {
|
|||||||
post {
|
post {
|
||||||
success {
|
success {
|
||||||
echo "✓ Version: ${IMAGE_VERSION}"
|
echo "✓ Version: ${IMAGE_VERSION}"
|
||||||
echo "✓ Image: ${DOCKERHUB_IMAGE}:{${RELEASE_TAG},${IMAGE_VERSION},latest}"
|
echo "✓ Image: ${DOCKERHUB_IMAGE}:{${IMAGE_VERSION},latest}"
|
||||||
echo "✓ Deploy: ${DEPLOY_JOB} triggered (async)"
|
echo "✓ Deploy: ${DEPLOY_JOB} triggered (async)"
|
||||||
}
|
}
|
||||||
failure {
|
failure {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ help:
|
|||||||
@echo " make bump-minor VERSION +0.1.0"
|
@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 bump-patch + git add/commit (prompt) + push"
|
@echo " make push git add/commit (prompt) + push (без auto-bump)"
|
||||||
@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"
|
||||||
@@ -136,7 +136,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
|
||||||
|
|
||||||
# Same flow as proxmox_api_simulator / infra: bump SemVer, stage, multiline commit, push.
|
# Ручной SemVer (не вызывается из make push).
|
||||||
bump-patch:
|
bump-patch:
|
||||||
@PYTHONPATH=. python3 scripts/bump_version.py patch
|
@PYTHONPATH=. python3 scripts/bump_version.py patch
|
||||||
@echo "VERSION → $$(cat VERSION)"
|
@echo "VERSION → $$(cat VERSION)"
|
||||||
@@ -153,7 +153,7 @@ version-commit:
|
|||||||
git commit -m "Bump version to $$(cat VERSION)."; \
|
git commit -m "Bump version to $$(cat VERSION)."; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
push: bump-patch
|
push:
|
||||||
@set -e; \
|
@set -e; \
|
||||||
git add .; \
|
git add .; \
|
||||||
echo "=== staged ==="; \
|
echo "=== staged ==="; \
|
||||||
|
|||||||
@@ -340,14 +340,12 @@ services:
|
|||||||
|
|
||||||
### UX (v0.1.3+)
|
### UX (v0.1.3+)
|
||||||
|
|
||||||
- После создания: **QR** на share-link, **скачать QR (PNG)**, **Web Share** (если есть `navigator.share`), чеклист и trust-строка про `#key`; отдельные кнопки Copy для ссылки / токена / пароля; пароль не советуется слать в той же переписке.
|
- После создания: **QR** на share-link, иконки **скачать QR (PNG)** и **Web Share** (если есть `navigator.share`) под QR; отдельные кнопки Copy для ссылки / токена / пароля; пароль не советуется слать в той же переписке.
|
||||||
- Create: счётчик размера `≈ used / max` и предупреждение near-limit (сверх лимита — `create.tooLarge`).
|
- Create: счётчик размера `≈ used / max`; **число открытий** 1–3 (потолок в админке); опционально **«доступно с»** (дата + время); человеческие TTL (в т.ч. «до вечера»).
|
||||||
- Unwrap: Enter в поле пароля отправляет форму; после `password_required` / `bad_password` — focus+select; при ≥2 элементах — **скачать всё** (zip через JSZip); trust-строка на результате; спокойный экран «ссылка недоступна» для already used / expired (anti-enumeration); отдельные состояния для `password_locked`, rate limit, CAPTCHA, ошибки расшифровки.
|
- Unwrap: Enter в поле пароля; focus+select при ошибках пароля; zip all; trust-строка; экран **ещё недоступно** до `available_from`; при N>1 ciphertext остаётся до последнего открытия.
|
||||||
- Картинки после unwrap: inline-превью и **lightbox** (тап/клик).
|
- Картинки: lightbox. Тема: `prefers-color-scheme` при первом визите; haptic на Copy. PWA manifest.
|
||||||
- Тема при первом визите следует `prefers-color-scheme` (пока нет выбора в `localStorage`); лёгкий haptic после успешного Copy.
|
- Страница [`/verify`](/verify) — как проверить ZK-модель.
|
||||||
- Минимальный **PWA**: `manifest.webmanifest` (без Service Worker для API).
|
- UI EN/RU. Статика с `?v=версия.mtime`.
|
||||||
- UI строки EN/RU через `i18n.js`.
|
|
||||||
- Статика (`/static/...`) отдаётся с `?v=версия.mtime` (cache-bust после деплоя).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -443,7 +441,7 @@ 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` в коммите), `:<short-sha>`, `:latest`.
|
Публичные теги на каждый реестр: `:<semver>` (из `VERSION` в коммите) и `:latest`. Short-sha больше не пушится. Промежуточные `:<semver>-amd64` / `-arm64` остаются только в Harbor для сборки multi-arch manifest.
|
||||||
|
|
||||||
### Версионирование
|
### Версионирование
|
||||||
|
|
||||||
@@ -452,8 +450,8 @@ docker buildx build \
|
|||||||
| Где | Поведение |
|
| Где | Поведение |
|
||||||
|-----|-----------|
|
|-----|-----------|
|
||||||
| UI (модалка «?») | бейдж `vX.Y.Z` |
|
| UI (модалка «?») | бейдж `vX.Y.Z` |
|
||||||
| `make bump-patch` / `bump-minor` | ручной bump |
|
| `make bump-patch` / `bump-minor` | ручной bump (по желанию) |
|
||||||
| `make push` | автоматически `bump-patch`, затем commit/push |
|
| `make push` | commit/push **без** auto-bump |
|
||||||
| Jenkins (`Jenkinsfile`) | читает `VERSION` из коммита **без** доп. bump → те же теги в образе и на кластере |
|
| Jenkins (`Jenkinsfile`) | читает `VERSION` из коммита **без** доп. bump → те же теги в образе и на кластере |
|
||||||
|
|
||||||
Что в `VERSION` запушили — то и уйдёт в Harbor/Hub/деплой (и в футер модалки внутри образа).
|
Что в `VERSION` запушили — то и уйдёт в Harbor/Hub/деплой (и в футер модалки внутри образа).
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""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")
|
||||||
@@ -210,6 +210,9 @@ 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()
|
||||||
|
|
||||||
@@ -408,6 +411,7 @@ 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,
|
||||||
|
|||||||
+76
-8
@@ -84,6 +84,11 @@ 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:
|
||||||
@@ -124,6 +129,18 @@ 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(
|
||||||
@@ -137,6 +154,9 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -156,6 +176,8 @@ 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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -164,6 +186,8 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -237,7 +261,8 @@ async def unwrap(
|
|||||||
fail("unavailable")
|
fail("unavailable")
|
||||||
|
|
||||||
assert wrap is not None
|
assert wrap is not None
|
||||||
if wrap.expires_at <= datetime.now(timezone.utc):
|
now = 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()
|
||||||
@@ -251,6 +276,26 @@ 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.
|
||||||
@@ -331,23 +376,25 @@ async def unwrap(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Atomic consume
|
# Atomic open: increment opens_used while still under max_opens
|
||||||
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(status=WrapStatus.consumed, consumed_at=now)
|
.values(opens_used=Wrap.opens_used + 1)
|
||||||
.returning(Wrap.id)
|
.returning(Wrap.id, Wrap.opens_used, Wrap.max_opens)
|
||||||
)
|
)
|
||||||
consumed = upd.scalar_one_or_none()
|
row = upd.one_or_none()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
if not consumed:
|
if not row:
|
||||||
await write_audit(
|
await write_audit(
|
||||||
db,
|
db,
|
||||||
event_type="wrap.unwrap",
|
event_type="wrap.unwrap",
|
||||||
@@ -358,6 +405,11 @@ 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:
|
||||||
@@ -370,8 +422,17 @@ 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:
|
|
||||||
await delete_wrap_object(db, wrap)
|
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 write_audit(
|
await write_audit(
|
||||||
db,
|
db,
|
||||||
@@ -383,6 +444,9 @@ 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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -393,4 +457,8 @@ 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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ 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 (
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ 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()
|
||||||
@@ -96,6 +98,9 @@ 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)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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):
|
||||||
@@ -27,6 +28,8 @@ 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):
|
||||||
@@ -34,6 +37,8 @@ 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):
|
||||||
@@ -48,6 +53,10 @@ 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):
|
||||||
@@ -68,6 +77,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,4 +70,5 @@ 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))),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,14 +141,16 @@ async def collect_stats(db: AsyncSession) -> dict[str, Any]:
|
|||||||
]
|
]
|
||||||
if since is not None:
|
if since is not None:
|
||||||
filters.append(AuditEvent.created_at >= since)
|
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 = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(
|
select(
|
||||||
func.coalesce(reason_col, "unknown"),
|
reason_key,
|
||||||
func.count(AuditEvent.id),
|
func.count(AuditEvent.id),
|
||||||
)
|
)
|
||||||
.where(*filters)
|
.where(*filters)
|
||||||
.group_by(func.coalesce(reason_col, "unknown"))
|
.group_by(reason_key)
|
||||||
.order_by(func.count(AuditEvent.id).desc())
|
.order_by(func.count(AuditEvent.id).desc())
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.1 KiB |
+184
-44
@@ -178,7 +178,8 @@ 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; }
|
.top-actions { display: flex; gap: 0.5rem; overflow: visible; position: relative; z-index: 5; }
|
||||||
|
.topbar { overflow: visible; }
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
min-width: 42px;
|
min-width: 42px;
|
||||||
@@ -252,7 +253,56 @@ html[data-theme="dark"] .icon-btn:hover {
|
|||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lede { color: var(--muted); margin: 0 0 1.5rem; max-width: 42rem; }
|
.verify-sections {
|
||||||
|
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;
|
||||||
@@ -322,46 +372,6 @@ html[data-theme="dark"] .icon-btn:hover {
|
|||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.success-checklist {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
.success-checklist li {
|
|
||||||
position: relative;
|
|
||||||
padding-left: 1.35rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.success-checklist li::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0.35rem;
|
|
||||||
width: 0.55rem;
|
|
||||||
height: 0.55rem;
|
|
||||||
border-radius: 2px;
|
|
||||||
border: 1.5px solid var(--accent-2);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
.success-trust {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
.success-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
}
|
|
||||||
.success-actions .btn {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 8rem;
|
|
||||||
}
|
|
||||||
.size-meter {
|
.size-meter {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -381,6 +391,7 @@ html[data-theme="dark"] .icon-btn:hover {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.share-qr {
|
.share-qr {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
@@ -405,6 +416,61 @@ html[data-theme="dark"] .icon-btn:hover {
|
|||||||
max-width: 11rem;
|
max-width: 11rem;
|
||||||
line-height: 1.35;
|
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) {
|
@media (max-width: 860px) {
|
||||||
.success-layout {
|
.success-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -788,6 +854,7 @@ 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;
|
||||||
@@ -799,6 +866,22 @@ 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; }
|
||||||
@@ -1030,6 +1113,63 @@ html[data-theme="light"] .about-version {
|
|||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
line-height: 1.5;
|
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 {
|
.about-modal-panel .modal-actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
@@ -1423,7 +1563,7 @@ 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: 860px) {
|
@media (max-width: 1015px) {
|
||||||
.admin-top {
|
.admin-top {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -1521,7 +1661,7 @@ html[data-theme="light"] .admin-nav {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 861px) {
|
@media (min-width: 1016px) {
|
||||||
.admin-top > .brand {
|
.admin-top > .brand {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -4,7 +4,7 @@
|
|||||||
const nav = document.getElementById("admin-nav");
|
const nav = document.getElementById("admin-nav");
|
||||||
if (!top || !toggle || !nav) return;
|
if (!top || !toggle || !nav) return;
|
||||||
|
|
||||||
const mq = window.matchMedia("(max-width: 860px)");
|
const mq = window.matchMedia("(max-width: 1015px)");
|
||||||
|
|
||||||
const syncLabel = () => {
|
const syncLabel = () => {
|
||||||
const open = top.classList.contains("is-nav-open");
|
const open = top.classList.contains("is-nav-open");
|
||||||
|
|||||||
+124
-8
@@ -1,5 +1,6 @@
|
|||||||
(() => {
|
(() => {
|
||||||
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;
|
||||||
@@ -14,6 +15,11 @@
|
|||||||
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"),
|
||||||
@@ -27,11 +33,11 @@
|
|||||||
shareToken: document.getElementById("share-token"),
|
shareToken: document.getElementById("share-token"),
|
||||||
sharePassword: document.getElementById("share-password"),
|
sharePassword: document.getElementById("share-password"),
|
||||||
passwordBlock: document.getElementById("success-password-block"),
|
passwordBlock: document.getElementById("success-password-block"),
|
||||||
|
successMetaBadges: document.getElementById("success-meta-badges"),
|
||||||
shareQr: document.getElementById("share-qr"),
|
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"),
|
sizeMeter: document.getElementById("size-meter"),
|
||||||
checkPasswordItem: document.getElementById("check-password-item"),
|
|
||||||
shareNative: document.getElementById("share-native"),
|
shareNative: document.getElementById("share-native"),
|
||||||
downloadQr: document.getElementById("download-qr"),
|
downloadQr: document.getElementById("download-qr"),
|
||||||
};
|
};
|
||||||
@@ -139,7 +145,7 @@
|
|||||||
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>${f.name} <small>(${f.type || "file"} · ${window.WrappedUI.formatBytes(f.size)})</small></span>`;
|
li.innerHTML = `<span>${escapeHtml(f.name)} <small>(${escapeHtml(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 = "×";
|
||||||
@@ -153,6 +159,14 @@
|
|||||||
refreshSizeMeter();
|
refreshSizeMeter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
function addFiles(list) {
|
function addFiles(list) {
|
||||||
for (const f of list) {
|
for (const f of list) {
|
||||||
if (!mimeAllowed(f.type || "application/octet-stream")) {
|
if (!mimeAllowed(f.type || "application/octet-stream")) {
|
||||||
@@ -165,15 +179,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.6h", value: 6 * 3600 },
|
{ key: "create.ttl.evening", value: evening },
|
||||||
{ 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 },
|
||||||
@@ -193,6 +218,58 @@
|
|||||||
.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
|
||||||
@@ -240,6 +317,7 @@
|
|||||||
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();
|
||||||
@@ -326,19 +404,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSuccess({ link, token, password, expiresAt }) {
|
function showSuccess({ link, token, password, expiresAt, availableFrom, maxOpens }) {
|
||||||
els.shareLink.value = link;
|
els.shareLink.value = link;
|
||||||
els.shareToken.value = token;
|
els.shareToken.value = token;
|
||||||
lastExpiresAt = expiresAt;
|
lastExpiresAt = expiresAt;
|
||||||
|
lastAvailableFrom = availableFrom || null;
|
||||||
|
lastMaxOpens = maxOpens || 1;
|
||||||
refreshExpiresMeta();
|
refreshExpiresMeta();
|
||||||
|
refreshSuccessBadges();
|
||||||
if (password) {
|
if (password) {
|
||||||
els.sharePassword.value = password;
|
els.sharePassword.value = password;
|
||||||
els.passwordBlock.classList.remove("hidden");
|
els.passwordBlock.classList.remove("hidden");
|
||||||
els.checkPasswordItem?.classList.remove("hidden");
|
|
||||||
} else {
|
} else {
|
||||||
els.sharePassword.value = "";
|
els.sharePassword.value = "";
|
||||||
els.passwordBlock.classList.add("hidden");
|
els.passwordBlock.classList.add("hidden");
|
||||||
els.checkPasswordItem?.classList.add("hidden");
|
|
||||||
}
|
}
|
||||||
renderShareQr(link);
|
renderShareQr(link);
|
||||||
syncShareControls();
|
syncShareControls();
|
||||||
@@ -433,6 +512,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -453,9 +534,10 @@
|
|||||||
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",
|
||||||
@@ -464,7 +546,20 @@
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json().catch(() => ({}));
|
const err = await resp.json().catch(() => ({}));
|
||||||
showError(err.detail || t("common.error"));
|
const detail = err.detail;
|
||||||
|
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();
|
||||||
@@ -475,6 +570,8 @@
|
|||||||
token,
|
token,
|
||||||
password,
|
password,
|
||||||
expiresAt: data.expires_at,
|
expiresAt: data.expires_at,
|
||||||
|
availableFrom: data.available_from,
|
||||||
|
maxOpens: data.max_opens || maxOpens,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
showError(t("common.error"));
|
showError(t("common.error"));
|
||||||
@@ -500,10 +597,29 @@
|
|||||||
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();
|
refreshSizeMeter();
|
||||||
syncShareControls();
|
syncShareControls();
|
||||||
setLanguage(els.lang.value, { silent: true });
|
setLanguage(els.lang.value, { silent: true });
|
||||||
|
|||||||
@@ -176,14 +176,17 @@
|
|||||||
return b64decode(b64);
|
return b64decode(b64);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fileToItem(file) {
|
async function fileToItem(file, label) {
|
||||||
const buf = new Uint8Array(await file.arrayBuffer());
|
const buf = new Uint8Array(await file.arrayBuffer());
|
||||||
return {
|
const item = {
|
||||||
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 = {
|
||||||
|
|||||||
@@ -66,6 +66,50 @@
|
|||||||
"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 2–3 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 (1–10). 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",
|
||||||
@@ -326,6 +370,50 @@
|
|||||||
"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",
|
||||||
|
|||||||
+69
-17
@@ -25,6 +25,10 @@
|
|||||||
lightboxCaption: document.getElementById("lightbox-caption"),
|
lightboxCaption: document.getElementById("lightbox-caption"),
|
||||||
lightboxClose: document.getElementById("lightbox-close"),
|
lightboxClose: document.getElementById("lightbox-close"),
|
||||||
downloadAll: document.getElementById("download-all"),
|
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 lastPack = null;
|
||||||
@@ -273,28 +277,51 @@
|
|||||||
els.downloadAll.setAttribute("aria-label", t("unwrap.downloadAll"));
|
els.downloadAll.setAttribute("aria-label", t("unwrap.downloadAll"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPackage(pack) {
|
function renderPackage(pack, meta = {}) {
|
||||||
revokeAllUrls();
|
revokeAllUrls();
|
||||||
lastPack = pack;
|
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 meta = document.createElement("div");
|
const metaEl = document.createElement("div");
|
||||||
const strong = document.createElement("strong");
|
const strong = document.createElement("strong");
|
||||||
strong.textContent = "text";
|
strong.textContent = label || "text";
|
||||||
meta.appendChild(strong);
|
metaEl.appendChild(strong);
|
||||||
meta.appendChild(document.createTextNode(" · "));
|
metaEl.appendChild(document.createTextNode(" · "));
|
||||||
const langEl = document.createElement("span");
|
const langEl = document.createElement("span");
|
||||||
langEl.className = "mono";
|
langEl.className = "mono";
|
||||||
langEl.textContent = lang;
|
langEl.textContent = lang;
|
||||||
meta.appendChild(langEl);
|
metaEl.appendChild(langEl);
|
||||||
head.appendChild(meta);
|
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));
|
||||||
@@ -318,27 +345,31 @@
|
|||||||
const name = item.name || "file";
|
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 meta = document.createElement("div");
|
const metaEl = document.createElement("div");
|
||||||
const strong = document.createElement("strong");
|
const strong = document.createElement("strong");
|
||||||
strong.textContent = name;
|
strong.textContent = label || name;
|
||||||
meta.appendChild(strong);
|
metaEl.appendChild(strong);
|
||||||
meta.appendChild(document.createTextNode(" · "));
|
if (label) {
|
||||||
|
metaEl.appendChild(document.createTextNode(" · "));
|
||||||
|
metaEl.appendChild(document.createTextNode(name));
|
||||||
|
}
|
||||||
|
metaEl.appendChild(document.createTextNode(" · "));
|
||||||
const mimeEl = document.createElement("span");
|
const mimeEl = document.createElement("span");
|
||||||
mimeEl.className = "mono";
|
mimeEl.className = "mono";
|
||||||
mimeEl.textContent = mime;
|
mimeEl.textContent = mime;
|
||||||
meta.appendChild(mimeEl);
|
metaEl.appendChild(mimeEl);
|
||||||
meta.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`));
|
metaEl.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`));
|
||||||
head.appendChild(meta);
|
head.appendChild(metaEl);
|
||||||
head.appendChild(makeDownloadBtn(() => downloadBlob(name, blob)));
|
head.appendChild(makeDownloadBtn(() => downloadBlob(name, blob)));
|
||||||
card.appendChild(head);
|
card.appendChild(head);
|
||||||
if (mime.startsWith("image/")) {
|
if (mime.startsWith("image/")) {
|
||||||
const url = trackUrl(URL.createObjectURL(blob));
|
const url = trackUrl(URL.createObjectURL(blob));
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.alt = name;
|
img.alt = label || name;
|
||||||
img.src = url;
|
img.src = url;
|
||||||
img.className = "item-preview-img";
|
img.className = "item-preview-img";
|
||||||
img.loading = "lazy";
|
img.loading = "lazy";
|
||||||
img.addEventListener("click", () => openLightbox(url, name));
|
img.addEventListener("click", () => openLightbox(url, label || name));
|
||||||
card.appendChild(img);
|
card.appendChild(img);
|
||||||
const tip = document.createElement("p");
|
const tip = document.createElement("p");
|
||||||
tip.className = "hint item-preview-hint";
|
tip.className = "hint item-preview-hint";
|
||||||
@@ -424,6 +455,22 @@
|
|||||||
});
|
});
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
if (detail.code === "password_required") {
|
if (detail.code === "password_required") {
|
||||||
showError(
|
showError(
|
||||||
t("unwrap.passwordRequired"),
|
t("unwrap.passwordRequired"),
|
||||||
@@ -477,7 +524,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastWrapId = parsed.wrapId;
|
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.head?.classList.add("hidden");
|
||||||
els.state?.classList.add("hidden");
|
els.state?.classList.add("hidden");
|
||||||
|
|||||||
@@ -8,10 +8,28 @@
|
|||||||
"theme_color": "#0b1020",
|
"theme_color": "#0b1020",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/static/favicon.svg",
|
"src": "/static/icon-192.png",
|
||||||
"sizes": "any",
|
"sizes": "192x192",
|
||||||
"type": "image/svg+xml",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,14 @@
|
|||||||
<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 (1–10). Default: 3.
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,16 @@
|
|||||||
<p data-i18n="about.p2">Можно отправить заметку или код, а также вложения: документы, архивы, скриншоты (drag-and-drop, выбор с диска или вставка из буфера). И текст, и файлы шифруются в браузере до загрузки — на сервер уходит только ciphertext.</p>
|
<p data-i18n="about.p2">Можно отправить заметку или код, а также вложения: документы, архивы, скриншоты (drag-and-drop, выбор с диска или вставка из буфера). И текст, и файлы шифруются в браузере до загрузки — на сервер уходит только ciphertext.</p>
|
||||||
<p data-i18n="about.p3">Сервер никогда не видит plaintext: зашифрованные данные лежат на сервере до тех пор, пока получатель не откроет ссылку с ключом и не расшифрует пакет.</p>
|
<p data-i18n="about.p3">Сервер никогда не видит plaintext: зашифрованные данные лежат на сервере до тех пор, пока получатель не откроет ссылку с ключом и не расшифрует пакет.</p>
|
||||||
<p data-i18n="about.p4">После успешной расшифровки копия на сервере уничтожается. Ключ шифрования живёт во фрагменте URL (#…) и не уходит на сервер вместе с запросом страницы. При желании wrap можно дополнительно защитить паролем.</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>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn primary" data-about-close data-i18n="about.close">Понятно</button>
|
<button type="button" class="btn primary" data-about-close data-i18n="about.close">Понятно</button>
|
||||||
|
|||||||
+44
-16
@@ -31,6 +31,34 @@
|
|||||||
<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">
|
||||||
@@ -66,15 +94,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="success-checklist" id="success-checklist">
|
|
||||||
<li data-i18n="create.checkLink">Copy the share link</li>
|
|
||||||
<li id="check-password-item" class="hidden" data-i18n="create.checkPassword">Send the password in a separate message</li>
|
|
||||||
<li data-i18n="create.checkExpires">Note the expiry time</li>
|
|
||||||
</ul>
|
|
||||||
<p class="hint success-trust" data-i18n="create.trustKey">
|
|
||||||
The encryption key lives only in the link #fragment — the server never sees it.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="success-layout">
|
<div class="success-layout">
|
||||||
<div class="success-main">
|
<div class="success-main">
|
||||||
<div id="success-password-block" class="success-password-block hidden">
|
<div id="success-password-block" class="success-password-block hidden">
|
||||||
@@ -92,6 +111,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="success-meta-badges" class="success-meta-badges"></div>
|
||||||
|
|
||||||
<label for="share-link" data-i18n="create.shareLink">Share link</label>
|
<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 />
|
||||||
@@ -103,21 +124,28 @@
|
|||||||
<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 class="success-actions">
|
|
||||||
<button type="button" class="btn hidden" id="share-native" data-i18n="create.share">Share</button>
|
|
||||||
<button type="button" class="btn" id="download-qr" data-i18n="create.downloadQr">Download QR</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="expires-meta" id="expires-meta"></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="success-qr" aria-label="QR code">
|
<div class="success-qr" aria-label="QR code">
|
||||||
<div id="share-qr" class="share-qr"></div>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -42,15 +42,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="result-panel" class="result-panel hidden">
|
<div id="result-panel" class="result-panel hidden">
|
||||||
<div class="success-callout" role="status">
|
<div class="success-callout" role="status" id="result-callout">
|
||||||
<span class="success-callout-icon" aria-hidden="true">
|
<span class="success-callout-icon" aria-hidden="true">
|
||||||
<i class="fa-solid fa-fire"></i>
|
<i class="fa-solid fa-fire" id="result-callout-fa"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="success-callout-body">
|
<span class="success-callout-body">
|
||||||
<strong data-i18n="unwrap.destroyedTitle">Server copy destroyed</strong>
|
<strong id="result-callout-title" data-i18n="unwrap.destroyedTitle">Server copy destroyed</strong>
|
||||||
<span data-i18n="unwrap.destroyedHint">Preview lives only in this browser session.</span>
|
<span id="result-callout-hint" 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">
|
<p class="hint success-trust" data-i18n="unwrap.trustKey">
|
||||||
The key was only in the link #fragment and was never sent to the server.
|
The key was only in the link #fragment and was never sent to the server.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{% 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/<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.</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 2–3 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 %}
|
||||||
Reference in New Issue
Block a user