diff --git a/README.md b/README.md index f027549..fca2166 100644 --- a/README.md +++ b/README.md @@ -341,13 +341,11 @@ services: ### UX (v0.1.3+) - После создания: **QR** на share-link, иконки **скачать QR (PNG)** и **Web Share** (если есть `navigator.share`) под QR; отдельные кнопки Copy для ссылки / токена / пароля; пароль не советуется слать в той же переписке. -- Create: счётчик размера `≈ used / max` и предупреждение near-limit (сверх лимита — `create.tooLarge`). -- Unwrap: Enter в поле пароля отправляет форму; после `password_required` / `bad_password` — focus+select; при ≥2 элементах — **скачать всё** (zip через JSZip); trust-строка на результате; спокойный экран «ссылка недоступна» для already used / expired (anti-enumeration); отдельные состояния для `password_locked`, rate limit, CAPTCHA, ошибки расшифровки. -- Картинки после unwrap: inline-превью и **lightbox** (тап/клик). -- Тема при первом визите следует `prefers-color-scheme` (пока нет выбора в `localStorage`); лёгкий haptic после успешного Copy. -- Минимальный **PWA**: `manifest.webmanifest` (без Service Worker для API). -- UI строки EN/RU через `i18n.js`. -- Статика (`/static/...`) отдаётся с `?v=версия.mtime` (cache-bust после деплоя). +- 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 + кнопка **На экран Домой** (Chrome install / iOS подсказка). +- Страница [`/verify`](/verify) — как проверить ZK-модель. +- UI EN/RU. Статика с `?v=версия.mtime`. --- diff --git a/VERSION b/VERSION index b1e80bb..845639e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.1.4 diff --git a/alembic/versions/003_opens_available_from.py b/alembic/versions/003_opens_available_from.py new file mode 100644 index 0000000..10b6dac --- /dev/null +++ b/alembic/versions/003_opens_available_from.py @@ -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") diff --git a/app/api/admin.py b/app/api/admin.py index 56f86a1..cc52069 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -210,6 +210,9 @@ async def settings_save( row.password_max_attempts = min( 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.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, "password_mode": row.password_mode.value, "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, "rate_limit_create_per_minute": row.rate_limit_create_per_minute, "rate_limit_unwrap_per_minute": row.rate_limit_unwrap_per_minute, diff --git a/app/api/wraps.py b/app/api/wraps.py index 9b02d96..3def89e 100644 --- a/app/api/wraps.py +++ b/app/api/wraps.py @@ -84,6 +84,11 @@ async def create_wrap( if body.ttl_seconds > settings.max_ttl_seconds: 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: ciphertext = base64.b64decode(body.ciphertext_b64, validate=True) except Exception as exc: @@ -124,6 +129,18 @@ async def create_wrap( now = datetime.now(timezone.utc) 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) wrap = Wrap( @@ -137,6 +154,9 @@ async def create_wrap( password_hash=password_hash, password_mode=settings.password_mode, expires_at=expires_at, + available_from=available_from, + max_opens=max_opens, + opens_used=0, creator_ip=ip, creator_ua=(meta["user_agent"] or "")[:512] or None, ) @@ -156,6 +176,8 @@ async def create_wrap( "ttl_seconds": body.ttl_seconds, "has_password": body.has_password, "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, password_mode=settings.password_mode.value, share_path=f"/w/{wrap_id}", + max_opens=max_opens, + available_from=available_from, ) @@ -237,7 +261,8 @@ async def unwrap( fail("unavailable") 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 await delete_wrap_object(db, wrap) await db.commit() @@ -251,6 +276,26 @@ async def unwrap( ) 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: max_attempts = max(1, int(settings.password_max_attempts or 3)) # 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 now = datetime.now(timezone.utc) + max_opens = max(1, int(wrap.max_opens or 1)) upd = await db.execute( update(Wrap) .where( Wrap.id == wrap_id, Wrap.status == WrapStatus.pending, Wrap.expires_at > now, + Wrap.opens_used < Wrap.max_opens, ) - .values(status=WrapStatus.consumed, consumed_at=now) - .returning(Wrap.id) + .values(opens_used=Wrap.opens_used + 1) + .returning(Wrap.id, Wrap.opens_used, Wrap.max_opens) ) - consumed = upd.scalar_one_or_none() + row = upd.one_or_none() await db.commit() - if not consumed: + if not row: await write_audit( db, event_type="wrap.unwrap", @@ -358,6 +405,11 @@ async def unwrap( ) 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: data = await storage.get_bytes(wrap.object_key) except Exception: @@ -370,8 +422,17 @@ async def unwrap( details={"reason": "storage_error"}, ) 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( db, @@ -383,6 +444,9 @@ async def unwrap( "size_bytes": wrap.size_bytes, "item_count": wrap.item_count, "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, password_mode=wrap.password_mode.value, size_bytes=wrap.size_bytes, + max_opens=max_opens, + opens_used=opens_used, + opens_remaining=opens_remaining, + destroyed=destroyed, ) diff --git a/app/main.py b/app/main.py index 6ff2e3b..24fa49d 100644 --- a/app/main.py +++ b/app/main.py @@ -140,6 +140,14 @@ def create_app() -> FastAPI: {"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) async def http_exception_handler(request: Request, exc: HTTPException): if ( diff --git a/app/models.py b/app/models.py index 6cc3eb7..b521053 100644 --- a/app/models.py +++ b/app/models.py @@ -65,6 +65,8 @@ class AppSettings(Base): ) # Wrong unwrap passwords allowed before the wrap is burned (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) updated_at: Mapped[datetime] = mapped_column( 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 ) 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) creator_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) creator_ua: Mapped[str | None] = mapped_column(String(512), nullable=True) diff --git a/app/schemas.py b/app/schemas.py index d28831a..052d0e2 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -17,6 +17,7 @@ class PublicSettingsOut(BaseModel): password_mode: str password_mode_description: dict[str, str] password_max_attempts: int + max_opens_limit: int class WrapCreateRequest(BaseModel): @@ -27,6 +28,8 @@ class WrapCreateRequest(BaseModel): has_password: bool = False password: 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): @@ -34,6 +37,8 @@ class WrapCreateResponse(BaseModel): expires_at: datetime password_mode: str share_path: str + max_opens: int = 1 + available_from: datetime | None = None class UnwrapRequest(BaseModel): @@ -48,6 +53,10 @@ class UnwrapResponse(BaseModel): has_password: bool password_mode: str size_bytes: int + max_opens: int = 1 + opens_used: int = 1 + opens_remaining: int = 0 + destroyed: bool = True class AdminLoginRequest(BaseModel): @@ -68,6 +77,7 @@ class AdminSettingsUpdate(BaseModel): hcaptcha_site_key: str | None = None password_mode: str | None = None 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 diff --git a/app/services/settings_service.py b/app/services/settings_service.py index f1137d6..d62c835 100644 --- a/app/services/settings_service.py +++ b/app/services/settings_service.py @@ -70,4 +70,5 @@ def public_settings_payload(row: AppSettings) -> dict: "password_mode": row.password_mode.value, "password_mode_description": PASSWORD_MODE_HELP, "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))), } diff --git a/app/static/css/app.css b/app/static/css/app.css index 4759abd..2dfb554 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -252,7 +252,41 @@ html[data-theme="dark"] .icon-btn:hover { 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; +} +#install-app { + position: relative; + overflow: visible; +} +#install-app .ui-tooltip { + left: 50%; + right: auto; + transform: translateX(-50%) translateY(4px); + white-space: nowrap; +} +#install-app .ui-tooltip::after { + left: 50%; + right: auto; + transform: translateX(-50%); +} +#install-app:hover .ui-tooltip, +#install-app:focus-visible .ui-tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%); +} .composer, .success-panel, .result-panel { display: grid; @@ -800,21 +834,67 @@ body.busy-open { overflow: hidden; } } .file-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.4rem; } -.file-list li { - display: flex; - justify-content: space-between; - gap: 0.75rem; +.file-list li, +.file-list-item { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(5.5rem, 9rem) auto; + gap: 0.45rem; + align-items: center; padding: 0.55rem 0.75rem; border: 1px solid var(--line); border-radius: 10px; font-size: 0.9rem; } +.file-label-input { + width: 100%; + min-width: 0; + padding: 0.35rem 0.5rem; + font-size: 0.82rem; +} .file-list button { border: 0; background: transparent; color: var(--danger); cursor: pointer; } +.template-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0 0 0.35rem; +} +.template-chip { + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + border-radius: 999px; + padding: 0.3rem 0.7rem; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; +} +.template-chip:hover, +.template-chip:focus-visible { + color: var(--accent-2); + border-color: rgba(75, 134, 240, 0.4); + outline: none; +} +.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-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem; } diff --git a/app/static/js/create.js b/app/static/js/create.js index 20d8378..ff6d3b9 100644 --- a/app/static/js/create.js +++ b/app/static/js/create.js @@ -1,11 +1,14 @@ (() => { const t = (k, vars) => window.WrappedI18n.t(k, vars); + /** @type {{ file: File, label: string }[]} */ const files = []; let settings = null; let captchaWidgetId = null; const els = { text: document.getElementById("payload-text"), + textLabel: document.getElementById("text-label"), + templateChips: document.getElementById("template-chips"), lang: document.getElementById("text-language"), langChip: document.getElementById("lang-chip"), langChipText: document.getElementById("lang-chip-text"), @@ -14,6 +17,8 @@ highlight: document.getElementById("code-highlight"), editor: document.getElementById("code-editor"), ttl: document.getElementById("ttl-seconds"), + maxOpens: document.getElementById("max-opens"), + availableFrom: document.getElementById("available-from"), password: document.getElementById("password"), generatePassword: document.getElementById("generate-password"), dropzone: document.getElementById("dropzone"), @@ -27,6 +32,7 @@ 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"), captchaSlot: document.getElementById("captcha-slot"), @@ -108,7 +114,7 @@ function estimatePayloadBytes() { const textBytes = new TextEncoder().encode(els.text?.value || "").length; - const fileBytes = files.reduce((sum, f) => sum + (Number(f.size) || 0), 0); + const fileBytes = files.reduce((sum, entry) => sum + (Number(entry.file.size) || 0), 0); return textBytes + fileBytes; } @@ -136,9 +142,21 @@ function renderFiles() { els.fileList.innerHTML = ""; - files.forEach((f, idx) => { + files.forEach((entry, idx) => { + const f = entry.file; const li = document.createElement("li"); - li.innerHTML = `${f.name} (${f.type || "file"} · ${window.WrappedUI.formatBytes(f.size)})`; + li.className = "file-list-item"; + const meta = document.createElement("span"); + meta.innerHTML = `${escapeHtml(f.name)} (${escapeHtml(f.type || "file")} · ${window.WrappedUI.formatBytes(f.size)})`; + const labelInput = document.createElement("input"); + labelInput.type = "text"; + labelInput.className = "file-label-input"; + labelInput.maxLength = 120; + labelInput.placeholder = t("create.itemLabelPlaceholder"); + labelInput.value = entry.label || ""; + labelInput.addEventListener("input", () => { + entry.label = labelInput.value; + }); const btn = document.createElement("button"); btn.type = "button"; btn.textContent = "×"; @@ -146,33 +164,54 @@ files.splice(idx, 1); renderFiles(); }); + li.appendChild(meta); + li.appendChild(labelInput); li.appendChild(btn); els.fileList.appendChild(li); }); refreshSizeMeter(); } + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + function addFiles(list) { for (const f of list) { if (!mimeAllowed(f.type || "application/octet-stream")) { showError(t("create.mimeDenied") + `: ${f.type || f.name}`); continue; } - files.push(f); + files.push({ file: f, label: "" }); } renderFiles(); } 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() { if (!settings || !els.ttl) return; const max = settings.max_ttl_seconds; const def = settings.default_ttl_seconds; const selected = els.ttl.value ? Number(els.ttl.value) : def; + const evening = secondsUntilEvening(); const options = [ { 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.3d", value: 3 * 24 * 3600 }, { key: "create.ttl.7d", value: 7 * 24 * 3600 }, @@ -192,6 +231,78 @@ .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) => + `` + ) + .join(""); + } + + function renderTemplates() { + if (!els.templateChips) return; + els.templateChips.innerHTML = ""; + const keys = [ + { id: "access", key: "create.tpl.access" }, + { id: "code", key: "create.tpl.code" }, + { id: "fileNote", key: "create.tpl.fileNote" }, + ]; + for (const item of keys) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "template-chip"; + btn.textContent = t(`create.tpl.${item.id}.name`); + btn.addEventListener("click", () => { + els.text.value = t(`create.tpl.${item.id}.body`); + refreshHighlight(); + refreshSizeMeter(); + }); + els.templateChips.appendChild(btn); + } + } + + function datetimeLocalToIso(value) { + if (!value) return null; + const d = new Date(value); + 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() { if (lastExpiresAt && els.expiresMeta) { els.expiresMeta.innerHTML = window.WrappedI18n.formatExpiresHtml @@ -239,6 +350,8 @@ const resp = await fetch("/api/v1/settings"); settings = await resp.json(); fillTtl(); + fillMaxOpens(); + renderTemplates(); loadCaptcha(); setLanguage("plaintext", { silent: true }); refreshHighlight(); @@ -325,11 +438,14 @@ } } - function showSuccess({ link, token, password, expiresAt }) { + 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"); @@ -409,15 +525,18 @@ const items = []; const contentTypes = []; if (text.trim()) { - items.push({ + const textItem = { type: "text", language: els.lang.value, content: text, - }); + }; + const label = (els.textLabel?.value || "").trim(); + if (label) textItem.label = label.slice(0, 120); + items.push(textItem); contentTypes.push("text/plain"); } - for (const f of files) { - const item = await window.WrappedCrypto.fileToItem(f); + for (const entry of files) { + const item = await window.WrappedCrypto.fileToItem(entry.file, entry.label); items.push(item); contentTypes.push(item.mime); } @@ -430,6 +549,8 @@ } const password = els.password.value || ""; + const maxOpens = Number(els.maxOpens?.value || 1); + const availableFromIso = datetimeLocalToIso(els.availableFrom?.value || ""); els.wrapBtn.disabled = true; window.WrappedUI.showBusy(t("create.working")); try { @@ -450,9 +571,10 @@ content_types: contentTypes, item_count: items.length, has_password: Boolean(password), - // Always send password when set so server can gate wrong guesses. password: password || null, captcha_token: captchaToken() || null, + max_opens: maxOpens, + available_from: availableFromIso, }; const resp = await fetch("/api/v1/wraps", { method: "POST", @@ -461,7 +583,20 @@ }); if (!resp.ok) { 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; } const data = await resp.json(); @@ -472,6 +607,8 @@ token, password, expiresAt: data.expires_at, + availableFrom: data.available_from, + maxOpens: data.max_opens || maxOpens, }); } catch { showError(t("common.error")); @@ -500,7 +637,10 @@ if (window.WrappedI18n.onChange) { window.WrappedI18n.onChange(() => { fillTtl(); + fillMaxOpens(); + renderTemplates(); refreshExpiresMeta(); + refreshSuccessBadges(); refreshSizeMeter(); syncShareControls(); setLanguage(els.lang.value, { silent: true }); diff --git a/app/static/js/crypto.js b/app/static/js/crypto.js index 8b24576..ea96a2f 100644 --- a/app/static/js/crypto.js +++ b/app/static/js/crypto.js @@ -176,14 +176,17 @@ return b64decode(b64); } - async function fileToItem(file) { + async function fileToItem(file, label) { const buf = new Uint8Array(await file.arrayBuffer()); - return { + const item = { type: "file", name: file.name || "file", mime: file.type || "application/octet-stream", data_b64: b64encode(buf), }; + const trimmed = (label || "").trim(); + if (trimmed) item.label = trimmed.slice(0, 120); + return item; } window.WrappedCrypto = { diff --git a/app/static/js/i18n.js b/app/static/js/i18n.js index b39cf6f..5295406 100644 --- a/app/static/js/i18n.js +++ b/app/static/js/i18n.js @@ -66,6 +66,51 @@ "create.ttl.24h": "24 hours", "create.ttl.3d": "3 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.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/#. 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 →", + "footer.verify": "How to verify", + "install.aria": "Add to Home Screen", + "install.tip": "Install Wrapped", + "install.iosTip": "Share → Add to Home Screen", + "admin.limits.maxOpens": "Max opens per wrap", + "admin.limits.maxOpensHint": "Ceiling for create UI (1–10). Default: 3.", "create.ttl.default": "Default ({seconds} s)", "lang.plaintext": "Plain text", "lang.markdown": "Markdown", @@ -326,6 +371,51 @@ "create.ttl.24h": "24 часа", "create.ttl.3d": "3 дня", "create.ttl.7d": "7 дней", + "create.ttl.evening": "До вечера (20:00)", + "create.maxOpens": "Открытий", + "create.maxOpensOption": "{n}", + "create.maxOpensInvalid": "Недопустимое число открытий.", + "create.availableFrom": "Доступно с (необязательно)", + "create.availableFromHint": "Пусто = доступно сразу", + "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/#. Часть после # не уходит на сервер в запросе страницы. Без фрагмента (или полного токена) 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": "Как проверить →", + "footer.verify": "Как проверить", + "install.aria": "На экран «Домой»", + "install.tip": "Установить Wrapped", + "install.iosTip": "Поделиться → На экран «Домой»", + "admin.limits.maxOpens": "Макс. открытий на wrap", + "admin.limits.maxOpensHint": "Потолок для UI создания (1–10). По умолчанию: 3.", "create.ttl.default": "По умолчанию ({seconds} с)", "lang.plaintext": "Обычный текст", "lang.markdown": "Markdown", diff --git a/app/static/js/install.js b/app/static/js/install.js new file mode 100644 index 0000000..1bf8c39 --- /dev/null +++ b/app/static/js/install.js @@ -0,0 +1,60 @@ +(() => { + const t = (k, vars) => window.WrappedI18n?.t(k, vars) || k; + const btn = document.getElementById("install-app"); + if (!btn) return; + + let deferredPrompt = null; + const isIos = + /iphone|ipad|ipod/i.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + const isStandalone = + window.matchMedia("(display-mode: standalone)").matches || + window.navigator.standalone === true; + + function syncLabels() { + btn.setAttribute("aria-label", t("install.aria")); + const tip = btn.querySelector(".ui-tooltip"); + if (tip) tip.textContent = t(isIos ? "install.iosTip" : "install.tip"); + } + + function showBtn() { + if (isStandalone) { + btn.classList.add("hidden"); + return; + } + btn.classList.remove("hidden"); + } + + window.addEventListener("beforeinstallprompt", (e) => { + e.preventDefault(); + deferredPrompt = e; + showBtn(); + syncLabels(); + }); + + if (isIos && !isStandalone) { + showBtn(); + } + + btn.addEventListener("click", async () => { + if (deferredPrompt) { + deferredPrompt.prompt(); + try { + await deferredPrompt.userChoice; + } catch { + /* ignore */ + } + deferredPrompt = null; + return; + } + if (isIos) { + window.WrappedUI?.showBusy?.(t("install.iosTip")); + setTimeout(() => window.WrappedUI?.hideBusy?.(), 2200); + } + }); + + if (window.WrappedI18n?.onChange) { + window.WrappedI18n.onChange(syncLabels); + } + syncLabels(); +})(); diff --git a/app/static/js/unwrap.js b/app/static/js/unwrap.js index ed4d9aa..202fdcf 100644 --- a/app/static/js/unwrap.js +++ b/app/static/js/unwrap.js @@ -25,6 +25,10 @@ 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; @@ -273,28 +277,51 @@ els.downloadAll.setAttribute("aria-label", t("unwrap.downloadAll")); } - function renderPackage(pack) { + function renderPackage(pack, meta = {}) { revokeAllUrls(); lastPack = pack; 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 || []) { const card = document.createElement("div"); card.className = "item-card"; + const label = (item.label || "").trim(); if (item.type === "text") { const lang = item.language || "plaintext"; const text = item.content || ""; const head = document.createElement("div"); head.className = "item-card-head"; - const meta = document.createElement("div"); + const metaEl = document.createElement("div"); const strong = document.createElement("strong"); - strong.textContent = "text"; - meta.appendChild(strong); - meta.appendChild(document.createTextNode(" · ")); + strong.textContent = label || "text"; + metaEl.appendChild(strong); + metaEl.appendChild(document.createTextNode(" · ")); const langEl = document.createElement("span"); langEl.className = "mono"; langEl.textContent = lang; - meta.appendChild(langEl); - head.appendChild(meta); + metaEl.appendChild(langEl); + head.appendChild(metaEl); const actions = document.createElement("div"); actions.className = "item-card-actions"; actions.appendChild(makeCopyBtn(() => text)); @@ -318,27 +345,31 @@ const name = item.name || "file"; const head = document.createElement("div"); head.className = "item-card-head"; - const meta = document.createElement("div"); + const metaEl = document.createElement("div"); const strong = document.createElement("strong"); - strong.textContent = name; - meta.appendChild(strong); - meta.appendChild(document.createTextNode(" · ")); + 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; - meta.appendChild(mimeEl); - meta.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`)); - head.appendChild(meta); + metaEl.appendChild(mimeEl); + metaEl.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`)); + head.appendChild(metaEl); head.appendChild(makeDownloadBtn(() => downloadBlob(name, blob))); card.appendChild(head); if (mime.startsWith("image/")) { const url = trackUrl(URL.createObjectURL(blob)); const img = document.createElement("img"); - img.alt = name; + img.alt = label || name; img.src = url; img.className = "item-preview-img"; img.loading = "lazy"; - img.addEventListener("click", () => openLightbox(url, name)); + img.addEventListener("click", () => openLightbox(url, label || name)); card.appendChild(img); const tip = document.createElement("p"); tip.className = "hint item-preview-hint"; @@ -424,6 +455,22 @@ }); 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") { showError( t("unwrap.passwordRequired"), @@ -477,7 +524,12 @@ 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.head?.classList.add("hidden"); els.state?.classList.add("hidden"); diff --git a/app/templates/admin_settings.html b/app/templates/admin_settings.html index 9724c71..8b66495 100644 --- a/app/templates/admin_settings.html +++ b/app/templates/admin_settings.html @@ -36,6 +36,14 @@ Audit retention (days) + diff --git a/app/templates/base.html b/app/templates/base.html index a924562..bca9658 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -31,6 +31,11 @@
+ @@ -50,6 +55,7 @@ @@ -65,6 +71,7 @@

Можно отправить заметку или код, а также вложения: документы, архивы, скриншоты (drag-and-drop, выбор с диска или вставка из буфера). И текст, и файлы шифруются в браузере до загрузки — на сервер уходит только ciphertext.

Сервер никогда не видит plaintext: зашифрованные данные лежат на сервере до тех пор, пока получатель не откроет ссылку с ключом и не расшифрует пакет.

После успешной расшифровки копия на сервере уничтожается. Ключ шифрования живёт во фрагменте URL (#…) и не уходит на сервер вместе с запросом страницы. При желании wrap можно дополнительно защитить паролем.

+

How to verify →

+
+
+ + +
@@ -31,6 +36,17 @@
+
+ + +
+ +
+
+ + +

Empty = available immediately

+
@@ -83,6 +99,8 @@

+
+
diff --git a/app/templates/unwrap.html b/app/templates/unwrap.html index 6b7f5dc..7a19a02 100644 --- a/app/templates/unwrap.html +++ b/app/templates/unwrap.html @@ -42,15 +42,16 @@