Compare commits

..

2 Commits

Author SHA1 Message Date
Sergey Antropoff fd0bf89e9e Исправлен VERSION до 0.1.3; убран авто-bump из make push.
devops-tools/wrapped/wrapped-build/pipeline/head This commit looks good
Упрощён экран «Токен выдан»: чеклист/trust убраны, Share и Download QR — иконки под QR.
2026-07-29 14:15:07 +03:00
Sergey Antropoff a4b750afef Убраны short-sha теги образа: в Hub/Harbor публикуются только SemVer и latest.
devops-tools/wrapped/wrapped-build/pipeline/head This commit looks good
devops-tools/wrapped/wrapped-deploy/pipeline/head This commit looks good
2026-07-29 13:41:17 +03:00
28 changed files with 58 additions and 702 deletions
+7 -5
View File
@@ -341,11 +341,13 @@ services:
### UX (v0.1.3+) ### UX (v0.1.3+)
- После создания: **QR** на share-link, иконки **скачать QR (PNG)** и **Web Share** (если есть `navigator.share`) под QR; отдельные кнопки Copy для ссылки / токена / пароля; пароль не советуется слать в той же переписке. - После создания: **QR** на share-link, иконки **скачать QR (PNG)** и **Web Share** (если есть `navigator.share`) под QR; отдельные кнопки Copy для ссылки / токена / пароля; пароль не советуется слать в той же переписке.
- Create: счётчик размера `≈ used / max`; **число открытий** 1–3 (потолок в админке); опционально **«доступно с»** (дата + время); человеческие TTL (в т.ч. «до вечера»). - Create: счётчик размера `≈ used / max` и предупреждение near-limit (сверх лимита — `create.tooLarge`).
- Unwrap: Enter в поле пароля; focus+select при ошибках пароля; zip all; trust-строка; экран **ещё недоступно** до `available_from`; при N>1 ciphertext остаётся до последнего открытия. - Unwrap: Enter в поле пароля отправляет форму; после `password_required` / `bad_password` focus+select; при ≥2 элементах — **скачать всё** (zip через JSZip); trust-строка на результате; спокойный экран «ссылка недоступна» для already used / expired (anti-enumeration); отдельные состояния для `password_locked`, rate limit, CAPTCHA, ошибки расшифровки.
- Картинки: lightbox. Тема: `prefers-color-scheme` при первом визите; haptic на Copy. PWA manifest. - Картинки после unwrap: inline-превью и **lightbox** (тап/клик).
- Страница [`/verify`](/verify) — как проверить ZK-модель. - Тема при первом визите следует `prefers-color-scheme` (пока нет выбора в `localStorage`); лёгкий haptic после успешного Copy.
- UI EN/RU. Статика с `?v=версия.mtime`. - Минимальный **PWA**: `manifest.webmanifest` (без Service Worker для API).
- UI строки EN/RU через `i18n.js`.
- Статика (`/static/...`) отдаётся с `?v=версия.mtime` (cache-bust после деплоя).
--- ---
+1 -1
View File
@@ -1 +1 @@
0.1.4 0.1.3
@@ -1,57 +0,0 @@
"""max_opens / available_from for wraps; max_opens_limit in settings
Revision ID: 003_opens_available_from
Revises: 002_password_attempts
Create Date: 2026-07-29
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "003_opens_available_from"
down_revision: Union[str, None] = "002_password_attempts"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"app_settings",
sa.Column(
"max_opens_limit",
sa.Integer(),
nullable=False,
server_default="3",
),
)
op.add_column(
"wraps",
sa.Column(
"max_opens",
sa.Integer(),
nullable=False,
server_default="1",
),
)
op.add_column(
"wraps",
sa.Column(
"opens_used",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"wraps",
sa.Column("available_from", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("wraps", "available_from")
op.drop_column("wraps", "opens_used")
op.drop_column("wraps", "max_opens")
op.drop_column("app_settings", "max_opens_limit")
-4
View File
@@ -210,9 +210,6 @@ async def settings_save(
row.password_max_attempts = min( row.password_max_attempts = min(
50, max(1, as_int("password_max_attempts", row.password_max_attempts or 3)) 50, max(1, as_int("password_max_attempts", row.password_max_attempts or 3))
) )
row.max_opens_limit = min(
10, max(1, as_int("max_opens_limit", getattr(row, "max_opens_limit", None) or 3))
)
row.turnstile_site_key = str(form.get("turnstile_site_key") or "").strip() row.turnstile_site_key = str(form.get("turnstile_site_key") or "").strip()
row.hcaptcha_site_key = str(form.get("hcaptcha_site_key") or "").strip() row.hcaptcha_site_key = str(form.get("hcaptcha_site_key") or "").strip()
@@ -411,7 +408,6 @@ async def admin_settings_api(
"captcha_provider": row.captcha_provider.value, "captcha_provider": row.captcha_provider.value,
"password_mode": row.password_mode.value, "password_mode": row.password_mode.value,
"password_max_attempts": row.password_max_attempts, "password_max_attempts": row.password_max_attempts,
"max_opens_limit": getattr(row, "max_opens_limit", None) or 3,
"audit_retention_days": row.audit_retention_days, "audit_retention_days": row.audit_retention_days,
"rate_limit_create_per_minute": row.rate_limit_create_per_minute, "rate_limit_create_per_minute": row.rate_limit_create_per_minute,
"rate_limit_unwrap_per_minute": row.rate_limit_unwrap_per_minute, "rate_limit_unwrap_per_minute": row.rate_limit_unwrap_per_minute,
+8 -76
View File
@@ -84,11 +84,6 @@ async def create_wrap(
if body.ttl_seconds > settings.max_ttl_seconds: if body.ttl_seconds > settings.max_ttl_seconds:
raise HTTPException(status_code=400, detail="ttl_too_large") raise HTTPException(status_code=400, detail="ttl_too_large")
opens_limit = max(1, min(10, int(getattr(settings, "max_opens_limit", None) or 3)))
max_opens = int(body.max_opens or 1)
if max_opens < 1 or max_opens > opens_limit:
raise HTTPException(status_code=400, detail="max_opens_invalid")
try: try:
ciphertext = base64.b64decode(body.ciphertext_b64, validate=True) ciphertext = base64.b64decode(body.ciphertext_b64, validate=True)
except Exception as exc: except Exception as exc:
@@ -129,18 +124,6 @@ async def create_wrap(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=body.ttl_seconds) expires_at = now + timedelta(seconds=body.ttl_seconds)
available_from = body.available_from
if available_from is not None:
if available_from.tzinfo is None:
available_from = available_from.replace(tzinfo=timezone.utc)
else:
available_from = available_from.astimezone(timezone.utc)
# Allow ~2 minutes of clock skew into the past
if available_from < now - timedelta(minutes=2):
raise HTTPException(status_code=400, detail="available_from_past")
if available_from >= expires_at:
raise HTTPException(status_code=400, detail="available_from_after_expiry")
await storage.put_bytes(object_key, ciphertext) await storage.put_bytes(object_key, ciphertext)
wrap = Wrap( wrap = Wrap(
@@ -154,9 +137,6 @@ async def create_wrap(
password_hash=password_hash, password_hash=password_hash,
password_mode=settings.password_mode, password_mode=settings.password_mode,
expires_at=expires_at, expires_at=expires_at,
available_from=available_from,
max_opens=max_opens,
opens_used=0,
creator_ip=ip, creator_ip=ip,
creator_ua=(meta["user_agent"] or "")[:512] or None, creator_ua=(meta["user_agent"] or "")[:512] or None,
) )
@@ -176,8 +156,6 @@ async def create_wrap(
"ttl_seconds": body.ttl_seconds, "ttl_seconds": body.ttl_seconds,
"has_password": body.has_password, "has_password": body.has_password,
"password_mode": settings.password_mode.value, "password_mode": settings.password_mode.value,
"max_opens": max_opens,
"available_from": available_from.isoformat() if available_from else None,
}, },
) )
@@ -186,8 +164,6 @@ async def create_wrap(
expires_at=expires_at, expires_at=expires_at,
password_mode=settings.password_mode.value, password_mode=settings.password_mode.value,
share_path=f"/w/{wrap_id}", share_path=f"/w/{wrap_id}",
max_opens=max_opens,
available_from=available_from,
) )
@@ -261,8 +237,7 @@ async def unwrap(
fail("unavailable") fail("unavailable")
assert wrap is not None assert wrap is not None
now = datetime.now(timezone.utc) if wrap.expires_at <= datetime.now(timezone.utc):
if wrap.expires_at <= now:
wrap.status = WrapStatus.expired wrap.status = WrapStatus.expired
await delete_wrap_object(db, wrap) await delete_wrap_object(db, wrap)
await db.commit() await db.commit()
@@ -276,26 +251,6 @@ async def unwrap(
) )
fail("unavailable") fail("unavailable")
if wrap.available_from is not None and wrap.available_from > now:
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={
"reason": "not_yet_available",
"available_from": wrap.available_from.isoformat(),
},
)
raise HTTPException(
status_code=403,
detail={
"code": "not_yet_available",
"available_from": wrap.available_from.isoformat(),
},
)
if wrap.has_password and wrap.password_hash: if wrap.has_password and wrap.password_hash:
max_attempts = max(1, int(settings.password_max_attempts or 3)) max_attempts = max(1, int(settings.password_max_attempts or 3))
# Empty password: ask to enter it, do not burn an attempt. # Empty password: ask to enter it, do not burn an attempt.
@@ -376,25 +331,23 @@ async def unwrap(
}, },
) )
# Atomic open: increment opens_used while still under max_opens # Atomic consume
from sqlalchemy import update from sqlalchemy import update
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
max_opens = max(1, int(wrap.max_opens or 1))
upd = await db.execute( upd = await db.execute(
update(Wrap) update(Wrap)
.where( .where(
Wrap.id == wrap_id, Wrap.id == wrap_id,
Wrap.status == WrapStatus.pending, Wrap.status == WrapStatus.pending,
Wrap.expires_at > now, Wrap.expires_at > now,
Wrap.opens_used < Wrap.max_opens,
) )
.values(opens_used=Wrap.opens_used + 1) .values(status=WrapStatus.consumed, consumed_at=now)
.returning(Wrap.id, Wrap.opens_used, Wrap.max_opens) .returning(Wrap.id)
) )
row = upd.one_or_none() consumed = upd.scalar_one_or_none()
await db.commit() await db.commit()
if not row: if not consumed:
await write_audit( await write_audit(
db, db,
event_type="wrap.unwrap", event_type="wrap.unwrap",
@@ -405,11 +358,6 @@ async def unwrap(
) )
fail("unavailable") fail("unavailable")
opens_used = int(row.opens_used)
max_opens = max(1, int(row.max_opens or 1))
opens_remaining = max(0, max_opens - opens_used)
destroyed = opens_remaining == 0
try: try:
data = await storage.get_bytes(wrap.object_key) data = await storage.get_bytes(wrap.object_key)
except Exception: except Exception:
@@ -422,17 +370,8 @@ async def unwrap(
details={"reason": "storage_error"}, details={"reason": "storage_error"},
) )
raise HTTPException(status_code=500, detail="storage_error") from None raise HTTPException(status_code=500, detail="storage_error") from None
finally:
if destroyed: await delete_wrap_object(db, wrap)
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,
@@ -444,9 +383,6 @@ async def unwrap(
"size_bytes": wrap.size_bytes, "size_bytes": wrap.size_bytes,
"item_count": wrap.item_count, "item_count": wrap.item_count,
"content_types": wrap.content_types, "content_types": wrap.content_types,
"opens_used": opens_used,
"max_opens": max_opens,
"destroyed": destroyed,
}, },
) )
@@ -457,8 +393,4 @@ async def unwrap(
has_password=wrap.has_password, has_password=wrap.has_password,
password_mode=wrap.password_mode.value, password_mode=wrap.password_mode.value,
size_bytes=wrap.size_bytes, size_bytes=wrap.size_bytes,
max_opens=max_opens,
opens_used=opens_used,
opens_remaining=opens_remaining,
destroyed=destroyed,
) )
-8
View File
@@ -140,14 +140,6 @@ def create_app() -> FastAPI:
{"title": "Unwrap", "page": "unwrap", "wrap_id": ""}, {"title": "Unwrap", "page": "unwrap", "wrap_id": ""},
) )
@app.get("/verify", include_in_schema=False)
async def verify_page(request: Request):
return templates.TemplateResponse(
request,
"verify.html",
{"title": "Verify", "page": "verify"},
)
@app.exception_handler(HTTPException) @app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException): async def http_exception_handler(request: Request, exc: HTTPException):
if ( if (
-5
View File
@@ -65,8 +65,6 @@ class AppSettings(Base):
) )
# Wrong unwrap passwords allowed before the wrap is burned (default 3). # Wrong unwrap passwords allowed before the wrap is burned (default 3).
password_max_attempts: Mapped[int] = mapped_column(Integer, default=3) password_max_attempts: Mapped[int] = mapped_column(Integer, default=3)
# Max allowed max_opens on create (UI shows 1…min(3, limit)).
max_opens_limit: Mapped[int] = mapped_column(Integer, default=3)
audit_retention_days: Mapped[int] = mapped_column(Integer, default=90) audit_retention_days: Mapped[int] = mapped_column(Integer, default=90)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
@@ -98,9 +96,6 @@ class Wrap(Base):
DateTime(timezone=True), server_default=func.now(), index=True DateTime(timezone=True), server_default=func.now(), index=True
) )
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
available_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
max_opens: Mapped[int] = mapped_column(Integer, default=1)
opens_used: Mapped[int] = mapped_column(Integer, default=0)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
creator_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) creator_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
creator_ua: Mapped[str | None] = mapped_column(String(512), nullable=True) creator_ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
-10
View File
@@ -17,7 +17,6 @@ class PublicSettingsOut(BaseModel):
password_mode: str password_mode: str
password_mode_description: dict[str, str] password_mode_description: dict[str, str]
password_max_attempts: int password_max_attempts: int
max_opens_limit: int
class WrapCreateRequest(BaseModel): class WrapCreateRequest(BaseModel):
@@ -28,8 +27,6 @@ class WrapCreateRequest(BaseModel):
has_password: bool = False has_password: bool = False
password: str | None = None password: str | None = None
captcha_token: str | None = None captcha_token: str | None = None
max_opens: int = Field(default=1, ge=1, le=10)
available_from: datetime | None = None
class WrapCreateResponse(BaseModel): class WrapCreateResponse(BaseModel):
@@ -37,8 +34,6 @@ class WrapCreateResponse(BaseModel):
expires_at: datetime expires_at: datetime
password_mode: str password_mode: str
share_path: str share_path: str
max_opens: int = 1
available_from: datetime | None = None
class UnwrapRequest(BaseModel): class UnwrapRequest(BaseModel):
@@ -53,10 +48,6 @@ class UnwrapResponse(BaseModel):
has_password: bool has_password: bool
password_mode: str password_mode: str
size_bytes: int size_bytes: int
max_opens: int = 1
opens_used: int = 1
opens_remaining: int = 0
destroyed: bool = True
class AdminLoginRequest(BaseModel): class AdminLoginRequest(BaseModel):
@@ -77,7 +68,6 @@ class AdminSettingsUpdate(BaseModel):
hcaptcha_site_key: str | None = None hcaptcha_site_key: str | None = None
password_mode: str | None = None password_mode: str | None = None
password_max_attempts: int | None = Field(default=None, ge=1, le=50) password_max_attempts: int | None = Field(default=None, ge=1, le=50)
max_opens_limit: int | None = Field(default=None, ge=1, le=10)
audit_retention_days: int | None = None audit_retention_days: int | None = None
-1
View File
@@ -70,5 +70,4 @@ def public_settings_payload(row: AppSettings) -> dict:
"password_mode": row.password_mode.value, "password_mode": row.password_mode.value,
"password_mode_description": PASSWORD_MODE_HELP, "password_mode_description": PASSWORD_MODE_HELP,
"password_max_attempts": max(1, int(row.password_max_attempts or 3)), "password_max_attempts": max(1, int(row.password_max_attempts or 3)),
"max_opens_limit": max(1, min(10, int(getattr(row, "max_opens_limit", None) or 3))),
} }
+2 -4
View File
@@ -141,16 +141,14 @@ 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(
reason_key, func.coalesce(reason_col, "unknown"),
func.count(AuditEvent.id), func.count(AuditEvent.id),
) )
.where(*filters) .where(*filters)
.group_by(reason_key) .group_by(func.coalesce(reason_col, "unknown"))
.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.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

+4 -128
View File
@@ -178,8 +178,7 @@ html[data-theme="light"] body {
50% { filter: saturate(1.2); transform: scale(1.04); } 50% { filter: saturate(1.2); transform: scale(1.04); }
} }
.top-actions { display: flex; gap: 0.5rem; overflow: visible; position: relative; z-index: 5; } .top-actions { display: flex; gap: 0.5rem; }
.topbar { overflow: visible; }
.icon-btn { .icon-btn {
min-width: 42px; min-width: 42px;
@@ -253,56 +252,7 @@ html[data-theme="dark"] .icon-btn:hover {
line-height: 1.15; line-height: 1.15;
} }
.verify-sections { .lede { color: var(--muted); margin: 0 0 1.5rem; max-width: 42rem; }
display: grid;
gap: 1rem;
margin: 0 0 1.25rem;
}
.verify-block h2 {
margin: 0 0 0.35rem;
font-size: 1.05rem;
}
.verify-block p {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.datetime-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
}
.datetime-split .input-with-action > input[type="date"],
.datetime-split .input-with-action > input[type="time"] {
width: 100%;
min-width: 0;
padding-right: 2.75rem;
color-scheme: dark;
}
html[data-theme="light"] .datetime-split .input-with-action > input[type="date"],
html[data-theme="light"] .datetime-split .input-with-action > input[type="time"] {
color-scheme: light;
}
.datetime-split .input-with-action > input[type="date"]::-webkit-calendar-picker-indicator,
.datetime-split .input-with-action > input[type="time"]::-webkit-calendar-picker-indicator {
opacity: 0;
position: absolute;
right: 0;
width: 2.5rem;
height: 100%;
cursor: pointer;
}
.field-label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
font-size: 0.88rem;
}
@media (max-width: 720px) {
.datetime-split {
grid-template-columns: 1fr;
}
}
.composer, .success-panel, .result-panel { .composer, .success-panel, .result-panel {
display: grid; display: grid;
@@ -854,7 +804,6 @@ body.busy-open { overflow: hidden; }
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
align-items: center;
padding: 0.55rem 0.75rem; padding: 0.55rem 0.75rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: 10px;
@@ -866,22 +815,6 @@ body.busy-open { overflow: hidden; }
color: var(--danger); color: var(--danger);
cursor: pointer; cursor: pointer;
} }
.success-meta-badges {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.success-meta-badge {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.65rem;
border-radius: 10px;
border: 1px solid rgba(110, 168, 255, 0.28);
background: rgba(110, 168, 255, 0.1);
color: var(--text);
font-size: 0.8rem;
font-weight: 600;
}
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; } .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem; } .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem; }
@@ -1113,63 +1046,6 @@ 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;
} }
@@ -1563,7 +1439,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: 1015px) { @media (max-width: 860px) {
.admin-top { .admin-top {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -1661,7 +1537,7 @@ html[data-theme="light"] .admin-nav {
} }
} }
@media (min-width: 1016px) { @media (min-width: 861px) {
.admin-top > .brand { .admin-top > .brand {
flex: 0 0 auto; flex: 0 0 auto;
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

+1 -1
View File
@@ -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: 1015px)"); const mq = window.matchMedia("(max-width: 860px)");
const syncLabel = () => { const syncLabel = () => {
const open = top.classList.contains("is-nav-open"); const open = top.classList.contains("is-nav-open");
+5 -124
View File
@@ -1,6 +1,5 @@
(() => { (() => {
const t = (k, vars) => window.WrappedI18n.t(k, vars); const t = (k, vars) => window.WrappedI18n.t(k, vars);
/** @type {File[]} */
const files = []; const files = [];
let settings = null; let settings = null;
let captchaWidgetId = null; let captchaWidgetId = null;
@@ -15,11 +14,6 @@
highlight: document.getElementById("code-highlight"), highlight: document.getElementById("code-highlight"),
editor: document.getElementById("code-editor"), editor: document.getElementById("code-editor"),
ttl: document.getElementById("ttl-seconds"), ttl: document.getElementById("ttl-seconds"),
maxOpens: document.getElementById("max-opens"),
availableFromDate: document.getElementById("available-from-date"),
availableFromTime: document.getElementById("available-from-time"),
availableFromDateBtn: document.getElementById("available-from-date-btn"),
availableFromTimeBtn: document.getElementById("available-from-time-btn"),
password: document.getElementById("password"), password: document.getElementById("password"),
generatePassword: document.getElementById("generate-password"), generatePassword: document.getElementById("generate-password"),
dropzone: document.getElementById("dropzone"), dropzone: document.getElementById("dropzone"),
@@ -33,7 +27,6 @@
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"),
@@ -145,7 +138,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>${escapeHtml(f.name)} <small>(${escapeHtml(f.type || "file")} · ${window.WrappedUI.formatBytes(f.size)})</small></span>`; li.innerHTML = `<span>${f.name} <small>(${f.type || "file"} · ${window.WrappedUI.formatBytes(f.size)})</small></span>`;
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.textContent = "×"; btn.textContent = "×";
@@ -159,14 +152,6 @@
refreshSizeMeter(); refreshSizeMeter();
} }
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function addFiles(list) { function addFiles(list) {
for (const f of list) { for (const f of list) {
if (!mimeAllowed(f.type || "application/octet-stream")) { if (!mimeAllowed(f.type || "application/octet-stream")) {
@@ -179,26 +164,15 @@
} }
let lastExpiresAt = null; let lastExpiresAt = null;
let lastAvailableFrom = null;
let lastMaxOpens = 1;
function secondsUntilEvening() {
const now = new Date();
const target = new Date(now);
target.setHours(20, 0, 0, 0);
if (target <= now) target.setDate(target.getDate() + 1);
return Math.max(60, Math.round((target.getTime() - now.getTime()) / 1000));
}
function fillTtl() { function fillTtl() {
if (!settings || !els.ttl) return; if (!settings || !els.ttl) return;
const max = settings.max_ttl_seconds; const max = settings.max_ttl_seconds;
const def = settings.default_ttl_seconds; const def = settings.default_ttl_seconds;
const selected = els.ttl.value ? Number(els.ttl.value) : def; const selected = els.ttl.value ? Number(els.ttl.value) : def;
const evening = secondsUntilEvening();
const options = [ const options = [
{ key: "create.ttl.1h", value: 3600 }, { key: "create.ttl.1h", value: 3600 },
{ key: "create.ttl.evening", value: evening }, { key: "create.ttl.6h", value: 6 * 3600 },
{ key: "create.ttl.24h", value: 24 * 3600 }, { key: "create.ttl.24h", value: 24 * 3600 },
{ key: "create.ttl.3d", value: 3 * 24 * 3600 }, { key: "create.ttl.3d", value: 3 * 24 * 3600 },
{ key: "create.ttl.7d", value: 7 * 24 * 3600 }, { key: "create.ttl.7d", value: 7 * 24 * 3600 },
@@ -218,58 +192,6 @@
.join(""); .join("");
} }
function fillMaxOpens() {
if (!els.maxOpens || !settings) return;
const limit = Math.max(1, Math.min(10, Number(settings.max_opens_limit) || 3));
const uiMax = Math.min(3, limit);
const selected = els.maxOpens.value ? Number(els.maxOpens.value) : 1;
const pick = selected >= 1 && selected <= uiMax ? selected : 1;
els.maxOpens.innerHTML = Array.from({ length: uiMax }, (_, i) => i + 1)
.map(
(n) =>
`<option value="${n}" ${n === pick ? "selected" : ""}>${t("create.maxOpensOption", { n })}</option>`
)
.join("");
}
function availableFromToIso() {
const date = (els.availableFromDate?.value || "").trim();
if (!date) return null;
const time = (els.availableFromTime?.value || "").trim() || "00:00";
const d = new Date(`${date}T${time}`);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}
function formatLocalDateTime(iso) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return new Intl.DateTimeFormat(window.WrappedI18n.locale?.() || undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(d);
}
function refreshSuccessBadges() {
if (!els.successMetaBadges) return;
els.successMetaBadges.innerHTML = "";
if (lastMaxOpens > 1) {
const b = document.createElement("span");
b.className = "success-meta-badge";
b.textContent = t("create.opensBadge", { n: lastMaxOpens });
els.successMetaBadges.appendChild(b);
}
if (lastAvailableFrom) {
const b = document.createElement("span");
b.className = "success-meta-badge";
b.textContent = t("create.availableFromBadge", {
datetime: formatLocalDateTime(lastAvailableFrom),
});
els.successMetaBadges.appendChild(b);
}
}
function refreshExpiresMeta() { function refreshExpiresMeta() {
if (lastExpiresAt && els.expiresMeta) { if (lastExpiresAt && els.expiresMeta) {
els.expiresMeta.innerHTML = window.WrappedI18n.formatExpiresHtml els.expiresMeta.innerHTML = window.WrappedI18n.formatExpiresHtml
@@ -317,7 +239,6 @@
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();
@@ -404,14 +325,11 @@
} }
} }
function showSuccess({ link, token, password, expiresAt, availableFrom, maxOpens }) { function showSuccess({ link, token, password, expiresAt }) {
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");
@@ -512,8 +430,6 @@
} }
const password = els.password.value || ""; const password = els.password.value || "";
const maxOpens = Number(els.maxOpens?.value || 1);
const availableFromIso = availableFromToIso();
els.wrapBtn.disabled = true; els.wrapBtn.disabled = true;
window.WrappedUI.showBusy(t("create.working")); window.WrappedUI.showBusy(t("create.working"));
try { try {
@@ -534,10 +450,9 @@
content_types: contentTypes, content_types: contentTypes,
item_count: items.length, item_count: items.length,
has_password: Boolean(password), has_password: Boolean(password),
// Always send password when set so server can gate wrong guesses.
password: password || null, password: password || null,
captcha_token: captchaToken() || null, captcha_token: captchaToken() || null,
max_opens: maxOpens,
available_from: availableFromIso,
}; };
const resp = await fetch("/api/v1/wraps", { const resp = await fetch("/api/v1/wraps", {
method: "POST", method: "POST",
@@ -546,20 +461,7 @@
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
const detail = err.detail; showError(err.detail || t("common.error"));
if (detail === "available_from_past") {
showError(t("create.availableFromPast"));
return;
}
if (detail === "available_from_after_expiry") {
showError(t("create.availableFromAfterExpiry"));
return;
}
if (detail === "max_opens_invalid") {
showError(t("create.maxOpensInvalid"));
return;
}
showError(typeof detail === "string" ? detail : t("common.error"));
return; return;
} }
const data = await resp.json(); const data = await resp.json();
@@ -570,8 +472,6 @@
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"));
@@ -597,29 +497,10 @@
els.password.select(); els.password.select();
}); });
function openPicker(input) {
if (!input) return;
try {
if (typeof input.showPicker === "function") {
input.showPicker();
return;
}
} catch {
/* ignore */
}
input.focus();
input.click();
}
els.availableFromDateBtn?.addEventListener("click", () => openPicker(els.availableFromDate));
els.availableFromTimeBtn?.addEventListener("click", () => openPicker(els.availableFromTime));
if (window.WrappedI18n.onChange) { if (window.WrappedI18n.onChange) {
window.WrappedI18n.onChange(() => { window.WrappedI18n.onChange(() => {
fillTtl(); fillTtl();
fillMaxOpens();
refreshExpiresMeta(); refreshExpiresMeta();
refreshSuccessBadges();
refreshSizeMeter(); refreshSizeMeter();
syncShareControls(); syncShareControls();
setLanguage(els.lang.value, { silent: true }); setLanguage(els.lang.value, { silent: true });
+2 -5
View File
@@ -176,17 +176,14 @@
return b64decode(b64); return b64decode(b64);
} }
async function fileToItem(file, label) { async function fileToItem(file) {
const buf = new Uint8Array(await file.arrayBuffer()); const buf = new Uint8Array(await file.arrayBuffer());
const item = { return {
type: "file", type: "file",
name: file.name || "file", name: file.name || "file",
mime: file.type || "application/octet-stream", mime: file.type || "application/octet-stream",
data_b64: b64encode(buf), data_b64: b64encode(buf),
}; };
const trimmed = (label || "").trim();
if (trimmed) item.label = trimmed.slice(0, 120);
return item;
} }
window.WrappedCrypto = { window.WrappedCrypto = {
-88
View File
@@ -66,50 +66,6 @@
"create.ttl.24h": "24 hours", "create.ttl.24h": "24 hours",
"create.ttl.3d": "3 days", "create.ttl.3d": "3 days",
"create.ttl.7d": "7 days", "create.ttl.7d": "7 days",
"create.ttl.evening": "Until evening (20:00)",
"create.maxOpens": "Opens",
"create.maxOpensOption": "{n}",
"create.maxOpensInvalid": "Invalid number of opens.",
"create.availableFrom": "Available from (optional)",
"create.availableFromHint": "Empty = available immediately",
"create.availableFromDate": "Pick date",
"create.availableFromTime": "Pick time",
"create.availableFromPast": "Available-from must not be in the past.",
"create.availableFromAfterExpiry": "Available-from must be before expiry.",
"create.opensBadge": "{n} opens",
"create.availableFromBadge": "Available from {datetime}",
"create.itemLabel": "Label (optional)",
"create.itemLabelPlaceholder": "e.g. Instructions",
"create.tpl.access.name": "Access / password",
"create.tpl.access.body": "Access details\n\nURL:\nUsername:\nPassword:\n\nNotes:\n",
"create.tpl.code.name": "Code + notes",
"create.tpl.code.body": "One-time code / snippet\n\n```\n\n```\n\nNotes:\n",
"create.tpl.fileNote.name": "File + note",
"create.tpl.fileNote.body": "Attached file(s) — see below.\n\nWhat this is for:\nHow to use:\n",
"unwrap.notYetTitle": "Not available yet",
"unwrap.notYetHint": "This wrap opens at {datetime}.",
"unwrap.notYetHintGeneric": "This wrap is not available yet.",
"unwrap.stillOnServerTitle": "Still on the server",
"unwrap.stillOnServerHint": "{n} open(s) remaining — ciphertext stays until the last unwrap.",
"unwrap.opensRemaining": "{n} open(s) remaining after this session.",
"verify.eyebrow": "Verify",
"verify.title": "How to verify Wrapped",
"verify.lede": "What leaves your browser, what stays on the server, and how the key in #fragment works.",
"verify.s1.title": "Encryption in the browser",
"verify.s1.body": "Text and files are packed and encrypted with Web Crypto (AES-GCM) before upload. The server receives only ciphertext plus metadata (TTL, MIME, size, optional password hash).",
"verify.s2.title": "Key in the URL fragment",
"verify.s2.body": "The share link looks like /w/<id>#<key>. The part after # never reaches the server in the page request. Without that fragment (or the full wrapped token), ciphertext cannot be decrypted.",
"verify.s3.title": "Opens and destruction",
"verify.s3.body": "By default a wrap can be opened once; then ciphertext is deleted. If the sender chose 23 opens, the server keeps ciphertext until the last successful unwrap. Expiry and password lockout still destroy the package.",
"verify.s4.title": "Optional password",
"verify.s4.body": "When a password is set, the server checks an Argon2 hash before releasing ciphertext. Wrong guesses are limited; empty password does not burn an attempt.",
"verify.s5.title": "Available from",
"verify.s5.body": "If \"available from\" is set, unwrap is rejected until that time. After that, normal open/expiry rules apply.",
"verify.createCta": "Create a wrap",
"about.verifyLink": "How to verify",
"about.verifyHint": "What the server sees and how the #key works",
"admin.limits.maxOpens": "Max opens per wrap",
"admin.limits.maxOpensHint": "Ceiling for create UI (110). Default: 3.",
"create.ttl.default": "Default ({seconds} s)", "create.ttl.default": "Default ({seconds} s)",
"lang.plaintext": "Plain text", "lang.plaintext": "Plain text",
"lang.markdown": "Markdown", "lang.markdown": "Markdown",
@@ -370,50 +326,6 @@
"create.ttl.24h": "24 часа", "create.ttl.24h": "24 часа",
"create.ttl.3d": "3 дня", "create.ttl.3d": "3 дня",
"create.ttl.7d": "7 дней", "create.ttl.7d": "7 дней",
"create.ttl.evening": "До вечера (20:00)",
"create.maxOpens": "Открытий",
"create.maxOpensOption": "{n}",
"create.maxOpensInvalid": "Недопустимое число открытий.",
"create.availableFrom": "Доступно с (необязательно)",
"create.availableFromHint": "Пусто = доступно сразу",
"create.availableFromDate": "Выбрать дату",
"create.availableFromTime": "Выбрать время",
"create.availableFromPast": "«Доступно с» не может быть в прошлом.",
"create.availableFromAfterExpiry": "«Доступно с» должно быть раньше срока истечения.",
"create.opensBadge": "{n} открытий",
"create.availableFromBadge": "Доступно с {datetime}",
"create.itemLabel": "Подпись (необязательно)",
"create.itemLabelPlaceholder": "напр. Инструкция",
"create.tpl.access.name": "Доступ / пароль",
"create.tpl.access.body": "Данные доступа\n\nURL:\nЛогин:\nПароль:\n\nЗаметки:\n",
"create.tpl.code.name": "Код + заметка",
"create.tpl.code.body": "Одноразовый код / фрагмент\n\n```\n\n```\n\nЗаметки:\n",
"create.tpl.fileNote.name": "Файл + заметка",
"create.tpl.fileNote.body": "Вложения — см. ниже.\n\nДля чего:\nКак пользоваться:\n",
"unwrap.notYetTitle": "Ещё недоступно",
"unwrap.notYetHint": "Этот wrap откроется {datetime}.",
"unwrap.notYetHintGeneric": "Этот wrap ещё недоступен.",
"unwrap.stillOnServerTitle": "Ещё на сервере",
"unwrap.stillOnServerHint": "Осталось открытий: {n} — ciphertext хранится до последнего unwrap.",
"unwrap.opensRemaining": "После этой сессии останется открытий: {n}.",
"verify.eyebrow": "Проверка",
"verify.title": "Как проверить Wrapped",
"verify.lede": "Что уходит из браузера, что лежит на сервере и как работает ключ в #fragment.",
"verify.s1.title": "Шифрование в браузере",
"verify.s1.body": "Текст и файлы упаковываются и шифруются Web Crypto (AES-GCM) до загрузки. На сервер уходит только ciphertext и метаданные (TTL, MIME, размер, опциональный хеш пароля).",
"verify.s2.title": "Ключ во фрагменте URL",
"verify.s2.body": "Ссылка вида /w/<id>#<key>. Часть после # не уходит на сервер в запросе страницы. Без фрагмента (или полного токена) ciphertext не расшифровать.",
"verify.s3.title": "Открытия и уничтожение",
"verify.s3.body": "По умолчанию wrap открывается один раз — затем ciphertext удаляется. Если отправитель выбрал 2–3 открытия, сервер хранит ciphertext до последнего успешного unwrap. Срок и блокировка пароля по-прежнему уничтожают пакет.",
"verify.s4.title": "Опциональный пароль",
"verify.s4.body": "При пароле сервер проверяет Argon2-хеш до выдачи ciphertext. Число попыток ограничено; пустой пароль попытку не тратит.",
"verify.s5.title": "Доступно с",
"verify.s5.body": "Если задано «доступно с», unwrap отклоняется до этого момента. Дальше действуют обычные правила открытий и срока.",
"verify.createCta": "Создать wrap",
"about.verifyLink": "Как проверить",
"about.verifyHint": "Что видит сервер и как работает ключ в #…",
"admin.limits.maxOpens": "Макс. открытий на wrap",
"admin.limits.maxOpensHint": "Потолок для UI создания (1–10). По умолчанию: 3.",
"create.ttl.default": "По умолчанию ({seconds} с)", "create.ttl.default": "По умолчанию ({seconds} с)",
"lang.plaintext": "Обычный текст", "lang.plaintext": "Обычный текст",
"lang.markdown": "Markdown", "lang.markdown": "Markdown",
+17 -69
View File
@@ -25,10 +25,6 @@
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;
@@ -277,51 +273,28 @@
els.downloadAll.setAttribute("aria-label", t("unwrap.downloadAll")); els.downloadAll.setAttribute("aria-label", t("unwrap.downloadAll"));
} }
function renderPackage(pack, meta = {}) { function renderPackage(pack) {
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 metaEl = document.createElement("div"); const meta = document.createElement("div");
const strong = document.createElement("strong"); const strong = document.createElement("strong");
strong.textContent = label || "text"; strong.textContent = "text";
metaEl.appendChild(strong); meta.appendChild(strong);
metaEl.appendChild(document.createTextNode(" · ")); meta.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;
metaEl.appendChild(langEl); meta.appendChild(langEl);
head.appendChild(metaEl); head.appendChild(meta);
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));
@@ -345,31 +318,27 @@
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 metaEl = document.createElement("div"); const meta = document.createElement("div");
const strong = document.createElement("strong"); const strong = document.createElement("strong");
strong.textContent = label || name; strong.textContent = name;
metaEl.appendChild(strong); meta.appendChild(strong);
if (label) { meta.appendChild(document.createTextNode(" · "));
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;
metaEl.appendChild(mimeEl); meta.appendChild(mimeEl);
metaEl.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`)); meta.appendChild(document.createTextNode(` · ${window.WrappedUI.formatBytes(bytes.length)}`));
head.appendChild(metaEl); head.appendChild(meta);
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 = label || name; img.alt = 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, label || name)); img.addEventListener("click", () => openLightbox(url, 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";
@@ -455,22 +424,6 @@
}); });
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"),
@@ -524,12 +477,7 @@
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");
+3 -21
View File
@@ -8,28 +8,10 @@
"theme_color": "#0b1020", "theme_color": "#0b1020",
"icons": [ "icons": [
{ {
"src": "/static/icon-192.png", "src": "/static/favicon.svg",
"sizes": "192x192", "sizes": "any",
"type": "image/png", "type": "image/svg+xml",
"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"
} }
] ]
} }
-8
View File
@@ -36,14 +36,6 @@
<span data-i18n="admin.limits.auditRetention">Audit retention (days)</span> <span data-i18n="admin.limits.auditRetention">Audit retention (days)</span>
<input name="audit_retention_days" type="number" value="{{ settings.audit_retention_days }}" /> <input name="audit_retention_days" type="number" value="{{ settings.audit_retention_days }}" />
</label> </label>
<label>
<span data-i18n="admin.limits.maxOpens">Max opens per wrap</span>
<input name="max_opens_limit" type="number" min="1" max="10" step="1"
value="{{ settings.max_opens_limit or 3 }}" />
<small class="hint" data-i18n="admin.limits.maxOpensHint">
Ceiling for create UI (110). Default: 3.
</small>
</label>
</div> </div>
</section> </section>
-10
View File
@@ -65,16 +65,6 @@
<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>
-30
View File
@@ -31,34 +31,6 @@
<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">
@@ -111,8 +83,6 @@
</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 />
+4 -5
View File
@@ -42,16 +42,15 @@
</div> </div>
<div id="result-panel" class="result-panel hidden"> <div id="result-panel" class="result-panel hidden">
<div class="success-callout" role="status" id="result-callout"> <div class="success-callout" role="status">
<span class="success-callout-icon" aria-hidden="true"> <span class="success-callout-icon" aria-hidden="true">
<i class="fa-solid fa-fire" id="result-callout-fa"></i> <i class="fa-solid fa-fire"></i>
</span> </span>
<span class="success-callout-body"> <span class="success-callout-body">
<strong id="result-callout-title" data-i18n="unwrap.destroyedTitle">Server copy destroyed</strong> <strong data-i18n="unwrap.destroyedTitle">Server copy destroyed</strong>
<span id="result-callout-hint" data-i18n="unwrap.destroyedHint">Preview lives only in this browser session.</span> <span data-i18n="unwrap.destroyedHint">Preview lives only in this browser session.</span>
</span> </span>
</div> </div>
<p class="hint success-trust" id="result-opens-hint"></p>
<p class="hint success-trust" data-i18n="unwrap.trustKey"> <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>
-38
View File
@@ -1,38 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }} · Wrapped{% endblock %}
{% block content %}
<section class="hero-panel verify-panel">
<div class="panel-head">
<p class="eyebrow" data-i18n="verify.eyebrow">Verify</p>
<h1 data-i18n="verify.title">How to verify Wrapped</h1>
<p class="lede" data-i18n="verify.lede">What leaves your browser, what stays on the server, and how the key in #fragment works.</p>
</div>
<div class="verify-sections">
<article class="verify-block">
<h2 data-i18n="verify.s1.title">Encryption in the browser</h2>
<p data-i18n="verify.s1.body">Text and files are packed and encrypted with Web Crypto (AES-GCM) before upload. The server receives only ciphertext plus metadata (TTL, MIME, size, optional password hash).</p>
</article>
<article class="verify-block">
<h2 data-i18n="verify.s2.title">Key in the URL fragment</h2>
<p data-i18n="verify.s2.body">The share link looks like /w/&lt;id&gt;#&lt;key&gt;. The part after # never reaches the server in the page request. Without that fragment (or the full wrapped token), ciphertext cannot be decrypted.</p>
</article>
<article class="verify-block">
<h2 data-i18n="verify.s3.title">Opens and destruction</h2>
<p data-i18n="verify.s3.body">By default a wrap can be opened once; then ciphertext is deleted. If the sender chose 23 opens, the server keeps ciphertext until the last successful unwrap. Expiry and password lockout still destroy the package.</p>
</article>
<article class="verify-block">
<h2 data-i18n="verify.s4.title">Optional password</h2>
<p data-i18n="verify.s4.body">When a password is set, the server checks an Argon2 hash before releasing ciphertext. Wrong guesses are limited; empty password does not burn an attempt.</p>
</article>
<article class="verify-block">
<h2 data-i18n="verify.s5.title">Available from</h2>
<p data-i18n="verify.s5.body">If “available from” is set, unwrap is rejected until that time. After that, normal open/expiry rules apply.</p>
</article>
</div>
<div class="actions actions-center">
<a class="btn primary" href="/" data-i18n="verify.createCta">Create a wrap</a>
</div>
</section>
{% endblock %}
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: wrapped name: wrapped
description: Zero-knowledge one-time encrypted drop (Wrapped) description: Zero-knowledge one-time encrypted drop (Wrapped)
type: application type: application
version: 0.1.4 version: 0.1.3
appVersion: "0.1.4" appVersion: "0.1.3"
+1 -1
View File
@@ -2,7 +2,7 @@ replicaCount: 1
image: image:
repository: inecs/wrapped repository: inecs/wrapped
tag: "0.1.4" tag: "0.1.3"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
service: service:
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "wrapped" name = "wrapped"
version = "0.1.4" version = "0.1.3"
description = "Zero-knowledge one-time encrypted drop service" description = "Zero-knowledge one-time encrypted drop service"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"