This commit is contained in:
Sergey Antropoff
2026-07-17 15:57:36 +03:00
commit e4240a7be5
72 changed files with 6227 additions and 0 deletions
View File
View File
+428
View File
@@ -0,0 +1,428 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db import get_db
from app.models import CaptchaProvider, DEFAULT_MIME_ALLOWLIST, PasswordMode
from app.schemas import AdminSettingsUpdate
from app.services.admin_auth import (
AdminAuth,
AdminWebAuth,
clear_admin_cookie,
get_admin_user,
set_admin_cookie,
)
from app.services.audit import (
count_audit,
list_audit,
list_audit_event_types,
purge_old_audit,
write_audit,
)
from app.services.rate_limit import rate_limiter
from app.services.settings_service import PASSWORD_MODE_HELP, get_or_create_settings
from app.services.wraps import cleanup_expired_meta, expire_due_wraps, purge_all_wraps, request_meta
router = APIRouter(prefix="/admin", include_in_schema=False)
api_router = APIRouter(prefix="/admin/api", tags=["Admin"])
templates = Jinja2Templates(directory="app/templates")
@router.get("")
async def admin_root(request: Request):
if get_admin_user(request):
return RedirectResponse("/admin/settings", status_code=302)
return RedirectResponse("/admin/login", status_code=302)
@router.get("/login")
async def login_page(request: Request):
if get_admin_user(request):
return RedirectResponse("/admin/settings", status_code=302)
return templates.TemplateResponse(
request,
"admin_login.html",
{"title": "Admin login"},
)
@router.post("/login")
async def login_submit(request: Request, db: AsyncSession = Depends(get_db)):
settings = get_settings()
app_settings = await get_or_create_settings(db)
meta = request_meta(request)
ip = meta["ip"] or "unknown"
if not rate_limiter.allow(
f"admin_login:{ip}", app_settings.rate_limit_admin_login_per_minute
):
await write_audit(
db,
event_type="admin.login",
success=False,
**meta,
details={"reason": "rate_limited"},
)
return templates.TemplateResponse(
request,
"admin_login.html",
{"title": "Admin login", "error": "Too many attempts. Try later.", "error_key": "admin.login.error.rate"},
status_code=429,
)
form = await request.form()
username = str(form.get("username") or "")
password = str(form.get("password") or "")
if username == settings.admin_username and password == settings.admin_password:
purged_audit = await purge_old_audit(db, app_settings.audit_retention_days)
expired = await expire_due_wraps(db)
cleaned = await cleanup_expired_meta(db)
await write_audit(
db,
event_type="admin.login",
success=True,
**meta,
details={
"purged_audit": purged_audit,
"expired_wraps": expired,
"cleaned_meta": cleaned,
},
)
response = RedirectResponse("/admin/settings", status_code=302)
set_admin_cookie(response, username)
return response
await write_audit(
db,
event_type="admin.login",
success=False,
**meta,
details={"reason": "bad_credentials"},
)
return templates.TemplateResponse(
request,
"admin_login.html",
{"title": "Admin login", "error": "Invalid credentials", "error_key": "admin.login.error.bad"},
status_code=401,
)
@router.post("/logout")
async def logout(request: Request, db: AsyncSession = Depends(get_db)):
meta = request_meta(request)
if get_admin_user(request):
await write_audit(db, event_type="admin.logout", success=True, **meta)
response = RedirectResponse("/admin/login", status_code=302)
clear_admin_cookie(response)
return response
@router.get("/settings")
async def settings_page(
request: Request,
db: AsyncSession = Depends(get_db),
_admin: AdminWebAuth = ...,
):
row = await get_or_create_settings(db)
env = get_settings()
return templates.TemplateResponse(
request,
"admin_settings.html",
{
"title": "Settings",
"settings": row,
"password_mode_help": PASSWORD_MODE_HELP,
"mime_examples": DEFAULT_MIME_ALLOWLIST,
"turnstile_secret_set": bool(env.turnstile_secret_key),
"hcaptcha_secret_set": bool(env.hcaptcha_secret_key),
"captcha_providers": list(CaptchaProvider),
"password_modes": list(PasswordMode),
"active": "settings",
},
)
@router.post("/settings")
async def settings_save(
request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminWebAuth = ...,
):
row = await get_or_create_settings(db)
form = await request.form()
meta = request_meta(request)
def as_int(name: str, default: int) -> int:
raw = form.get(name)
try:
return int(str(raw))
except Exception:
return default
old = {
"max_upload_bytes": row.max_upload_bytes,
"default_ttl_seconds": row.default_ttl_seconds,
"max_ttl_seconds": row.max_ttl_seconds,
"captcha_provider": row.captcha_provider.value,
"password_mode": row.password_mode.value,
}
row.max_upload_bytes = max(1, as_int("max_upload_mb", max(1, row.max_upload_bytes // 1048576))) * 1048576
row.default_ttl_seconds = max(1, as_int("default_ttl_hours", max(1, row.default_ttl_seconds // 3600))) * 3600
row.max_ttl_seconds = max(1, as_int("max_ttl_hours", max(1, row.max_ttl_seconds // 3600))) * 3600
if row.default_ttl_seconds > row.max_ttl_seconds:
row.default_ttl_seconds = row.max_ttl_seconds
row.rate_limit_create_per_minute = as_int(
"rate_limit_create_per_minute", row.rate_limit_create_per_minute
)
row.rate_limit_unwrap_per_minute = as_int(
"rate_limit_unwrap_per_minute", row.rate_limit_unwrap_per_minute
)
row.rate_limit_admin_login_per_minute = as_int(
"rate_limit_admin_login_per_minute", row.rate_limit_admin_login_per_minute
)
row.audit_retention_days = as_int("audit_retention_days", row.audit_retention_days)
row.turnstile_site_key = str(form.get("turnstile_site_key") or "").strip()
row.hcaptcha_site_key = str(form.get("hcaptcha_site_key") or "").strip()
mime_raw = str(form.get("mime_allowlist") or "")
row.mime_allowlist = [line.strip() for line in mime_raw.splitlines() if line.strip()]
captcha = str(form.get("captcha_provider") or "off")
try:
row.captcha_provider = CaptchaProvider(captcha)
except ValueError:
pass
pwd_mode = str(form.get("password_mode") or "client_only")
try:
row.password_mode = PasswordMode(pwd_mode)
except ValueError:
pass
await db.commit()
await write_audit(
db,
event_type="admin.settings_update",
success=True,
**meta,
details={"admin": admin, "old": old},
)
return RedirectResponse("/admin/settings?saved=1", status_code=302)
@router.post("/purge")
async def purge_all(
request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminWebAuth = ...,
):
meta = request_meta(request)
form = await request.form()
confirm = str(form.get("confirm") or "").strip().upper()
if confirm != "PURGE":
await write_audit(
db,
event_type="admin.purge",
success=False,
**meta,
details={"admin": admin, "reason": "confirm_mismatch"},
)
return RedirectResponse("/admin/danger?purge=confirm", status_code=302)
try:
stats = await purge_all_wraps(db)
await write_audit(
db,
event_type="admin.purge",
success=True,
**meta,
details={"admin": admin, **stats},
)
return RedirectResponse(
f"/admin/danger?purged=1&wraps={stats['wraps_deleted']}&objects={stats['objects_deleted']}",
status_code=302,
)
except Exception as exc:
await write_audit(
db,
event_type="admin.purge",
success=False,
**meta,
details={"admin": admin, "reason": "error", "error": str(exc)[:200]},
)
return RedirectResponse("/admin/danger?purge=error", status_code=302)
@router.get("/danger")
async def danger_page(
request: Request,
_admin: AdminWebAuth = ...,
):
return templates.TemplateResponse(
request,
"admin_danger.html",
{
"title": "Danger zone",
"active": "danger",
},
)
@router.get("/audit")
async def audit_page(
request: Request,
db: AsyncSession = Depends(get_db),
_admin: AdminWebAuth = ...,
):
event_type = request.query_params.get("event_type") or None
wrap_id = request.query_params.get("wrap_id") or None
if event_type == "":
event_type = None
per_page = 50
try:
page = max(1, int(request.query_params.get("page") or "1"))
except ValueError:
page = 1
total = await count_audit(db, event_type=event_type, wrap_id=wrap_id or None)
pages = max(1, (total + per_page - 1) // per_page) if total else 1
if page > pages:
page = pages
offset = (page - 1) * per_page
events = await list_audit(
db,
limit=per_page,
offset=offset,
event_type=event_type,
wrap_id=wrap_id or None,
)
event_types = await list_audit_event_types(db)
from_idx = offset + 1 if total else 0
to_idx = offset + len(events)
return templates.TemplateResponse(
request,
"admin_audit.html",
{
"title": "Audit",
"events": events,
"event_type": event_type or "",
"wrap_id": wrap_id or "",
"event_types": event_types,
"active": "audit",
"page": page,
"pages": pages,
"per_page": per_page,
"total": total,
"from_idx": from_idx,
"to_idx": to_idx,
"has_prev": page > 1,
"has_next": page < pages,
},
)
@api_router.get("/settings", summary="Get admin settings")
async def admin_settings_api(
db: AsyncSession = Depends(get_db),
_admin: AdminAuth = ...,
):
row = await get_or_create_settings(db)
return JSONResponse(
{
"max_upload_bytes": row.max_upload_bytes,
"default_ttl_seconds": row.default_ttl_seconds,
"max_ttl_seconds": row.max_ttl_seconds,
"mime_allowlist": row.mime_allowlist,
"captcha_provider": row.captcha_provider.value,
"password_mode": row.password_mode.value,
"audit_retention_days": row.audit_retention_days,
"rate_limit_create_per_minute": row.rate_limit_create_per_minute,
"rate_limit_unwrap_per_minute": row.rate_limit_unwrap_per_minute,
"rate_limit_admin_login_per_minute": row.rate_limit_admin_login_per_minute,
}
)
@api_router.put("/settings", summary="Update admin settings")
async def admin_settings_put(
body: AdminSettingsUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminAuth = ...,
):
row = await get_or_create_settings(db)
data = body.model_dump(exclude_unset=True)
if "captcha_provider" in data and data["captcha_provider"] is not None:
row.captcha_provider = CaptchaProvider(data.pop("captcha_provider"))
if "password_mode" in data and data["password_mode"] is not None:
row.password_mode = PasswordMode(data.pop("password_mode"))
for key, value in data.items():
if value is not None and hasattr(row, key):
setattr(row, key, value)
await db.commit()
meta = request_meta(request)
await write_audit(
db,
event_type="admin.settings_update",
success=True,
**meta,
details={"admin": admin, "via": "api"},
)
return {"ok": True}
@api_router.post("/purge", summary="Force-delete all wraps and MinIO objects")
async def admin_purge_api(
request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminAuth = ...,
confirm: str = "PURGE",
):
meta = request_meta(request)
if confirm.strip().upper() != "PURGE":
raise HTTPException(status_code=400, detail="confirm must be PURGE")
stats = await purge_all_wraps(db)
await write_audit(
db,
event_type="admin.purge",
success=True,
**meta,
details={"admin": admin, "via": "api", **stats},
)
return {"ok": True, **stats}
@api_router.get("/audit", summary="List audit events")
async def admin_audit_api(
db: AsyncSession = Depends(get_db),
_admin: AdminAuth = ...,
event_type: str | None = None,
wrap_id: str | None = None,
limit: int = 100,
offset: int = 0,
):
events = await list_audit(
db,
limit=min(limit, 500),
offset=max(0, offset),
event_type=event_type,
wrap_id=wrap_id,
)
return [
{
"id": str(e.id),
"created_at": e.created_at.isoformat() if e.created_at else None,
"event_type": e.event_type,
"wrap_id": e.wrap_id,
"success": e.success,
"ip": e.ip,
"user_agent": e.user_agent,
"details": e.details,
}
for e in events
]
+331
View File
@@ -0,0 +1,331 @@
from __future__ import annotations
import base64
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.models import PasswordMode, Wrap, WrapStatus
from app.schemas import (
PublicSettingsOut,
UnwrapRequest,
UnwrapResponse,
WrapCreateRequest,
WrapCreateResponse,
)
from app.services.audit import write_audit
from app.services.captcha import verify_captcha
from app.services.password_hash import hash_password, verify_password
from app.services.rate_limit import rate_limiter
from app.services.settings_service import get_or_create_settings, mime_allowed, public_settings_payload
from app.services.storage import storage
from app.services.wraps import delete_wrap_object, expire_due_wraps, new_wrap_id, request_meta
router = APIRouter(prefix="/api/v1")
@router.get(
"/settings",
response_model=PublicSettingsOut,
tags=["Public"],
summary="Public client settings",
)
async def public_settings(db: AsyncSession = Depends(get_db)) -> PublicSettingsOut:
row = await get_or_create_settings(db)
return PublicSettingsOut(**public_settings_payload(row))
@router.post(
"/wraps",
response_model=WrapCreateResponse,
tags=["Wraps"],
summary="Create encrypted wrap",
)
async def create_wrap(
body: WrapCreateRequest,
request: Request,
db: AsyncSession = Depends(get_db),
) -> WrapCreateResponse:
meta = request_meta(request)
settings = await get_or_create_settings(db)
ip = meta["ip"] or "unknown"
if not rate_limiter.allow(
f"create:{ip}", settings.rate_limit_create_per_minute
):
await write_audit(
db,
event_type="wrap.create",
success=False,
**meta,
details={"reason": "rate_limited"},
)
raise HTTPException(status_code=429, detail="rate_limited")
ok, reason = await verify_captcha(
settings.captcha_provider,
body.captcha_token,
ip,
turnstile_site_configured=bool(settings.turnstile_site_key),
hcaptcha_site_configured=bool(settings.hcaptcha_site_key),
)
if not ok:
await write_audit(
db,
event_type="wrap.create",
success=False,
**meta,
details={"reason": f"captcha_{reason}"},
)
raise HTTPException(status_code=400, detail="captcha_failed")
if body.ttl_seconds > settings.max_ttl_seconds:
raise HTTPException(status_code=400, detail="ttl_too_large")
try:
ciphertext = base64.b64decode(body.ciphertext_b64, validate=True)
except Exception as exc:
raise HTTPException(status_code=400, detail="invalid_ciphertext") from exc
if len(ciphertext) > settings.max_upload_bytes:
await write_audit(
db,
event_type="wrap.create",
success=False,
**meta,
details={"reason": "too_large", "size": len(ciphertext)},
)
raise HTTPException(status_code=413, detail="too_large")
allowlist = list(settings.mime_allowlist or [])
for ct in body.content_types:
if not mime_allowed(allowlist, ct):
await write_audit(
db,
event_type="wrap.create",
success=False,
**meta,
details={"reason": "mime_denied", "content_type": ct},
)
raise HTTPException(status_code=400, detail="mime_not_allowed")
password_hash = None
if body.has_password:
if settings.password_mode == PasswordMode.server_gate:
if not body.password:
raise HTTPException(status_code=400, detail="password_required")
password_hash = hash_password(body.password)
wrap_id = new_wrap_id()
object_key = f"wraps/{wrap_id}.bin"
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=body.ttl_seconds)
await storage.put_bytes(object_key, ciphertext)
wrap = Wrap(
id=wrap_id,
status=WrapStatus.pending,
object_key=object_key,
size_bytes=len(ciphertext),
content_types=body.content_types,
item_count=body.item_count,
has_password=body.has_password,
password_hash=password_hash,
password_mode=settings.password_mode,
expires_at=expires_at,
creator_ip=ip,
creator_ua=(meta["user_agent"] or "")[:512] or None,
)
db.add(wrap)
await db.commit()
await write_audit(
db,
event_type="wrap.create",
success=True,
wrap_id=wrap_id,
**meta,
details={
"size_bytes": len(ciphertext),
"item_count": body.item_count,
"content_types": body.content_types,
"ttl_seconds": body.ttl_seconds,
"has_password": body.has_password,
"password_mode": settings.password_mode.value,
},
)
return WrapCreateResponse(
wrap_id=wrap_id,
expires_at=expires_at,
password_mode=settings.password_mode.value,
share_path=f"/w/{wrap_id}",
)
@router.post(
"/wraps/{wrap_id}/unwrap",
response_model=UnwrapResponse,
tags=["Wraps"],
summary="One-time unwrap",
)
async def unwrap(
wrap_id: str,
body: UnwrapRequest,
request: Request,
db: AsyncSession = Depends(get_db),
) -> UnwrapResponse:
meta = request_meta(request)
settings = await get_or_create_settings(db)
ip = meta["ip"] or "unknown"
if not rate_limiter.allow(
f"unwrap:{ip}", settings.rate_limit_unwrap_per_minute
):
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "rate_limited"},
)
raise HTTPException(status_code=429, detail="rate_limited")
ok, reason = await verify_captcha(
settings.captcha_provider,
body.captcha_token,
ip,
turnstile_site_configured=bool(settings.turnstile_site_key),
hcaptcha_site_configured=bool(settings.hcaptcha_site_key),
)
if not ok:
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": f"captcha_{reason}"},
)
raise HTTPException(status_code=400, detail="captcha_failed")
await expire_due_wraps(db)
from sqlalchemy import select
result = await db.execute(select(Wrap).where(Wrap.id == wrap_id))
wrap = result.scalar_one_or_none()
# Uniform-ish failure for enumeration resistance
def fail(reason: str, status: int = 410) -> None:
raise HTTPException(status_code=status, detail=reason)
if not wrap or wrap.status != WrapStatus.pending:
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "unavailable"},
)
fail("unavailable")
assert wrap is not None
if wrap.expires_at <= datetime.now(timezone.utc):
wrap.status = WrapStatus.expired
await delete_wrap_object(db, wrap)
await db.commit()
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "expired"},
)
fail("unavailable")
if (
wrap.has_password
and wrap.password_mode == PasswordMode.server_gate
and wrap.password_hash
):
if not body.password or not verify_password(wrap.password_hash, body.password):
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "bad_password"},
)
raise HTTPException(status_code=403, detail="bad_password")
# Atomic consume
from sqlalchemy import update
now = datetime.now(timezone.utc)
upd = await db.execute(
update(Wrap)
.where(
Wrap.id == wrap_id,
Wrap.status == WrapStatus.pending,
Wrap.expires_at > now,
)
.values(status=WrapStatus.consumed, consumed_at=now)
.returning(Wrap.id)
)
consumed = upd.scalar_one_or_none()
await db.commit()
if not consumed:
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "unavailable"},
)
fail("unavailable")
try:
data = await storage.get_bytes(wrap.object_key)
except Exception:
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "storage_error"},
)
raise HTTPException(status_code=500, detail="storage_error") from None
finally:
await delete_wrap_object(db, wrap)
await write_audit(
db,
event_type="wrap.unwrap",
success=True,
wrap_id=wrap_id,
**meta,
details={
"size_bytes": wrap.size_bytes,
"item_count": wrap.item_count,
"content_types": wrap.content_types,
},
)
return UnwrapResponse(
ciphertext_b64=base64.b64encode(data).decode("ascii"),
content_types=list(wrap.content_types or []),
item_count=wrap.item_count,
has_password=wrap.has_password,
password_mode=wrap.password_mode.value,
size_bytes=wrap.size_bytes,
)
+52
View File
@@ -0,0 +1,52 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "Wrapped"
app_env: str = "development"
app_secret_key: str = "change-me"
app_base_url: str = "http://localhost:8000"
log_level: str = "INFO"
# Swagger / ReDoc / openapi.json. Default: on in development, off otherwise.
docs_enabled: bool | None = None
admin_username: str = "admin"
admin_password: str = "38dmWjE1p"
database_url: str = "postgresql+asyncpg://wrapped:wrapped@localhost:5432/wrapped"
s3_endpoint_url: str = "http://localhost:9000"
s3_access_key: str = "wrappedminio"
s3_secret_key: str = "wrappedminio123"
s3_bucket: str = "wrapped"
s3_region: str = "us-east-1"
s3_use_ssl: bool = False
s3_create_bucket: bool = True
turnstile_secret_key: str = ""
hcaptcha_secret_key: str = ""
session_cookie_name: str = "wrapped_admin"
session_max_age: int = 60 * 60 * 12
# Comma-separated IPs/CIDRs allowed to set X-Forwarded-For / X-Real-IP.
# Use * to trust any peer (recommended in k8s when only Ingress reaches the pod).
# Default: loopback + RFC1918 + Docker Desktop gateway range.
trusted_proxies: str = (
"127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,192.168.65.0/24"
)
@property
def is_docs_enabled(self) -> bool:
if self.docs_enabled is not None:
return self.docs_enabled
return self.app_env.lower() in {"development", "dev", "local"}
@lru_cache
def get_settings() -> Settings:
return Settings()
+20
View File
@@ -0,0 +1,20 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True, echo=False)
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.openapi.utils import get_openapi
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from app.api import admin, wraps
from app.config import get_settings
from app.db import SessionLocal
from app.services.settings_service import get_or_create_settings
from app.services.storage import storage
OPENAPI_TAGS = [
{
"name": "System",
"description": "Service health and runtime checks.",
},
{
"name": "Public",
"description": "Anonymous client configuration (limits, captcha, password mode).",
},
{
"name": "Wraps",
"description": "Zero-knowledge create / one-time unwrap of encrypted packages.",
},
{
"name": "Admin",
"description": "Protected admin JSON API. Authorize with HTTP Basic (admin username/password from env).",
},
]
@asynccontextmanager
async def lifespan(_app: FastAPI):
await storage.ensure_bucket()
async with SessionLocal() as db:
await get_or_create_settings(db)
yield
def custom_openapi(app: FastAPI):
if app.openapi_schema:
return app.openapi_schema
schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
tags=OPENAPI_TAGS,
)
schema.setdefault("components", {}).setdefault("securitySchemes", {})
schema["components"]["securitySchemes"]["HTTPBasic"] = {
"type": "http",
"scheme": "basic",
"description": "Admin username and password from environment (ADMIN_USERNAME / ADMIN_PASSWORD).",
}
# Mark Admin-tagged operations as secured
for path_item in schema.get("paths", {}).values():
for operation in path_item.values():
if not isinstance(operation, dict):
continue
if "Admin" in (operation.get("tags") or []):
operation["security"] = [{"HTTPBasic": []}]
app.openapi_schema = schema
return app.openapi_schema
def create_app() -> FastAPI:
settings = get_settings()
docs_on = settings.is_docs_enabled
app = FastAPI(
title=settings.app_name,
version="0.1.0",
description=(
"Wrapped — zero-knowledge one-time encrypted drop.\n\n"
"Public wrap APIs are anonymous. Admin APIs require HTTP Basic."
),
lifespan=lifespan,
openapi_tags=OPENAPI_TAGS if docs_on else None,
docs_url="/docs" if docs_on else None,
redoc_url="/redoc" if docs_on else None,
openapi_url="/openapi.json" if docs_on else None,
)
# Honor X-Forwarded-For / X-Real-IP from ingress / reverse proxies.
trusted = (settings.trusted_proxies or "").strip() or "*"
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=trusted)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(wraps.router)
app.include_router(admin.router)
app.include_router(admin.api_router)
templates = Jinja2Templates(directory="app/templates")
@app.get("/health", tags=["System"], summary="Health check", include_in_schema=docs_on)
async def health():
return {"status": "ok", "service": settings.app_name}
@app.get("/", include_in_schema=False)
async def index(request: Request):
return templates.TemplateResponse(
request,
"create.html",
{"title": "Wrapped", "page": "create"},
)
@app.get("/w/{wrap_id}", include_in_schema=False)
async def unwrap_page(request: Request, wrap_id: str):
return templates.TemplateResponse(
request,
"unwrap.html",
{"title": "Unwrap", "page": "unwrap", "wrap_id": wrap_id},
)
@app.get("/unwrap", include_in_schema=False)
async def unwrap_token_page(request: Request):
return templates.TemplateResponse(
request,
"unwrap.html",
{"title": "Unwrap", "page": "unwrap", "wrap_id": ""},
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
if (
exc.status_code == 401
and request.url.path.startswith("/admin")
and not request.url.path.startswith("/admin/api")
):
return RedirectResponse("/admin/login", status_code=302)
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=exc.headers,
)
if docs_on:
app.openapi = lambda: custom_openapi(app)
return app
app = create_app()
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
import enum
from datetime import datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
Enum,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class WrapStatus(str, enum.Enum):
pending = "pending"
consumed = "consumed"
expired = "expired"
class PasswordMode(str, enum.Enum):
client_only = "client_only"
server_gate = "server_gate"
class CaptchaProvider(str, enum.Enum):
off = "off"
turnstile = "turnstile"
hcaptcha = "hcaptcha"
class AppSettings(Base):
__tablename__ = "app_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
max_upload_bytes: Mapped[int] = mapped_column(BigInteger, default=25 * 1024 * 1024)
default_ttl_seconds: Mapped[int] = mapped_column(Integer, default=24 * 3600)
max_ttl_seconds: Mapped[int] = mapped_column(Integer, default=7 * 24 * 3600)
mime_allowlist: Mapped[list[Any]] = mapped_column(
JSONB,
default=list,
)
rate_limit_create_per_minute: Mapped[int] = mapped_column(Integer, default=10)
rate_limit_unwrap_per_minute: Mapped[int] = mapped_column(Integer, default=30)
rate_limit_admin_login_per_minute: Mapped[int] = mapped_column(Integer, default=5)
captcha_provider: Mapped[CaptchaProvider] = mapped_column(
Enum(CaptchaProvider, name="captcha_provider"),
default=CaptchaProvider.off,
)
turnstile_site_key: Mapped[str] = mapped_column(String(256), default="")
hcaptcha_site_key: Mapped[str] = mapped_column(String(256), default="")
password_mode: Mapped[PasswordMode] = mapped_column(
Enum(PasswordMode, name="password_mode"),
default=PasswordMode.client_only,
)
audit_retention_days: Mapped[int] = mapped_column(Integer, default=90)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class Wrap(Base):
__tablename__ = "wraps"
__table_args__ = (Index("ix_wraps_expires_at", "expires_at"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
status: Mapped[WrapStatus] = mapped_column(
Enum(WrapStatus, name="wrap_status"),
default=WrapStatus.pending,
index=True,
)
object_key: Mapped[str] = mapped_column(String(512), unique=True)
size_bytes: Mapped[int] = mapped_column(BigInteger)
content_types: Mapped[list[Any]] = mapped_column(JSONB, default=list)
item_count: Mapped[int] = mapped_column(Integer, default=1)
has_password: Mapped[bool] = mapped_column(Boolean, default=False)
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
password_mode: Mapped[PasswordMode] = mapped_column(
Enum(PasswordMode, name="password_mode", create_constraint=False),
default=PasswordMode.client_only,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
creator_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
creator_ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
class AuditEvent(Base):
__tablename__ = "audit_events"
__table_args__ = (
Index("ix_audit_created_at", "created_at"),
Index("ix_audit_event_type", "event_type"),
)
id: Mapped[Any] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid4)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
event_type: Mapped[str] = mapped_column(String(64))
wrap_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
success: Mapped[bool] = mapped_column(Boolean, default=True)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
accept_language: Mapped[str | None] = mapped_column(String(128), nullable=True)
forwarded_for: Mapped[str | None] = mapped_column(String(256), nullable=True)
details: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
DEFAULT_MIME_ALLOWLIST = [
"image/*",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/svg+xml",
"text/*",
"text/plain",
"text/markdown",
"text/csv",
"text/html",
"text/css",
"text/javascript",
"application/json",
"application/xml",
"application/pdf",
"application/zip",
"application/x-zip-compressed",
"application/gzip",
"application/x-gzip",
"application/x-tar",
"application/x-7z-compressed",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/msword",
"application/vnd.ms-excel",
"audio/*",
"video/*",
]
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class PublicSettingsOut(BaseModel):
max_upload_bytes: int
default_ttl_seconds: int
max_ttl_seconds: int
mime_allowlist: list[str]
captcha_provider: str
turnstile_site_key: str
hcaptcha_site_key: str
password_mode: str
password_mode_description: dict[str, str]
class WrapCreateRequest(BaseModel):
ciphertext_b64: str = Field(..., min_length=1)
ttl_seconds: int = Field(..., gt=0)
content_types: list[str] = Field(default_factory=list)
item_count: int = Field(default=1, ge=1, le=100)
has_password: bool = False
password: str | None = None
captcha_token: str | None = None
class WrapCreateResponse(BaseModel):
wrap_id: str
expires_at: datetime
password_mode: str
share_path: str
class UnwrapRequest(BaseModel):
password: str | None = None
captcha_token: str | None = None
class UnwrapResponse(BaseModel):
ciphertext_b64: str
content_types: list[str]
item_count: int
has_password: bool
password_mode: str
size_bytes: int
class AdminLoginRequest(BaseModel):
username: str
password: str
class AdminSettingsUpdate(BaseModel):
max_upload_bytes: int | None = None
default_ttl_seconds: int | None = None
max_ttl_seconds: int | None = None
mime_allowlist: list[str] | None = None
rate_limit_create_per_minute: int | None = None
rate_limit_unwrap_per_minute: int | None = None
rate_limit_admin_login_per_minute: int | None = None
captcha_provider: str | None = None
turnstile_site_key: str | None = None
hcaptcha_site_key: str | None = None
password_mode: str | None = None
audit_retention_days: int | None = None
class AuditEventOut(BaseModel):
id: str
created_at: datetime
event_type: str
wrap_id: str | None
success: bool
ip: str | None
user_agent: str | None
accept_language: str | None
forwarded_for: str | None
details: dict[str, Any]
View File
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
import secrets
from typing import Annotated
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from starlette.responses import Response
from app.config import get_settings
http_basic = HTTPBasic(auto_error=False)
def _serializer() -> URLSafeTimedSerializer:
settings = get_settings()
return URLSafeTimedSerializer(settings.app_secret_key, salt="wrapped-admin")
def create_session_token(username: str) -> str:
return _serializer().dumps({"u": username})
def read_session_token(token: str, max_age: int) -> str | None:
try:
data = _serializer().loads(token, max_age=max_age)
return data.get("u")
except (BadSignature, SignatureExpired, Exception):
return None
def set_admin_cookie(response: Response, username: str) -> None:
settings = get_settings()
token = create_session_token(username)
response.set_cookie(
settings.session_cookie_name,
token,
httponly=True,
samesite="lax",
max_age=settings.session_max_age,
secure=settings.app_env == "production",
path="/",
)
def clear_admin_cookie(response: Response) -> None:
settings = get_settings()
response.delete_cookie(settings.session_cookie_name, path="/")
def get_admin_user(request: Request) -> str | None:
settings = get_settings()
token = request.cookies.get(settings.session_cookie_name)
if not token:
return None
return read_session_token(token, settings.session_max_age)
def _basic_ok(credentials: HTTPBasicCredentials) -> bool:
settings = get_settings()
user_ok = secrets.compare_digest(credentials.username, settings.admin_username)
pass_ok = secrets.compare_digest(credentials.password, settings.admin_password)
return user_ok and pass_ok
async def require_admin_web(request: Request) -> str:
"""Cookie-only auth for HTML admin pages (redirect via exception handler)."""
cookie_user = get_admin_user(request)
if cookie_user:
return cookie_user
raise HTTPException(status_code=401, detail="unauthorized")
async def require_admin_api(
request: Request,
credentials: Annotated[HTTPBasicCredentials | None, Depends(http_basic)] = None,
) -> str:
"""Cookie or HTTP Basic — for JSON admin API / Swagger."""
cookie_user = get_admin_user(request)
if cookie_user:
return cookie_user
if credentials and _basic_ok(credentials):
return credentials.username
raise HTTPException(
status_code=401,
detail="unauthorized",
headers={"WWW-Authenticate": "Basic"},
)
AdminWebAuth = Annotated[str, Depends(require_admin_web)]
AdminAuth = Annotated[str, Depends(require_admin_api)]
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditEvent
KNOWN_AUDIT_EVENT_TYPES = [
"wrap.create",
"wrap.unwrap",
"admin.login",
"admin.logout",
"admin.settings_update",
"admin.purge",
]
async def write_audit(
db: AsyncSession,
*,
event_type: str,
success: bool,
wrap_id: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
accept_language: str | None = None,
forwarded_for: str | None = None,
details: dict[str, Any] | None = None,
) -> None:
event = AuditEvent(
event_type=event_type,
success=success,
wrap_id=wrap_id,
ip=ip,
user_agent=(user_agent or "")[:512] or None,
accept_language=(accept_language or "")[:128] or None,
forwarded_for=(forwarded_for or "")[:256] or None,
details=details or {},
)
db.add(event)
await db.commit()
async def purge_old_audit(db: AsyncSession, retention_days: int) -> int:
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
result = await db.execute(delete(AuditEvent).where(AuditEvent.created_at < cutoff))
await db.commit()
return result.rowcount or 0
def _audit_filters(event_type: str | None, wrap_id: str | None):
filters = []
if event_type:
filters.append(AuditEvent.event_type == event_type)
if wrap_id:
filters.append(AuditEvent.wrap_id == wrap_id)
return filters
async def list_audit(
db: AsyncSession,
*,
limit: int = 100,
offset: int = 0,
event_type: str | None = None,
wrap_id: str | None = None,
) -> list[AuditEvent]:
stmt = select(AuditEvent).order_by(AuditEvent.created_at.desc()).offset(offset).limit(limit)
for f in _audit_filters(event_type, wrap_id):
stmt = stmt.where(f)
result = await db.execute(stmt)
return list(result.scalars().all())
async def count_audit(
db: AsyncSession,
*,
event_type: str | None = None,
wrap_id: str | None = None,
) -> int:
stmt = select(func.count()).select_from(AuditEvent)
for f in _audit_filters(event_type, wrap_id):
stmt = stmt.where(f)
result = await db.execute(stmt)
return int(result.scalar_one() or 0)
async def list_audit_event_types(db: AsyncSession) -> list[str]:
"""Known types plus distinct values already stored in DB (dynamic dropdown)."""
result = await db.execute(
select(AuditEvent.event_type).distinct().order_by(AuditEvent.event_type)
)
from_db = [row[0] for row in result.all() if row[0]]
return sorted(set(KNOWN_AUDIT_EVENT_TYPES) | set(from_db))
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import httpx
from app.config import get_settings
from app.models import CaptchaProvider
async def verify_captcha(
provider: CaptchaProvider,
token: str | None,
remote_ip: str | None,
*,
turnstile_site_configured: bool,
hcaptcha_site_configured: bool,
) -> tuple[bool, str]:
if provider == CaptchaProvider.off:
return True, "off"
if not token:
return False, "missing_token"
settings = get_settings()
if provider == CaptchaProvider.turnstile:
if not turnstile_site_configured:
return False, "turnstile_not_configured"
secret = settings.turnstile_secret_key
if not secret:
return False, "turnstile_secret_missing"
return await _post_verify(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{"secret": secret, "response": token, "remoteip": remote_ip or ""},
)
if provider == CaptchaProvider.hcaptcha:
if not hcaptcha_site_configured:
return False, "hcaptcha_not_configured"
secret = settings.hcaptcha_secret_key
if not secret:
return False, "hcaptcha_secret_missing"
return await _post_verify(
"https://hcaptcha.com/siteverify",
{"secret": secret, "response": token, "remoteip": remote_ip or ""},
)
return False, "unknown_provider"
async def _post_verify(url: str, data: dict) -> tuple[bool, str]:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, data=data)
payload = resp.json()
if payload.get("success"):
return True, "ok"
return False, "verify_failed"
except Exception:
return False, "verify_error"
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Wrap, WrapStatus
from app.services.storage import storage
async def purge_expired_wraps(db: AsyncSession) -> int:
now = datetime.now(timezone.utc)
result = await db.execute(
select(Wrap).where(
and_(
Wrap.status == WrapStatus.pending,
Wrap.expires_at < now,
)
)
)
wraps = list(result.scalars().all())
count = 0
for wrap in wraps:
try:
await storage.delete(wrap.object_key)
except Exception:
pass
wrap.status = WrapStatus.expired
count += 1
if count:
await db.commit()
return count
async def cleanup_consumed_orphans(db: AsyncSession) -> int:
"""Best-effort: mark very old pending with missing logic already handled elsewhere."""
result = await db.execute(
select(Wrap).where(
or_(
Wrap.status == WrapStatus.consumed,
Wrap.status == WrapStatus.expired,
)
).limit(0)
)
_ = result
return 0
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import ipaddress
import re
from fastapi import Request
from app.config import get_settings
_FORWARDED_FOR_RE = re.compile(r"for=(?:\"?\[?)([^;\"\\]\S*)", re.IGNORECASE)
def _parse_ip(value: str | None) -> str | None:
if not value:
return None
raw = value.strip().strip('"').strip()
if not raw or raw.lower() == "unknown":
return None
# [IPv6]:port or IPv4:port
if raw.startswith("["):
end = raw.find("]")
if end != -1:
raw = raw[1:end]
elif raw.count(":") == 1 and "." in raw:
raw = raw.rsplit(":", 1)[0]
try:
addr = ipaddress.ip_address(raw)
except ValueError:
return None
# Normalize IPv4-mapped IPv6 (::ffff:1.2.3.4 → 1.2.3.4)
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
return str(addr.ipv4_mapped)
return str(addr)
def _peer_ip(request: Request) -> str | None:
if request.client and request.client.host:
return _parse_ip(request.client.host) or request.client.host
return None
def _trusted_proxy_nets() -> list[ipaddress.IPv4Network | ipaddress.IPv6Network] | None:
"""None means trust any peer (TRUSTED_PROXIES=*)."""
raw = (get_settings().trusted_proxies or "").strip()
if not raw or raw == "*":
return None
nets: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
for part in raw.split(","):
part = part.strip()
if not part:
continue
try:
if "/" in part:
nets.append(ipaddress.ip_network(part, strict=False))
else:
ip = ipaddress.ip_address(part)
nets.append(ipaddress.ip_network(f"{ip}/{ip.max_prefixlen}"))
except ValueError:
continue
return nets
def _is_trusted_peer(peer: str | None) -> bool:
if not peer:
return False
nets = _trusted_proxy_nets()
if nets is None:
return True
try:
addr = ipaddress.ip_address(peer)
except ValueError:
return False
return any(addr in net for net in nets)
def _ips_from_forwarded_for(header: str | None) -> list[str]:
if not header:
return []
out: list[str] = []
for part in header.split(","):
ip = _parse_ip(part)
if ip:
out.append(ip)
return out
def _ips_from_forwarded(header: str | None) -> list[str]:
if not header:
return []
out: list[str] = []
for match in _FORWARDED_FOR_RE.finditer(header):
ip = _parse_ip(match.group(1))
if ip:
out.append(ip)
return out
def client_ip(request: Request) -> str | None:
"""
Resolve the original client IP.
When the immediate peer is a trusted proxy, prefer (in order):
CF-Connecting-IP, True-Client-IP, X-Real-IP, left-most X-Forwarded-For,
RFC 7239 Forwarded. Otherwise use the TCP peer address.
"""
peer = _peer_ip(request)
if _is_trusted_peer(peer):
for header in ("cf-connecting-ip", "true-client-ip", "x-real-ip"):
ip = _parse_ip(request.headers.get(header))
if ip:
return ip
chain = _ips_from_forwarded_for(request.headers.get("x-forwarded-for"))
if chain:
return chain[0]
chain = _ips_from_forwarded(request.headers.get("forwarded"))
if chain:
return chain[0]
return peer
def request_meta(request: Request) -> dict[str, str | None]:
forwarded = request.headers.get("x-forwarded-for")
real_ip = request.headers.get("x-real-ip")
cf_ip = request.headers.get("cf-connecting-ip")
forwarded_store = forwarded or real_ip or cf_ip
return {
"ip": client_ip(request),
"user_agent": request.headers.get("user-agent"),
"accept_language": request.headers.get("accept-language"),
"forwarded_for": (forwarded_store or "")[:256] or None,
}
def uvicorn_forwarded_allow_ips() -> str:
"""Value for uvicorn --forwarded-allow-ips (CIDRs not supported → * or IP list)."""
raw = (get_settings().trusted_proxies or "").strip()
if not raw or raw == "*" or "/" in raw:
return "*"
return raw
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
_ph = PasswordHasher()
def hash_password(password: str) -> str:
return _ph.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return _ph.verify(password_hash, password)
except VerifyMismatchError:
return False
except Exception:
return False
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import time
from collections import defaultdict, deque
from threading import Lock
class RateLimiter:
"""In-memory sliding window limiter (per process). Good enough for single-replica / dev."""
def __init__(self) -> None:
self._hits: dict[str, deque[float]] = defaultdict(deque)
self._lock = Lock()
def allow(self, key: str, limit: int, window_seconds: int = 60) -> bool:
if limit <= 0:
return True
now = time.monotonic()
with self._lock:
q = self._hits[key]
while q and now - q[0] > window_seconds:
q.popleft()
if len(q) >= limit:
return False
q.append(now)
return True
rate_limiter = RateLimiter()
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
DEFAULT_MIME_ALLOWLIST,
AppSettings,
CaptchaProvider,
PasswordMode,
)
PASSWORD_MODE_HELP = {
"client_only": (
"Password is verified only in the browser after ciphertext download. "
"Maximum zero-knowledge: the server never checks the password. "
"Best when you trust link secrecy and want strongest ZK."
),
"server_gate": (
"Server stores an Argon2id hash and releases ciphertext only after a correct password. "
"Slightly weaker ZK metadata (server knows password success/fail) but stops offline "
"bruteforce if someone steals the share link without the password."
),
}
async def get_or_create_settings(db: AsyncSession) -> AppSettings:
result = await db.execute(select(AppSettings).where(AppSettings.id == 1))
row = result.scalar_one_or_none()
if row:
return row
row = AppSettings(
id=1,
mime_allowlist=list(DEFAULT_MIME_ALLOWLIST),
captcha_provider=CaptchaProvider.off,
password_mode=PasswordMode.client_only,
)
db.add(row)
await db.commit()
await db.refresh(row)
return row
def mime_allowed(allowlist: list[str], content_type: str) -> bool:
ct = (content_type or "").split(";")[0].strip().lower()
if not ct:
return False
for pattern in allowlist:
p = pattern.strip().lower()
if not p:
continue
if p.endswith("/*"):
if ct.startswith(p[:-1]):
return True
elif ct == p:
return True
return False
def public_settings_payload(row: AppSettings) -> dict:
return {
"max_upload_bytes": row.max_upload_bytes,
"default_ttl_seconds": row.default_ttl_seconds,
"max_ttl_seconds": row.max_ttl_seconds,
"mime_allowlist": list(row.mime_allowlist or []),
"captcha_provider": row.captcha_provider.value,
"turnstile_site_key": row.turnstile_site_key or "",
"hcaptcha_site_key": row.hcaptcha_site_key or "",
"password_mode": row.password_mode.value,
"password_mode_description": PASSWORD_MODE_HELP,
}
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator
from aiobotocore.session import get_session
from app.config import get_settings
class ObjectStorage:
def __init__(self) -> None:
self.settings = get_settings()
def _client_kwargs(self) -> dict[str, Any]:
return {
"endpoint_url": self.settings.s3_endpoint_url,
"aws_access_key_id": self.settings.s3_access_key,
"aws_secret_access_key": self.settings.s3_secret_key,
"region_name": self.settings.s3_region,
"use_ssl": self.settings.s3_use_ssl,
}
@asynccontextmanager
async def client(self) -> AsyncIterator[Any]:
session = get_session()
async with session.create_client("s3", **self._client_kwargs()) as client:
yield client
async def ensure_bucket(self) -> None:
if not self.settings.s3_create_bucket:
return
async with self.client() as client:
try:
await client.head_bucket(Bucket=self.settings.s3_bucket)
except Exception:
await client.create_bucket(Bucket=self.settings.s3_bucket)
async def put_bytes(self, key: str, data: bytes, content_type: str = "application/octet-stream") -> None:
async with self.client() as client:
await client.put_object(
Bucket=self.settings.s3_bucket,
Key=key,
Body=data,
ContentType=content_type,
)
async def get_bytes(self, key: str) -> bytes:
async with self.client() as client:
resp = await client.get_object(Bucket=self.settings.s3_bucket, Key=key)
async with resp["Body"] as stream:
return await stream.read()
async def delete(self, key: str) -> None:
async with self.client() as client:
await client.delete_object(Bucket=self.settings.s3_bucket, Key=key)
async def delete_prefix(self, prefix: str = "wraps/") -> int:
"""Delete all objects under prefix. Returns number of deleted keys."""
deleted = 0
async with self.client() as client:
token: str | None = None
while True:
kwargs: dict[str, Any] = {
"Bucket": self.settings.s3_bucket,
"Prefix": prefix,
"MaxKeys": 1000,
}
if token:
kwargs["ContinuationToken"] = token
resp = await client.list_objects_v2(**kwargs)
contents = resp.get("Contents") or []
if contents:
# delete_objects accepts up to 1000 keys
await client.delete_objects(
Bucket=self.settings.s3_bucket,
Delete={
"Objects": [{"Key": obj["Key"]} for obj in contents],
"Quiet": True,
},
)
deleted += len(contents)
if not resp.get("IsTruncated"):
break
token = resp.get("NextContinuationToken")
return deleted
storage = ObjectStorage()
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from app.services.client_ip import client_ip, request_meta
def new_wrap_id() -> str:
return secrets.token_urlsafe(18).replace("-", "").replace("_", "")[:24]
def expires_at_from_ttl(ttl_seconds: int) -> datetime:
return datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
__all__ = ["client_ip", "request_meta", "new_wrap_id", "expires_at_from_ttl"]
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import secrets
from datetime import datetime, timezone
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Wrap, WrapStatus
from app.services.client_ip import client_ip, request_meta
from app.services.storage import storage
def new_wrap_id() -> str:
return secrets.token_urlsafe(18).replace("-", "").replace("_", "")[:24]
async def expire_due_wraps(db: AsyncSession) -> int:
now = datetime.now(timezone.utc)
result = await db.execute(
select(Wrap).where(Wrap.status == WrapStatus.pending, Wrap.expires_at <= now)
)
rows = list(result.scalars().all())
for wrap in rows:
try:
await storage.delete(wrap.object_key)
except Exception:
pass
wrap.status = WrapStatus.expired
if rows:
await db.commit()
return len(rows)
async def delete_wrap_object(db: AsyncSession, wrap: Wrap) -> None:
try:
await storage.delete(wrap.object_key)
except Exception:
pass
async def cleanup_expired_meta(db: AsyncSession) -> int:
"""Remove long-expired wrap rows (objects already deleted)."""
now = datetime.now(timezone.utc)
result = await db.execute(
delete(Wrap).where(
Wrap.status.in_([WrapStatus.expired, WrapStatus.consumed]),
Wrap.expires_at < now,
)
)
await db.commit()
return result.rowcount or 0
async def mark_consumed(db: AsyncSession, wrap_id: str) -> Wrap | None:
now = datetime.now(timezone.utc)
result = await db.execute(
update(Wrap)
.where(Wrap.id == wrap_id, Wrap.status == WrapStatus.pending, Wrap.expires_at > now)
.values(status=WrapStatus.consumed, consumed_at=now)
.returning(Wrap)
)
row = result.scalar_one_or_none()
await db.commit()
return row
async def purge_all_wraps(db: AsyncSession) -> dict[str, int]:
"""Force-delete all wrap DB rows and all objects under wraps/ in MinIO."""
count_result = await db.execute(select(Wrap.id))
db_count = len(list(count_result.scalars().all()))
await db.execute(delete(Wrap))
await db.commit()
objects_deleted = await storage.delete_prefix("wraps/")
return {"wraps_deleted": db_count, "objects_deleted": objects_deleted}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 501 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="g" x1="2" y1="2" x2="30" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#3dd6c6"/>
<stop offset="1" stop-color="#6ea8ff"/>
</linearGradient>
</defs>
<rect x="1" y="1" width="30" height="30" rx="8" fill="url(#g)"/>
<!-- shield (halved style) -->
<path d="M16 6.4 24 9.5v6.2c0 4.5-3.4 8.3-8 9.9-4.6-1.6-8-5.4-8-9.9V9.5L16 6.4z" fill="#041018"/>
<path d="M16 7.5v17.2c3.7-1.4 6.2-4.7 6.2-8V10.2L16 7.5z" fill="#0a2030"/>
<path d="M16 7.2v17.9" stroke="#3dd6c6" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 654 B

+25
View File
@@ -0,0 +1,25 @@
(() => {
const form = document.getElementById("audit-filter-form");
const typeSelect = document.getElementById("audit-event-type");
if (form && typeSelect) {
typeSelect.addEventListener("change", () => form.requestSubmit());
}
// Human-friendly local timestamps
document.querySelectorAll(".audit-time[data-ts]").forEach((el) => {
const raw = el.getAttribute("data-ts");
if (!raw) return;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return;
const locale = window.WrappedI18n?.locale?.() || undefined;
el.textContent = new Intl.DateTimeFormat(locale, {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(d);
el.title = raw;
});
})();
+32
View File
@@ -0,0 +1,32 @@
(() => {
const root = document.getElementById("admin-tabs");
if (!root) return;
const tabs = [...root.querySelectorAll(".admin-tab")];
const panels = [...root.querySelectorAll(".admin-tabpanel")];
function activate(name) {
const id = name || "limits";
tabs.forEach((tab) => {
const on = tab.dataset.tab === id;
tab.classList.toggle("active", on);
tab.setAttribute("aria-selected", on ? "true" : "false");
});
panels.forEach((panel) => {
const on = panel.dataset.panel === id;
panel.classList.toggle("active", on);
panel.hidden = !on;
});
const url = new URL(location.href);
url.hash = id;
history.replaceState(null, "", url);
}
tabs.forEach((tab) => {
tab.addEventListener("click", () => activate(tab.dataset.tab));
});
const fromHash = (location.hash || "").replace(/^#/, "");
const initial = tabs.some((t) => t.dataset.tab === fromHash) ? fromHash : "limits";
activate(initial);
})();
+304
View File
@@ -0,0 +1,304 @@
(() => {
const t = (k) => window.WrappedI18n.t(k);
const files = [];
let settings = null;
let captchaWidgetId = null;
const els = {
text: document.getElementById("payload-text"),
lang: document.getElementById("text-language"),
ttl: document.getElementById("ttl-seconds"),
password: document.getElementById("password"),
generatePassword: document.getElementById("generate-password"),
dropzone: document.getElementById("dropzone"),
fileInput: document.getElementById("file-input"),
fileList: document.getElementById("file-list"),
wrapBtn: document.getElementById("wrap-btn"),
error: document.getElementById("form-error"),
success: document.getElementById("success-panel"),
composer: document.querySelector(".composer"),
shareLink: document.getElementById("share-link"),
shareToken: document.getElementById("share-token"),
expiresMeta: document.getElementById("expires-meta"),
captchaSlot: document.getElementById("captcha-slot"),
};
function showError(msg) {
els.error.textContent = msg;
els.error.classList.remove("hidden");
}
function clearError() {
els.error.classList.add("hidden");
els.error.textContent = "";
}
function mimeAllowed(ct) {
const allow = settings.mime_allowlist || [];
const base = (ct || "").split(";")[0].trim().toLowerCase();
return allow.some((p) => {
const pat = p.toLowerCase();
if (pat.endsWith("/*")) return base.startsWith(pat.slice(0, -1));
return base === pat;
});
}
function renderFiles() {
els.fileList.innerHTML = "";
files.forEach((f, idx) => {
const li = document.createElement("li");
li.innerHTML = `<span>${f.name} <small>(${f.type || "file"} · ${f.size} B)</small></span>`;
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = "×";
btn.addEventListener("click", () => {
files.splice(idx, 1);
renderFiles();
});
li.appendChild(btn);
els.fileList.appendChild(li);
});
}
function addFiles(list) {
for (const f of list) {
if (!mimeAllowed(f.type || "application/octet-stream")) {
showError(t("create.mimeDenied") + `: ${f.type || f.name}`);
continue;
}
files.push(f);
}
renderFiles();
}
let lastExpiresAt = null;
function fillTtl() {
if (!settings || !els.ttl) return;
const max = settings.max_ttl_seconds;
const def = settings.default_ttl_seconds;
const selected = els.ttl.value ? Number(els.ttl.value) : def;
const options = [
{ key: "create.ttl.1h", value: 3600 },
{ key: "create.ttl.6h", value: 6 * 3600 },
{ key: "create.ttl.24h", value: 24 * 3600 },
{ key: "create.ttl.3d", value: 3 * 24 * 3600 },
{ key: "create.ttl.7d", value: 7 * 24 * 3600 },
].filter((o) => o.value <= max);
if (!options.find((o) => o.value === def) && def <= max) {
options.unshift({ key: "create.ttl.default", value: def, seconds: def });
}
const pick = options.some((o) => o.value === selected) ? selected : def;
els.ttl.innerHTML = options
.map((o) => {
const label =
o.key === "create.ttl.default"
? t("create.ttl.default", { seconds: o.seconds })
: t(o.key);
return `<option value="${o.value}" ${o.value === pick ? "selected" : ""}>${label}</option>`;
})
.join("");
}
function refreshExpiresMeta() {
if (lastExpiresAt && els.expiresMeta) {
els.expiresMeta.textContent = window.WrappedI18n.formatExpires(lastExpiresAt);
}
}
function loadCaptcha() {
els.captchaSlot.innerHTML = "";
captchaWidgetId = null;
const provider = settings.captcha_provider;
if (provider === "turnstile" && settings.turnstile_site_key) {
const div = document.createElement("div");
div.className = "cf-turnstile";
div.dataset.sitekey = settings.turnstile_site_key;
els.captchaSlot.appendChild(div);
const s = document.createElement("script");
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
s.async = true;
document.body.appendChild(s);
} else if (provider === "hcaptcha" && settings.hcaptcha_site_key) {
const div = document.createElement("div");
div.className = "h-captcha";
div.dataset.sitekey = settings.hcaptcha_site_key;
els.captchaSlot.appendChild(div);
const s = document.createElement("script");
s.src = "https://js.hcaptcha.com/1/api.js";
s.async = true;
document.body.appendChild(s);
}
}
function captchaToken() {
if (settings.captcha_provider === "turnstile" && window.turnstile) {
return window.turnstile.getResponse?.() || "";
}
if (settings.captcha_provider === "hcaptcha" && window.hcaptcha) {
return window.hcaptcha.getResponse?.(captchaWidgetId) || window.hcaptcha.getResponse?.() || "";
}
return "";
}
async function init() {
const resp = await fetch("/api/v1/settings");
settings = await resp.json();
fillTtl();
loadCaptcha();
}
els.dropzone.addEventListener("click", () => els.fileInput.click());
els.fileInput.addEventListener("change", () => addFiles(els.fileInput.files));
["dragenter", "dragover"].forEach((ev) => {
els.dropzone.addEventListener(ev, (e) => {
e.preventDefault();
els.dropzone.classList.add("dragover");
});
});
["dragleave", "drop"].forEach((ev) => {
els.dropzone.addEventListener(ev, (e) => {
e.preventDefault();
els.dropzone.classList.remove("dragover");
});
});
els.dropzone.addEventListener("drop", (e) => addFiles(e.dataTransfer.files));
document.addEventListener("paste", (e) => {
const items = e.clipboardData?.items;
if (!items) return;
const pasted = [];
for (const item of items) {
if (item.kind === "file") {
const f = item.getAsFile();
if (f) pasted.push(f);
}
}
if (pasted.length) {
e.preventDefault();
addFiles(pasted);
}
});
async function copyFrom(input, btn) {
await navigator.clipboard.writeText(input.value);
const old = btn.textContent;
btn.textContent = t("common.copied");
setTimeout(() => (btn.textContent = old), 1200);
}
document.getElementById("copy-link").addEventListener("click", () => {
copyFrom(els.shareLink, document.getElementById("copy-link"));
});
document.getElementById("copy-token").addEventListener("click", () => {
copyFrom(els.shareToken, document.getElementById("copy-token"));
});
document.getElementById("create-another").addEventListener("click", () => {
location.reload();
});
els.wrapBtn.addEventListener("click", async () => {
clearError();
const text = els.text.value;
if (!text.trim() && files.length === 0) {
showError(t("create.needPayload"));
return;
}
const items = [];
const contentTypes = [];
if (text.trim()) {
items.push({
type: "text",
language: els.lang.value,
content: text,
});
contentTypes.push("text/plain");
}
for (const f of files) {
const item = await window.WrappedCrypto.fileToItem(f);
items.push(item);
contentTypes.push(item.mime);
}
for (const ct of contentTypes) {
if (!mimeAllowed(ct)) {
showError(t("create.mimeDenied"));
return;
}
}
const password = els.password.value || "";
const pack = { version: 1, items };
const { ciphertext, keyB64url } = await window.WrappedCrypto.encryptPackage(
pack,
password || null
);
if (ciphertext.byteLength > settings.max_upload_bytes) {
showError(t("create.tooLarge"));
return;
}
els.wrapBtn.disabled = true;
try {
const body = {
ciphertext_b64: window.WrappedCrypto.bytesToBase64(ciphertext),
ttl_seconds: Number(els.ttl.value),
content_types: contentTypes,
item_count: items.length,
has_password: Boolean(password),
password: settings.password_mode === "server_gate" && password ? password : null,
captcha_token: captchaToken() || null,
};
const resp = await fetch("/api/v1/wraps", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showError(err.detail || t("common.error"));
return;
}
const data = await resp.json();
const token = window.WrappedCrypto.buildToken(data.wrap_id, keyB64url);
const link = `${location.origin}${data.share_path}#${keyB64url}`;
els.shareLink.value = link;
els.shareToken.value = token;
lastExpiresAt = data.expires_at;
refreshExpiresMeta();
els.composer.classList.add("hidden");
els.success.classList.remove("hidden");
} catch {
showError(t("common.error"));
} finally {
els.wrapBtn.disabled = false;
}
});
function generatePassword(length = 20) {
const alphabet =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*-_";
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join("");
}
els.generatePassword?.addEventListener("click", () => {
const pwd = generatePassword();
els.password.type = "text";
els.password.value = pwd;
els.password.focus();
els.password.select();
});
if (window.WrappedI18n.onChange) {
window.WrappedI18n.onChange(() => {
fillTtl();
refreshExpiresMeta();
});
}
init().catch(() => showError(t("common.error")));
})();
+199
View File
@@ -0,0 +1,199 @@
(() => {
const te = new TextEncoder();
const td = new TextDecoder();
function b64encode(buf) {
const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf;
let s = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(s);
}
function b64decode(str) {
const bin = atob(str);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function b64urlEncode(buf) {
return b64encode(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function b64urlDecode(str) {
const pad = "=".repeat((4 - (str.length % 4)) % 4);
return b64decode(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
}
async function importAesKey(raw) {
return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [
"encrypt",
"decrypt",
]);
}
async function derivePasswordKey(password, salt) {
const base = await crypto.subtle.importKey(
"raw",
te.encode(password),
"PBKDF2",
false,
["deriveKey"]
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 210000, hash: "SHA-256" },
base,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
async function encryptPackage(packageObj, password) {
const dek = crypto.getRandomValues(new Uint8Array(32));
const payload = te.encode(JSON.stringify(packageObj));
const nonce = crypto.getRandomValues(new Uint8Array(12));
const key = await importAesKey(dek);
const encrypted = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, payload)
);
let body = encrypted;
let bodyNonce = nonce;
let flags = 0;
let salt = new Uint8Array(16);
let outerNonce = new Uint8Array(12);
if (password) {
flags = 1;
salt = crypto.getRandomValues(new Uint8Array(16));
outerNonce = crypto.getRandomValues(new Uint8Array(12));
const pwKey = await derivePasswordKey(password, salt);
const inner = new Uint8Array(nonce.length + encrypted.length);
inner.set(nonce, 0);
inner.set(encrypted, nonce.length);
body = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv: outerNonce }, pwKey, inner)
);
bodyNonce = outerNonce;
}
// header: magic(4) version(1) flags(1) salt(16) nonce(12) + ciphertext
const header = new Uint8Array(4 + 1 + 1 + 16 + 12);
header.set(te.encode("WRPD"), 0);
header[4] = 1;
header[5] = flags;
header.set(salt, 6);
header.set(bodyNonce, 22);
const out = new Uint8Array(header.length + body.length);
out.set(header, 0);
out.set(body, header.length);
return {
ciphertext: out,
keyB64url: b64urlEncode(dek),
};
}
async function decryptPackage(ciphertext, keyB64url, password) {
if (ciphertext.length < 34) throw new Error("truncated");
const magic = td.decode(ciphertext.subarray(0, 4));
if (magic !== "WRPD") throw new Error("bad_magic");
const flags = ciphertext[5];
const salt = ciphertext.subarray(6, 22);
const nonce = ciphertext.subarray(22, 34);
const body = ciphertext.subarray(34);
const dek = b64urlDecode(keyB64url);
let plainEncrypted;
let innerNonce;
if (flags & 1) {
if (!password) throw new Error("password_required");
const pwKey = await derivePasswordKey(password, salt);
let inner;
try {
inner = new Uint8Array(
await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, pwKey, body)
);
} catch {
throw new Error("bad_password");
}
innerNonce = inner.subarray(0, 12);
plainEncrypted = inner.subarray(12);
} else {
innerNonce = nonce;
plainEncrypted = body;
}
const key = await importAesKey(dek);
let payload;
try {
payload = new Uint8Array(
await crypto.subtle.decrypt({ name: "AES-GCM", iv: innerNonce }, key, plainEncrypted)
);
} catch {
throw new Error("decrypt_failed");
}
return JSON.parse(td.decode(payload));
}
function buildToken(wrapId, keyB64url) {
return `wrapped_v1.${wrapId}.${keyB64url}`;
}
function parseToken(input) {
const raw = (input || "").trim();
if (!raw) return null;
if (raw.startsWith("wrapped_v1.")) {
const parts = raw.split(".");
if (parts.length < 3) return null;
return { wrapId: parts[1], key: parts.slice(2).join(".") };
}
// bare id — key may come from location.hash
if (/^[A-Za-z0-9]+$/.test(raw) && raw.length >= 16) {
return { wrapId: raw, key: null };
}
try {
const url = new URL(raw);
const m = url.pathname.match(/\/w\/([A-Za-z0-9]+)/);
if (m) {
return { wrapId: m[1], key: url.hash ? url.hash.slice(1) : null };
}
} catch {
/* ignore */
}
return null;
}
function bytesToBase64(bytes) {
return b64encode(bytes);
}
function base64ToBytes(b64) {
return b64decode(b64);
}
async function fileToItem(file) {
const buf = new Uint8Array(await file.arrayBuffer());
return {
type: "file",
name: file.name || "file",
mime: file.type || "application/octet-stream",
data_b64: b64encode(buf),
};
}
window.WrappedCrypto = {
encryptPackage,
decryptPackage,
buildToken,
parseToken,
bytesToBase64,
base64ToBytes,
fileToItem,
b64urlEncode,
};
})();
+422
View File
@@ -0,0 +1,422 @@
(() => {
const KEY = "wrapped.lang";
const dict = {
en: {
"footer.tagline": "Zero-knowledge · one-time · encrypted",
"footer.lede": "Wrap text, images or files into a one-time token. Encryption happens in your browser — the server never sees plaintext.",
"footer.cap.text": "Text",
"footer.cap.text.hint": "Secrets, configs, notes",
"footer.cap.media": "Images & files",
"footer.cap.media.hint": "Screenshots, archives, docs",
"footer.cap.browser": "Encrypted in-browser",
"footer.cap.browser.hint": "Only ciphertext reaches the server",
"footer.pillar.zk": "Zero-knowledge",
"footer.pillar.once": "One-time",
"footer.pillar.encrypted": "Encrypted",
"footer.pillar.zk.hint": "Server never sees content",
"footer.pillar.once.hint": "Gone after unwrap",
"footer.pillar.encrypted.hint": "Key lives only in your link",
"footer.copy.author": "Sergey Antropov",
"brand.tagline": "Wrap. Send. Vanish.",
"create.eyebrow": "Secure drop",
"create.title": "Wrap. Send. Vanish.",
"create.lede": "Wrap text, images or files into a one-time token. Encryption happens in your browser — the server never sees plaintext.",
"create.language": "Highlight language",
"create.text": "Text",
"create.textPlaceholder": "Paste secrets, configs, notes…",
"create.drop": "Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)",
"create.ttl": "Time to live",
"create.password": "Password (optional)",
"create.passwordPlaceholder": "Extra unlock secret",
"create.passwordGenerate": "Generate password",
"create.passwordGenerateTip": "Generate a strong password",
"create.submit": "Create wrapped token",
"create.openToken": "Open a token",
"create.successTitle": "Token issued",
"create.successWarn": "One-time unwrap. After someone opens it, ciphertext is destroyed on the server.",
"create.shareLink": "Share link",
"create.token": "Wrapped token",
"create.another": "Create another",
"create.needPayload": "Add text or at least one file.",
"create.tooLarge": "Package exceeds max size.",
"create.mimeDenied": "One or more MIME types are not allowed.",
"create.expires": "Expires {datetime} · {relative}",
"create.ttl.1h": "1 hour",
"create.ttl.6h": "6 hours",
"create.ttl.24h": "24 hours",
"create.ttl.3d": "3 days",
"create.ttl.7d": "7 days",
"create.ttl.default": "Default ({seconds} s)",
"lang.plaintext": "Plain text",
"lang.markdown": "Markdown",
"lang.json": "JSON",
"lang.yaml": "YAML",
"lang.python": "Python",
"lang.javascript": "JavaScript",
"lang.typescript": "TypeScript",
"lang.go": "Go",
"lang.rust": "Rust",
"lang.sql": "SQL",
"lang.bash": "Bash",
"lang.html": "HTML",
"lang.css": "CSS",
"relative.inMinutes": "in {n} min",
"relative.inHours": "in {n} h",
"relative.inDays": "in {n} d",
"relative.soon": "soon",
"unwrap.eyebrow": "Unwrap",
"unwrap.title": "Reveal once",
"unwrap.lede": "Paste a wrapped token or open a share link. Ciphertext is fetched once and deleted from the server.",
"unwrap.token": "Wrapped token or ID",
"unwrap.tokenPlaceholder": "wrapped_v1.… or wrap id",
"unwrap.password": "Password (if set)",
"unwrap.submit": "Unwrap",
"unwrap.destroyed": "Server copy destroyed. Preview lives only in this browser session.",
"unwrap.needKey": "Missing encryption key. Open the full share link (with #key) or paste the full wrapped token.",
"unwrap.badPassword": "Wrong password.",
"unwrap.unavailable": "Unavailable (already used, expired, or invalid).",
"common.copy": "Copy",
"common.copied": "Copied",
"common.download": "Download",
"common.error": "Something went wrong.",
"admin.nav.settings": "Settings",
"admin.nav.audit": "Audit",
"admin.nav.danger": "Danger",
"admin.nav.site": "Site",
"admin.nav.logout": "Logout",
"admin.brand.tagline": "Admin console",
"admin.settings.title": "Settings",
"admin.audit.title": "Audit",
"admin.tab.limits": "Limits",
"admin.tab.rate": "Rate limits",
"admin.tab.mime": "MIME",
"admin.tab.password": "Password",
"admin.tab.captcha": "CAPTCHA",
"admin.login.sub": "Admin",
"admin.login.username": "Username",
"admin.login.password": "Password",
"admin.login.submit": "Sign in",
"admin.login.error.rate": "Too many attempts. Try later.",
"admin.login.error.bad": "Invalid credentials",
"admin.flash.saved": "Settings saved.",
"admin.flash.purgedPrefix": "Purged",
"admin.flash.purgedWraps": "wrap(s) and",
"admin.flash.purgedObjects": "object(s).",
"admin.flash.purgeConfirm": "Type PURGE to confirm forced cleanup.",
"admin.flash.purgeError": "Purge failed. Check logs / MinIO connectivity.",
"admin.limits.title": "Limits",
"admin.limits.maxUpload": "Max upload (MB)",
"admin.limits.defaultTtl": "Default TTL (hours)",
"admin.limits.maxTtl": "Max TTL (hours)",
"admin.limits.auditRetention": "Audit retention (days)",
"admin.rate.title": "Rate limits (per IP / minute)",
"admin.rate.create": "Create",
"admin.rate.unwrap": "Unwrap",
"admin.rate.adminLogin": "Admin login",
"admin.mime.title": "MIME allowlist",
"admin.mime.hint": "One pattern per line. Supports exact types and wildcards like image/*.",
"admin.mime.examples": "Examples / suggested defaults",
"admin.passwordMode.title": "Password mode",
"admin.passwordMode.client_only": "Password is verified only in the browser after ciphertext download. Maximum zero-knowledge: the server never checks the password. Best when you trust link secrecy and want strongest ZK.",
"admin.passwordMode.server_gate": "Server stores an Argon2id hash and releases ciphertext only after a correct password. Slightly weaker ZK metadata but stops offline bruteforce if someone steals the share link without the password.",
"admin.captcha.title": "CAPTCHA",
"admin.captcha.provider": "Provider",
"admin.captcha.turnstileSite": "Turnstile site key",
"admin.captcha.hcaptchaSite": "hCaptcha site key",
"admin.captcha.secrets": "Secrets:",
"admin.captcha.set": "set",
"admin.captcha.missing": "missing",
"admin.captcha.viaEnv": "(via env TURNSTILE_SECRET_KEY / HCAPTCHA_SECRET_KEY)",
"admin.captcha.helperTitle": "How to get CAPTCHA tokens",
"admin.captcha.turnstile.1": "Open Cloudflare Turnstile dashboard",
"admin.captcha.turnstile.2": "Create a site widget for your domain (use localhost for dev)",
"admin.captcha.turnstile.3": "Copy Site Key into the field above",
"admin.captcha.turnstile.4": "Copy Secret Key into env TURNSTILE_SECRET_KEY and restart",
"admin.captcha.turnstile.5": "Set provider to turnstile",
"admin.captcha.hcaptcha.1": "Open hCaptcha dashboard",
"admin.captcha.hcaptcha.2": "New site → copy Sitekey here",
"admin.captcha.hcaptcha.3": "Secret into env HCAPTCHA_SECRET_KEY, restart",
"admin.captcha.hcaptcha.4": "Set provider to hcaptcha",
"admin.settings.save": "Save settings",
"admin.danger.title": "Danger zone",
"admin.danger.hint": "Force-delete all wraps from the database and all ciphertext objects in MinIO (wraps/). Audit log is kept. This cannot be undone.",
"admin.danger.confirmLabel": "Type PURGE to confirm",
"admin.danger.submit": "Clear all wraps & files",
"admin.danger.confirmDialog": "Delete ALL wraps and files? This cannot be undone.",
"admin.danger.confirmTitle": "Confirm purge",
"admin.danger.confirmOk": "Delete everything",
"modal.confirm.title": "Confirm",
"modal.confirm.ok": "Confirm",
"modal.confirm.cancel": "Cancel",
"admin.audit.eventType": "Event type",
"admin.audit.wrapId": "Wrap ID",
"admin.audit.filter": "Filter",
"admin.audit.allEvents": "All events",
"admin.audit.shown": "events shown",
"admin.audit.of": "of",
"admin.audit.events": "events",
"admin.audit.page": "Page",
"admin.audit.prev": "Prev",
"admin.audit.next": "Next",
"admin.audit.col.time": "Time",
"admin.audit.col.event": "Event",
"admin.audit.col.ok": "OK",
"admin.audit.col.wrap": "Wrap",
"admin.audit.col.details": "Details",
"admin.audit.yes": "yes",
"admin.audit.no": "no",
"admin.audit.empty": "No events",
},
ru: {
"footer.tagline": "Zero-knowledge · одноразово · зашифровано",
"footer.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.",
"footer.cap.text": "Текст",
"footer.cap.text.hint": "Секреты, конфиги, заметки",
"footer.cap.media": "Картинки и файлы",
"footer.cap.media.hint": "Скриншоты, архивы, документы",
"footer.cap.browser": "Шифрование в браузере",
"footer.cap.browser.hint": "На сервер уходит только ciphertext",
"footer.pillar.zk": "Zero-knowledge",
"footer.pillar.once": "Одноразово",
"footer.pillar.encrypted": "Зашифровано",
"footer.pillar.zk.hint": "Сервер не видит содержимое",
"footer.pillar.once.hint": "После открытия — удаление",
"footer.pillar.encrypted.hint": "Ключ только в вашей ссылке",
"footer.copy.author": "Сергей Антропов",
"brand.tagline": "Упакуй. Отправь. Исчезни.",
"create.eyebrow": "Безопасная передача",
"create.title": "Упакуй. Отправь. Исчезни.",
"create.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.",
"create.language": "Язык подсветки",
"create.text": "Текст",
"create.textPlaceholder": "Вставь секреты, конфиги, заметки…",
"create.drop": "Перетащи файлы, выбери или вставь скриншот из буфера (⌘V / Ctrl+V)",
"create.ttl": "Время жизни",
"create.password": "Пароль (необязательно)",
"create.passwordPlaceholder": "Дополнительный секрет",
"create.passwordGenerate": "Сгенерировать пароль",
"create.passwordGenerateTip": "Сгенерировать надёжный пароль",
"create.submit": "Создать wrapped-токен",
"create.openToken": "Открыть токен",
"create.successTitle": "Токен выдан",
"create.successWarn": "Unwrap один раз. После открытия ciphertext удаляется на сервере.",
"create.shareLink": "Ссылка",
"create.token": "Wrapped-токен",
"create.another": "Создать ещё",
"create.needPayload": "Добавь текст или хотя бы один файл.",
"create.tooLarge": "Пакет превышает лимит размера.",
"create.mimeDenied": "Один или несколько MIME-типов запрещены.",
"create.expires": "Истекает {datetime} · {relative}",
"create.ttl.1h": "1 час",
"create.ttl.6h": "6 часов",
"create.ttl.24h": "24 часа",
"create.ttl.3d": "3 дня",
"create.ttl.7d": "7 дней",
"create.ttl.default": "По умолчанию ({seconds} с)",
"lang.plaintext": "Обычный текст",
"lang.markdown": "Markdown",
"lang.json": "JSON",
"lang.yaml": "YAML",
"lang.python": "Python",
"lang.javascript": "JavaScript",
"lang.typescript": "TypeScript",
"lang.go": "Go",
"lang.rust": "Rust",
"lang.sql": "SQL",
"lang.bash": "Bash",
"lang.html": "HTML",
"lang.css": "CSS",
"relative.inMinutes": "через {n} мин",
"relative.inHours": "через {n} ч",
"relative.inDays": "через {n} дн",
"relative.soon": "скоро",
"unwrap.eyebrow": "Unwrap",
"unwrap.title": "Открыть один раз",
"unwrap.lede": "Вставь wrapped-токен или открой ссылку. Ciphertext скачивается один раз и удаляется на сервере.",
"unwrap.token": "Wrapped-токен или ID",
"unwrap.tokenPlaceholder": "wrapped_v1.… или ID",
"unwrap.password": "Пароль (если задан)",
"unwrap.submit": "Расшифровать",
"unwrap.destroyed": "Копия на сервере уничтожена. Превью только в этой сессии браузера.",
"unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.",
"unwrap.badPassword": "Неверный пароль.",
"unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).",
"common.copy": "Копировать",
"common.copied": "Скопировано",
"common.download": "Скачать",
"common.error": "Что-то пошло не так.",
"admin.nav.settings": "Настройки",
"admin.nav.audit": "Аудит",
"admin.nav.danger": "Опасная зона",
"admin.nav.site": "Сайт",
"admin.nav.logout": "Выйти",
"admin.brand.tagline": "Консоль администратора",
"admin.settings.title": "Настройки",
"admin.audit.title": "Аудит",
"admin.tab.limits": "Лимиты",
"admin.tab.rate": "Rate limits",
"admin.tab.mime": "MIME",
"admin.tab.password": "Пароль",
"admin.tab.captcha": "CAPTCHA",
"admin.login.sub": "Админ",
"admin.login.username": "Логин",
"admin.login.password": "Пароль",
"admin.login.submit": "Войти",
"admin.login.error.rate": "Слишком много попыток. Попробуйте позже.",
"admin.login.error.bad": "Неверный логин или пароль",
"admin.flash.saved": "Настройки сохранены.",
"admin.flash.purgedPrefix": "Удалено",
"admin.flash.purgedWraps": "wrap(ов) и",
"admin.flash.purgedObjects": "объект(ов).",
"admin.flash.purgeConfirm": "Введите PURGE для подтверждения очистки.",
"admin.flash.purgeError": "Очистка не удалась. Проверьте логи / MinIO.",
"admin.limits.title": "Лимиты",
"admin.limits.maxUpload": "Макс. размер (МБ)",
"admin.limits.defaultTtl": "TTL по умолчанию (часы)",
"admin.limits.maxTtl": "Макс. TTL (часы)",
"admin.limits.auditRetention": "Хранение аудита (дни)",
"admin.rate.title": "Rate limits (на IP / минуту)",
"admin.rate.create": "Создание",
"admin.rate.unwrap": "Unwrap",
"admin.rate.adminLogin": "Вход в админку",
"admin.mime.title": "MIME allowlist",
"admin.mime.hint": "Один шаблон на строку. Точные типы и wildcards вроде image/*.",
"admin.mime.examples": "Примеры / рекомендуемые значения",
"admin.passwordMode.title": "Режим пароля",
"admin.passwordMode.client_only": "Пароль проверяется только в браузере после скачивания ciphertext. Максимальный zero-knowledge: сервер пароль не проверяет. Лучше, если доверяете секретности ссылки.",
"admin.passwordMode.server_gate": "Сервер хранит Argon2id-хеш и отдаёт ciphertext только после верного пароля. Чуть слабее ZK по метаданным, но защищает от офлайн-брута при утечке ссылки без пароля.",
"admin.captcha.title": "CAPTCHA",
"admin.captcha.provider": "Провайдер",
"admin.captcha.turnstileSite": "Turnstile site key",
"admin.captcha.hcaptchaSite": "hCaptcha site key",
"admin.captcha.secrets": "Секреты:",
"admin.captcha.set": "задан",
"admin.captcha.missing": "нет",
"admin.captcha.viaEnv": "(через env TURNSTILE_SECRET_KEY / HCAPTCHA_SECRET_KEY)",
"admin.captcha.helperTitle": "Как получить токены CAPTCHA",
"admin.captcha.turnstile.1": "Откройте Cloudflare Turnstile dashboard",
"admin.captcha.turnstile.2": "Создайте виджет для домена (для dev — localhost)",
"admin.captcha.turnstile.3": "Скопируйте Site Key в поле выше",
"admin.captcha.turnstile.4": "Secret Key в env TURNSTILE_SECRET_KEY и перезапустите",
"admin.captcha.turnstile.5": "Выберите provider = turnstile",
"admin.captcha.hcaptcha.1": "Откройте hCaptcha dashboard",
"admin.captcha.hcaptcha.2": "Новый сайт → Sitekey сюда",
"admin.captcha.hcaptcha.3": "Secret в env HCAPTCHA_SECRET_KEY, перезапуск",
"admin.captcha.hcaptcha.4": "Выберите provider = hcaptcha",
"admin.settings.save": "Сохранить",
"admin.danger.title": "Опасная зона",
"admin.danger.hint": "Принудительно удаляет все wraps из БД и все ciphertext-объекты в MinIO (wraps/). Аудит сохраняется. Отменить нельзя.",
"admin.danger.confirmLabel": "Введите PURGE для подтверждения",
"admin.danger.submit": "Очистить все wraps и файлы",
"admin.danger.confirmDialog": "Удалить ВСЕ wraps и файлы? Это нельзя отменить.",
"admin.danger.confirmTitle": "Подтверждение очистки",
"admin.danger.confirmOk": "Удалить всё",
"modal.confirm.title": "Подтверждение",
"modal.confirm.ok": "Подтвердить",
"modal.confirm.cancel": "Отмена",
"admin.audit.eventType": "Тип события",
"admin.audit.wrapId": "Wrap ID",
"admin.audit.filter": "Фильтр",
"admin.audit.allEvents": "Все события",
"admin.audit.shown": "событий показано",
"admin.audit.of": "из",
"admin.audit.events": "событий",
"admin.audit.page": "Страница",
"admin.audit.prev": "Назад",
"admin.audit.next": "Далее",
"admin.audit.col.time": "Время",
"admin.audit.col.event": "Событие",
"admin.audit.col.ok": "OK",
"admin.audit.col.wrap": "Wrap",
"admin.audit.col.details": "Детали",
"admin.audit.yes": "да",
"admin.audit.no": "нет",
"admin.audit.empty": "Нет событий",
},
};
const listeners = [];
function current() {
const stored = localStorage.getItem(KEY);
if (stored === "en" || stored === "ru") return stored;
return "ru";
}
function t(key, vars) {
const lang = current();
let text = (dict[lang] && dict[lang][key]) || dict.en[key] || key;
if (vars) {
Object.entries(vars).forEach(([k, v]) => {
text = text.replaceAll(`{${k}}`, String(v));
});
}
return text;
}
function locale() {
return current() === "ru" ? "ru-RU" : "en-GB";
}
function formatExpires(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
const datetime = new Intl.DateTimeFormat(locale(), {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(d);
const diffMs = d.getTime() - Date.now();
let relative = t("relative.soon");
if (diffMs > 0) {
const mins = Math.round(diffMs / 60000);
if (mins < 60) relative = t("relative.inMinutes", { n: Math.max(1, mins) });
else if (mins < 60 * 48) relative = t("relative.inHours", { n: Math.round(mins / 60) });
else relative = t("relative.inDays", { n: Math.round(mins / (60 * 24)) });
}
return t("create.expires", { datetime, relative });
}
function apply() {
const lang = current();
document.documentElement.lang = lang;
const label = document.getElementById("lang-label");
if (label) label.textContent = lang.toUpperCase();
document.querySelectorAll("[data-i18n]").forEach((el) => {
el.textContent = t(el.getAttribute("data-i18n"));
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
el.setAttribute("placeholder", t(el.getAttribute("data-i18n-placeholder")));
});
document.querySelectorAll("[data-i18n-tooltip]").forEach((el) => {
el.textContent = t(el.getAttribute("data-i18n-tooltip"));
});
listeners.forEach((fn) => {
try {
fn(lang);
} catch {
/* ignore */
}
});
}
function onChange(fn) {
listeners.push(fn);
}
function toggle() {
const next = current() === "en" ? "ru" : "en";
localStorage.setItem(KEY, next);
apply();
}
document.addEventListener("DOMContentLoaded", () => {
apply();
const btn = document.getElementById("lang-toggle");
if (btn) btn.addEventListener("click", toggle);
});
window.WrappedI18n = { t, apply, current, locale, formatExpires, onChange };
})();
+86
View File
@@ -0,0 +1,86 @@
(() => {
let root = null;
let resolveFn = null;
function ensure() {
if (root) return root;
root = document.createElement("div");
root.id = "confirm-modal";
root.className = "modal-root hidden";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.innerHTML = `
<div class="modal-backdrop" data-modal-cancel></div>
<div class="modal-panel" role="document">
<h2 class="modal-title" id="confirm-modal-title"></h2>
<p class="modal-message" id="confirm-modal-message"></p>
<div class="modal-actions">
<button type="button" class="btn" data-modal-cancel id="confirm-modal-cancel"></button>
<button type="button" class="btn danger" data-modal-ok id="confirm-modal-ok"></button>
</div>
</div>
`;
document.body.appendChild(root);
root.addEventListener("click", (e) => {
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest("[data-modal-cancel]")) finish(false);
if (t.closest("[data-modal-ok]")) finish(true);
});
document.addEventListener("keydown", (e) => {
if (!root || root.classList.contains("hidden")) return;
if (e.key === "Escape") {
e.preventDefault();
finish(false);
}
});
return root;
}
function finish(ok) {
if (!root || root.classList.contains("hidden")) return;
root.classList.add("hidden");
document.body.classList.remove("modal-open");
const fn = resolveFn;
resolveFn = null;
if (fn) fn(ok);
}
function t(key, fallback) {
return window.WrappedI18n?.t(key) || fallback;
}
/**
* @param {{ title?: string, message: string, confirmLabel?: string, cancelLabel?: string, danger?: boolean }} opts
* @returns {Promise<boolean>}
*/
function confirm(opts) {
const el = ensure();
const title = opts.title || t("modal.confirm.title", "Confirm");
const message = opts.message || "";
const confirmLabel = opts.confirmLabel || t("modal.confirm.ok", "Confirm");
const cancelLabel = opts.cancelLabel || t("modal.confirm.cancel", "Cancel");
const danger = opts.danger !== false;
el.querySelector("#confirm-modal-title").textContent = title;
el.querySelector("#confirm-modal-message").textContent = message;
const okBtn = el.querySelector("#confirm-modal-ok");
const cancelBtn = el.querySelector("#confirm-modal-cancel");
okBtn.textContent = confirmLabel;
cancelBtn.textContent = cancelLabel;
okBtn.className = danger ? "btn danger" : "btn primary";
el.classList.remove("hidden");
document.body.classList.add("modal-open");
okBtn.focus();
return new Promise((resolve) => {
resolveFn = resolve;
});
}
window.WrappedModal = { confirm };
})();
+30
View File
@@ -0,0 +1,30 @@
(() => {
const KEY = "wrapped.theme";
function preferred() {
return localStorage.getItem(KEY) || "dark";
}
function apply(theme) {
document.documentElement.setAttribute("data-theme", theme);
const sun = document.getElementById("theme-icon-sun");
const moon = document.getElementById("theme-icon-moon");
if (sun && moon) {
sun.classList.toggle("hidden", theme !== "light");
moon.classList.toggle("hidden", theme !== "dark");
}
}
function toggle() {
const next = preferred() === "dark" ? "light" : "dark";
localStorage.setItem(KEY, next);
apply(next);
}
apply(preferred());
document.addEventListener("DOMContentLoaded", () => {
apply(preferred());
const btn = document.getElementById("theme-toggle");
if (btn) btn.addEventListener("click", toggle);
});
})();
+188
View File
@@ -0,0 +1,188 @@
(() => {
const t = (k) => window.WrappedI18n.t(k);
let settings = null;
const els = {
token: document.getElementById("token-input"),
password: document.getElementById("unwrap-password"),
btn: document.getElementById("unwrap-btn"),
error: document.getElementById("form-error"),
form: document.getElementById("unwrap-form"),
result: document.getElementById("result-panel"),
items: document.getElementById("items"),
captchaSlot: document.getElementById("captcha-slot"),
};
function showError(msg) {
els.error.textContent = msg;
els.error.classList.remove("hidden");
}
function clearError() {
els.error.classList.add("hidden");
}
function loadCaptcha() {
els.captchaSlot.innerHTML = "";
const provider = settings.captcha_provider;
if (provider === "turnstile" && settings.turnstile_site_key) {
const div = document.createElement("div");
div.className = "cf-turnstile";
div.dataset.sitekey = settings.turnstile_site_key;
els.captchaSlot.appendChild(div);
const s = document.createElement("script");
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
s.async = true;
document.body.appendChild(s);
} else if (provider === "hcaptcha" && settings.hcaptcha_site_key) {
const div = document.createElement("div");
div.className = "h-captcha";
div.dataset.sitekey = settings.hcaptcha_site_key;
els.captchaSlot.appendChild(div);
const s = document.createElement("script");
s.src = "https://js.hcaptcha.com/1/api.js";
s.async = true;
document.body.appendChild(s);
}
}
function captchaToken() {
if (settings.captcha_provider === "turnstile" && window.turnstile) {
return window.turnstile.getResponse?.() || "";
}
if (settings.captcha_provider === "hcaptcha" && window.hcaptcha) {
return window.hcaptcha.getResponse?.() || "";
}
return "";
}
function resolveKey(parsed) {
if (parsed.key) return parsed.key;
if (location.hash && location.hash.length > 1) return location.hash.slice(1);
return null;
}
function downloadBlob(name, blob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
function renderPackage(pack) {
els.items.innerHTML = "";
for (const item of pack.items || []) {
const card = document.createElement("div");
card.className = "item-card";
if (item.type === "text") {
card.innerHTML = `<div><strong>text</strong> · <span class="mono">${item.language || "plaintext"}</span></div>`;
const pre = document.createElement("pre");
pre.textContent = item.content || "";
card.appendChild(pre);
const btn = document.createElement("button");
btn.className = "btn";
btn.type = "button";
btn.textContent = t("common.download");
btn.addEventListener("click", () => {
downloadBlob(
`wrapped-${item.language || "text"}.txt`,
new Blob([item.content || ""], { type: "text/plain" })
);
});
card.appendChild(btn);
} else if (item.type === "file") {
const bytes = window.WrappedCrypto.base64ToBytes(item.data_b64);
const blob = new Blob([bytes], { type: item.mime || "application/octet-stream" });
card.innerHTML = `<div><strong>${item.name}</strong> · <span class="mono">${item.mime}</span> · ${bytes.length} B</div>`;
if ((item.mime || "").startsWith("image/")) {
const img = document.createElement("img");
img.alt = item.name;
img.src = URL.createObjectURL(blob);
card.appendChild(img);
}
const btn = document.createElement("button");
btn.className = "btn";
btn.type = "button";
btn.textContent = t("common.download");
btn.style.marginTop = "0.6rem";
btn.addEventListener("click", () => downloadBlob(item.name || "file", blob));
card.appendChild(btn);
}
els.items.appendChild(card);
}
}
els.btn.addEventListener("click", async () => {
clearError();
const parsed = window.WrappedCrypto.parseToken(els.token.value);
if (!parsed) {
showError(t("unwrap.unavailable"));
return;
}
const key = resolveKey(parsed);
if (!key) {
showError(t("unwrap.needKey"));
return;
}
els.btn.disabled = true;
try {
const resp = await fetch(`/api/v1/wraps/${encodeURIComponent(parsed.wrapId)}/unwrap`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
password: els.password.value || null,
captcha_token: captchaToken() || null,
}),
});
if (resp.status === 403) {
showError(t("unwrap.badPassword"));
return;
}
if (!resp.ok) {
showError(t("unwrap.unavailable"));
return;
}
const data = await resp.json();
const ciphertext = window.WrappedCrypto.base64ToBytes(data.ciphertext_b64);
let pack;
try {
pack = await window.WrappedCrypto.decryptPackage(
ciphertext,
key,
els.password.value || null
);
} catch (err) {
if (err.message === "bad_password" || err.message === "password_required") {
showError(t("unwrap.badPassword"));
return;
}
showError(t("common.error"));
return;
}
renderPackage(pack);
els.form.classList.add("hidden");
els.result.classList.remove("hidden");
history.replaceState(null, "", location.pathname);
} catch {
showError(t("common.error"));
} finally {
els.btn.disabled = false;
}
});
async function init() {
const resp = await fetch("/api/v1/settings");
settings = await resp.json();
loadCaptcha();
// Prefill from path + hash
const pathMatch = location.pathname.match(/\/w\/([A-Za-z0-9]+)/);
if (pathMatch && !els.token.value) {
els.token.value = pathMatch[1];
}
}
init().catch(() => showError(t("common.error")));
})();
+112
View File
@@ -0,0 +1,112 @@
{% extends "admin_base.html" %}
{% block admin_content %}
<section class="audit-page">
<form class="audit-filters" method="get" action="/admin/audit">
<input type="hidden" name="page" value="1" />
<label class="audit-field">
<span data-i18n="admin.audit.eventType">Event type</span>
<select name="event_type" id="audit-event-type">
<option value="" {% if not event_type %}selected{% endif %} data-i18n="admin.audit.allEvents">All events</option>
{% for t in event_types %}
<option value="{{ t }}" {% if event_type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</label>
<label class="audit-field audit-field-grow">
<span data-i18n="admin.audit.wrapId">Wrap ID</span>
<input name="wrap_id" value="{{ wrap_id }}" class="mono" placeholder="…" autocomplete="off" />
</label>
<button class="btn primary audit-filter-btn" type="submit" data-i18n="admin.audit.filter">Filter</button>
</form>
<div class="audit-meta">
{% if total %}
<span class="audit-count">{{ from_idx }}{{ to_idx }}</span>
<span data-i18n="admin.audit.of">of</span>
<span class="audit-count">{{ total }}</span>
{% else %}
<span class="audit-count">0</span>
{% endif %}
<span data-i18n="admin.audit.events">events</span>
</div>
<div class="audit-table-wrap">
<table class="audit-table">
<thead>
<tr>
<th data-i18n="admin.audit.col.time">Time</th>
<th data-i18n="admin.audit.col.event">Event</th>
<th data-i18n="admin.audit.col.ok">OK</th>
<th data-i18n="admin.audit.col.wrap">Wrap</th>
<th>IP</th>
<th>UA</th>
<th data-i18n="admin.audit.col.details">Details</th>
</tr>
</thead>
<tbody>
{% for e in events %}
<tr class="{% if not e.success %}audit-row-fail{% endif %}">
<td class="mono audit-time">{{ e.created_at }}</td>
<td><span class="audit-event-pill">{{ e.event_type }}</span></td>
<td>
{% if e.success %}
<span class="audit-status ok"><i class="fa-solid fa-check" aria-hidden="true"></i> <span data-i18n="admin.audit.yes">yes</span></span>
{% else %}
<span class="audit-status fail"><i class="fa-solid fa-xmark" aria-hidden="true"></i> <span data-i18n="admin.audit.no">no</span></span>
{% endif %}
</td>
<td class="mono">{{ e.wrap_id or "—" }}</td>
<td class="mono">{{ e.ip or "—" }}</td>
<td class="ua" title="{{ e.user_agent or '' }}">{{ e.user_agent or "—" }}</td>
<td class="audit-details"><code>{{ e.details }}</code></td>
</tr>
{% else %}
<tr>
<td colspan="7" class="audit-empty" data-i18n="admin.audit.empty">No events</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
<nav class="pagination" aria-label="Pagination">
{% if has_prev %}
<a class="btn" href="/admin/audit?page={{ page - 1 }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span data-i18n="admin.audit.prev">Prev</span>
</a>
{% else %}
<span class="btn" aria-disabled="true" disabled>
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span data-i18n="admin.audit.prev">Prev</span>
</span>
{% endif %}
<span class="pagination-status">
<span data-i18n="admin.audit.page">Page</span>
{{ page }}
<span data-i18n="admin.audit.of">of</span>
{{ pages }}
</span>
{% if has_next %}
<a class="btn" href="/admin/audit?page={{ page + 1 }}&amp;event_type={{ event_type|urlencode }}&amp;wrap_id={{ wrap_id|urlencode }}">
<span data-i18n="admin.audit.next">Next</span>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</a>
{% else %}
<span class="btn" aria-disabled="true" disabled>
<span data-i18n="admin.audit.next">Next</span>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</span>
{% endif %}
</nav>
{% endif %}
</section>
<script>
document.getElementById("audit-event-type")?.addEventListener("change", (e) => {
e.target.form?.requestSubmit();
});
</script>
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title }} · Wrapped Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body class="admin-body">
<div class="bg-grid" aria-hidden="true"></div>
<header class="admin-top">
<a class="brand" href="/admin">
<span class="brand-mark" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i>
</span>
<span class="brand-text">
<span class="brand-name">Wrapped</span>
<span class="brand-tagline" data-i18n="admin.brand.tagline">Admin console</span>
</span>
</a>
<div class="admin-top-right">
<nav class="admin-nav" aria-label="Admin">
<a href="/admin/settings" class="admin-nav-link {% if active == 'settings' %}active{% endif %}">
<i class="fa-solid fa-sliders" aria-hidden="true"></i>
<span data-i18n="admin.nav.settings">Settings</span>
</a>
<a href="/admin/audit" class="admin-nav-link {% if active == 'audit' %}active{% endif %}">
<i class="fa-solid fa-list" aria-hidden="true"></i>
<span data-i18n="admin.nav.audit">Audit</span>
</a>
<a href="/admin/danger" class="admin-nav-link admin-nav-danger {% if active == 'danger' %}active{% endif %}">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
<span data-i18n="admin.nav.danger">Danger</span>
</a>
<a href="/" class="admin-nav-link" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span data-i18n="admin.nav.site">Site</span>
</a>
<form method="post" action="/admin/logout" class="inline">
<button class="admin-nav-link admin-nav-logout" type="submit">
<i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i>
<span data-i18n="admin.nav.logout">Logout</span>
</button>
</form>
</nav>
<div class="top-actions">
<button type="button" class="icon-btn" id="lang-toggle" title="Language" aria-label="Language">
<span id="lang-label">RU</span>
</button>
<button type="button" class="icon-btn" id="theme-toggle" title="Theme" aria-label="Theme">
<svg id="theme-icon-sun" class="icon hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg id="theme-icon-moon" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 14.5A8.5 8.5 0 1 1 9.5 3a7 7 0 0 0 11.5 11.5z"/></svg>
</button>
</div>
</div>
</header>
<main class="admin-shell">
<h1 data-i18n="{% if active == 'audit' %}admin.audit.title{% elif active == 'danger' %}admin.danger.title{% else %}admin.settings.title{% endif %}">{{ title }}</h1>
{% block admin_content %}{% endblock %}
</main>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/modal.js"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{% extends "admin_base.html" %}
{% block admin_content %}
{% if request.query_params.get('purged') %}
<p class="ok">
<span data-i18n="admin.flash.purgedPrefix">Purged</span>
{{ request.query_params.get('wraps', '0') }}
<span data-i18n="admin.flash.purgedWraps">wrap(s) and</span>
{{ request.query_params.get('objects', '0') }}
<span data-i18n="admin.flash.purgedObjects">object(s).</span>
</p>
{% endif %}
{% if request.query_params.get('purge') == 'confirm' %}
<p class="error" data-i18n="admin.flash.purgeConfirm">Type PURGE to confirm forced cleanup.</p>
{% endif %}
{% if request.query_params.get('purge') == 'error' %}
<p class="error" data-i18n="admin.flash.purgeError">Purge failed. Check logs / MinIO connectivity.</p>
{% endif %}
<section class="admin-section danger-zone danger-page">
<p class="hint" data-i18n="admin.danger.hint">Force-delete all wraps from the database and all ciphertext objects in MinIO (wraps/). Audit log is kept. This cannot be undone.</p>
<form method="post" action="/admin/purge" class="purge-form" id="purge-form">
<label>
<span data-i18n="admin.danger.confirmLabel">Type PURGE to confirm</span>
<input name="confirm" type="text" autocomplete="off" placeholder="PURGE" required />
</label>
<button class="btn danger" type="submit" data-i18n="admin.danger.submit">Clear all wraps &amp; files</button>
</form>
</section>
<script>
document.getElementById("purge-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.target;
const ok = await window.WrappedModal.confirm({
title: window.WrappedI18n?.t("admin.danger.confirmTitle") || "Danger zone",
message: window.WrappedI18n?.t("admin.danger.confirmDialog")
|| "Delete ALL wraps and files? This cannot be undone.",
confirmLabel: window.WrappedI18n?.t("admin.danger.confirmOk") || "Delete everything",
cancelLabel: window.WrappedI18n?.t("modal.confirm.cancel") || "Cancel",
danger: true,
});
if (ok) form.submit();
});
</script>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title }} · Wrapped</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body class="admin-body">
<div class="bg-grid" aria-hidden="true"></div>
<div class="top-actions admin-login-toggles">
<button type="button" class="icon-btn" id="lang-toggle" title="Language" aria-label="Language">
<span id="lang-label">RU</span>
</button>
<button type="button" class="icon-btn" id="theme-toggle" title="Theme" aria-label="Theme">
<svg id="theme-icon-sun" class="icon hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg id="theme-icon-moon" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 14.5A8.5 8.5 0 1 1 9.5 3a7 7 0 0 0 11.5 11.5z"/></svg>
</button>
</div>
<main class="admin-login-shell">
<form class="admin-card login-card" method="post" action="/admin/login">
<div class="login-head">
<span class="brand-mark" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i>
</span>
<div>
<div class="brand-name">Wrapped</div>
<div class="login-sub" data-i18n="admin.login.sub">Admin</div>
</div>
</div>
{% if error %}<p class="error login-error" {% if error_key %}data-i18n="{{ error_key }}"{% endif %}>{{ error }}</p>{% endif %}
<label>
<span data-i18n="admin.login.username">Username</span>
<input name="username" autocomplete="username" required autofocus />
</label>
<label>
<span data-i18n="admin.login.password">Password</span>
<input name="password" type="password" autocomplete="current-password" required />
</label>
<button class="btn primary" type="submit" data-i18n="admin.login.submit">Sign in</button>
</form>
</main>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
{% extends "admin_base.html" %}
{% block admin_content %}
{% if request.query_params.get('saved') %}
<p class="ok" data-i18n="admin.flash.saved">Settings saved.</p>
{% endif %}
<div class="admin-tabs" id="admin-tabs">
<div class="admin-tablist" role="tablist">
<button type="button" class="admin-tab active" role="tab" data-tab="limits" data-i18n="admin.tab.limits">Limits</button>
<button type="button" class="admin-tab" role="tab" data-tab="rate" data-i18n="admin.tab.rate">Rate limits</button>
<button type="button" class="admin-tab" role="tab" data-tab="mime" data-i18n="admin.tab.mime">MIME</button>
<button type="button" class="admin-tab" role="tab" data-tab="password" data-i18n="admin.tab.password">Password</button>
<button type="button" class="admin-tab" role="tab" data-tab="captcha" data-i18n="admin.tab.captcha">CAPTCHA</button>
</div>
<form method="post" action="/admin/settings" class="admin-form">
<section class="admin-tabpanel active" data-panel="limits" role="tabpanel">
<h2 data-i18n="admin.limits.title">Limits</h2>
<div class="grid-2">
<label>
<span data-i18n="admin.limits.maxUpload">Max upload (MB)</span>
<input name="max_upload_mb" type="number" min="1" step="1"
value="{{ (settings.max_upload_bytes / 1048576)|round|int }}" />
</label>
<label>
<span data-i18n="admin.limits.defaultTtl">Default TTL (hours)</span>
<input name="default_ttl_hours" type="number" min="1" step="1"
value="{{ (settings.default_ttl_seconds / 3600)|round|int }}" />
</label>
<label>
<span data-i18n="admin.limits.maxTtl">Max TTL (hours)</span>
<input name="max_ttl_hours" type="number" min="1" step="1"
value="{{ (settings.max_ttl_seconds / 3600)|round|int }}" />
</label>
<label>
<span data-i18n="admin.limits.auditRetention">Audit retention (days)</span>
<input name="audit_retention_days" type="number" value="{{ settings.audit_retention_days }}" />
</label>
</div>
</section>
<section class="admin-tabpanel" data-panel="rate" role="tabpanel" hidden>
<h2 data-i18n="admin.rate.title">Rate limits (per IP / minute)</h2>
<div class="grid-3">
<label>
<span data-i18n="admin.rate.create">Create</span>
<input name="rate_limit_create_per_minute" type="number" value="{{ settings.rate_limit_create_per_minute }}" />
</label>
<label>
<span data-i18n="admin.rate.unwrap">Unwrap</span>
<input name="rate_limit_unwrap_per_minute" type="number" value="{{ settings.rate_limit_unwrap_per_minute }}" />
</label>
<label>
<span data-i18n="admin.rate.adminLogin">Admin login</span>
<input name="rate_limit_admin_login_per_minute" type="number" value="{{ settings.rate_limit_admin_login_per_minute }}" />
</label>
</div>
</section>
<section class="admin-tabpanel" data-panel="mime" role="tabpanel" hidden>
<h2 data-i18n="admin.mime.title">MIME allowlist</h2>
<p class="hint" data-i18n="admin.mime.hint">One pattern per line. Supports exact types and wildcards like image/*.</p>
<textarea name="mime_allowlist" rows="14">{% for m in settings.mime_allowlist %}{{ m }}
{% endfor %}</textarea>
<details class="helper">
<summary data-i18n="admin.mime.examples">Examples / suggested defaults</summary>
<pre class="mono">{% for m in mime_examples %}{{ m }}
{% endfor %}</pre>
</details>
</section>
<section class="admin-tabpanel" data-panel="password" role="tabpanel" hidden>
<h2 data-i18n="admin.passwordMode.title">Password mode</h2>
{% for mode in password_modes %}
<label class="radio-block">
<input type="radio" name="password_mode" value="{{ mode.value }}" {% if settings.password_mode == mode %}checked{% endif %} />
<span>
<strong>{{ mode.value }}</strong>
<small data-i18n="admin.passwordMode.{{ mode.value }}">{{ password_mode_help[mode.value] }}</small>
</span>
</label>
{% endfor %}
</section>
<section class="admin-tabpanel" data-panel="captcha" role="tabpanel" hidden>
<h2 data-i18n="admin.captcha.title">CAPTCHA</h2>
<label>
<span data-i18n="admin.captcha.provider">Provider</span>
<select name="captcha_provider">
{% for p in captcha_providers %}
<option value="{{ p.value }}" {% if settings.captcha_provider == p %}selected{% endif %}>{{ p.value }}</option>
{% endfor %}
</select>
</label>
<div class="grid-2">
<label>
<span data-i18n="admin.captcha.turnstileSite">Turnstile site key</span>
<input name="turnstile_site_key" value="{{ settings.turnstile_site_key }}" />
</label>
<label>
<span data-i18n="admin.captcha.hcaptchaSite">hCaptcha site key</span>
<input name="hcaptcha_site_key" value="{{ settings.hcaptcha_site_key }}" />
</label>
</div>
<p class="hint">
<span data-i18n="admin.captcha.secrets">Secrets:</span>
Turnstile {% if turnstile_secret_set %}<span class="ok" data-i18n="admin.captcha.set">set</span>{% else %}<span class="error" data-i18n="admin.captcha.missing">missing</span>{% endif %}
· hCaptcha {% if hcaptcha_secret_set %}<span class="ok" data-i18n="admin.captcha.set">set</span>{% else %}<span class="error" data-i18n="admin.captcha.missing">missing</span>{% endif %}
<span data-i18n="admin.captcha.viaEnv">(via env TURNSTILE_SECRET_KEY / HCAPTCHA_SECRET_KEY)</span>
</p>
<details class="helper" open>
<summary data-i18n="admin.captcha.helperTitle">How to get CAPTCHA tokens</summary>
<div class="helper-body">
<h3>Cloudflare Turnstile</h3>
<ol>
<li data-i18n="admin.captcha.turnstile.1">Open Cloudflare Turnstile dashboard</li>
<li data-i18n="admin.captcha.turnstile.2">Create a site widget for your domain (use localhost for dev)</li>
<li data-i18n="admin.captcha.turnstile.3">Copy Site Key into the field above</li>
<li data-i18n="admin.captcha.turnstile.4">Copy Secret Key into env TURNSTILE_SECRET_KEY and restart</li>
<li data-i18n="admin.captcha.turnstile.5">Set provider to turnstile</li>
</ol>
<h3>hCaptcha</h3>
<ol>
<li data-i18n="admin.captcha.hcaptcha.1">Open hCaptcha dashboard</li>
<li data-i18n="admin.captcha.hcaptcha.2">New site → copy Sitekey here</li>
<li data-i18n="admin.captcha.hcaptcha.3">Secret into env HCAPTCHA_SECRET_KEY, restart</li>
<li data-i18n="admin.captcha.hcaptcha.4">Set provider to hcaptcha</li>
</ol>
</div>
</details>
</section>
<div class="admin-tab-actions" id="settings-save-bar">
<button class="btn primary" type="submit" data-i18n="admin.settings.save">Save settings</button>
</div>
</form>
</div>
<script src="/static/js/admin-tabs.js"></script>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{{ title or "Wrapped" }}{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/static/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
{% block head %}{% endblock %}
</head>
<body>
<div class="bg-grid" aria-hidden="true"></div>
<header class="topbar">
<a class="brand" href="/">
<span class="brand-mark" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i>
</span>
<span class="brand-text">
<span class="brand-name">Wrapped</span>
<span class="brand-tagline" data-i18n="brand.tagline">Упакуй. Отправь. Исчезни.</span>
</span>
</a>
<div class="top-actions">
<button type="button" class="icon-btn" id="lang-toggle" title="Language" aria-label="Language">
<span id="lang-label">RU</span>
</button>
<button type="button" class="icon-btn" id="theme-toggle" title="Theme" aria-label="Theme">
<svg id="theme-icon-sun" class="icon hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg id="theme-icon-moon" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 14.5A8.5 8.5 0 1 1 9.5 3a7 7 0 0 0 11.5 11.5z"/></svg>
</button>
</div>
</header>
<main class="shell">
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<ul class="footer-pillars">
<li>
<i class="fa-solid fa-user-secret" aria-hidden="true"></i>
<span>
<strong data-i18n="footer.pillar.zk">Zero-knowledge</strong>
<small data-i18n="footer.pillar.zk.hint">Сервер не видит содержимое</small>
</span>
</li>
<li>
<i class="fa-solid fa-fire" aria-hidden="true"></i>
<span>
<strong data-i18n="footer.pillar.once">Одноразово</strong>
<small data-i18n="footer.pillar.once.hint">После открытия — удаление</small>
</span>
</li>
<li>
<i class="fa-solid fa-lock" aria-hidden="true"></i>
<span>
<strong data-i18n="footer.pillar.encrypted">Зашифровано</strong>
<small data-i18n="footer.pillar.encrypted.hint">Ключ только в вашей ссылке</small>
</span>
</li>
</ul>
<p class="footer-copy">
© <span data-i18n="footer.copy.author">Сергей Антропов</span>
· <a href="https://devops.org.ru" target="_blank" rel="noopener noreferrer">devops.org.ru</a>
</p>
</footer>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block content %}
<section class="hero-panel create-panel">
<div class="composer">
<div class="field-row">
<label for="text-language" data-i18n="create.language">Language</label>
<select id="text-language">
<option value="plaintext" data-i18n="lang.plaintext">Обычный текст</option>
<option value="markdown" data-i18n="lang.markdown">Markdown</option>
<option value="json" data-i18n="lang.json">JSON</option>
<option value="yaml" data-i18n="lang.yaml">YAML</option>
<option value="python" data-i18n="lang.python">Python</option>
<option value="javascript" data-i18n="lang.javascript">JavaScript</option>
<option value="typescript" data-i18n="lang.typescript">TypeScript</option>
<option value="go" data-i18n="lang.go">Go</option>
<option value="rust" data-i18n="lang.rust">Rust</option>
<option value="sql" data-i18n="lang.sql">SQL</option>
<option value="bash" data-i18n="lang.bash">Bash</option>
<option value="html" data-i18n="lang.html">HTML</option>
<option value="css" data-i18n="lang.css">CSS</option>
</select>
</div>
<label class="sr-only" for="payload-text" data-i18n="create.text">Text</label>
<textarea id="payload-text" class="code-input" spellcheck="false" data-i18n-placeholder="create.textPlaceholder" placeholder="Paste secrets, configs, notes…"></textarea>
<div id="dropzone" class="dropzone" tabindex="0">
<p data-i18n="create.drop">Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)</p>
<input id="file-input" type="file" multiple hidden />
</div>
<ul id="file-list" class="file-list"></ul>
<div class="grid-2">
<div class="field">
<label for="ttl-seconds" data-i18n="create.ttl">Time to live</label>
<select id="ttl-seconds"></select>
</div>
<div class="field">
<label for="password" data-i18n="create.password">Password (optional)</label>
<div class="input-with-action">
<input id="password" type="password" autocomplete="new-password" data-i18n-placeholder="create.passwordPlaceholder" placeholder="Extra unlock secret" />
<button type="button" class="field-icon-btn" id="generate-password" aria-describedby="generate-password-tip">
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
<span class="sr-only" data-i18n="create.passwordGenerate">Generate password</span>
<span class="ui-tooltip" id="generate-password-tip" role="tooltip" data-i18n="create.passwordGenerateTip">Generate a strong password</span>
</button>
</div>
</div>
</div>
<div id="captcha-slot" class="captcha-slot"></div>
<p id="form-error" class="error hidden"></p>
<div class="actions actions-center">
<button type="button" id="wrap-btn" class="btn primary">
<span data-i18n="create.submit">Create wrapped token</span>
</button>
</div>
</div>
<div id="success-panel" class="success-panel hidden">
<h2 data-i18n="create.successTitle">Token issued</h2>
<p class="warn" data-i18n="create.successWarn">One-time unwrap. After someone opens it, ciphertext is destroyed on the server.</p>
<label data-i18n="create.shareLink">Share link</label>
<div class="copy-row">
<input id="share-link" readonly />
<button type="button" class="btn" id="copy-link" data-i18n="common.copy">Copy</button>
</div>
<label data-i18n="create.token">Wrapped token</label>
<div class="copy-row">
<input id="share-token" class="mono" readonly />
<button type="button" class="btn" id="copy-token" data-i18n="common.copy">Copy</button>
</div>
<p class="meta" id="expires-meta"></p>
<button type="button" class="btn ghost" id="create-another" data-i18n="create.another">Create another</button>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/js/crypto.js"></script>
<script src="/static/js/create.js"></script>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block content %}
<section class="hero-panel unwrap-panel">
<div class="panel-head">
<p class="eyebrow" data-i18n="unwrap.eyebrow">Unwrap</p>
<h1 data-i18n="unwrap.title">Reveal once</h1>
<p class="lede" data-i18n="unwrap.lede">Paste a wrapped token or open a share link. Ciphertext is fetched once and deleted from the server.</p>
</div>
<div class="composer" id="unwrap-form">
<div class="field">
<label for="token-input" data-i18n="unwrap.token">Wrapped token or ID</label>
<input id="token-input" class="mono" value="{{ wrap_id }}" data-i18n-placeholder="unwrap.tokenPlaceholder" placeholder="wrapped_v1.… или ID" />
</div>
<div class="field">
<label for="unwrap-password" data-i18n="unwrap.password">Password (if set)</label>
<input id="unwrap-password" type="password" autocomplete="current-password" />
</div>
<div id="captcha-slot" class="captcha-slot"></div>
<p id="form-error" class="error hidden"></p>
<button type="button" id="unwrap-btn" class="btn primary" data-i18n="unwrap.submit">Unwrap</button>
</div>
<div id="result-panel" class="result-panel hidden">
<p class="warn" data-i18n="unwrap.destroyed">Server copy destroyed. Preview lives only in this browser session.</p>
<div id="items" class="items"></div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/js/crypto.js"></script>
<script src="/static/js/unwrap.js"></script>
{% endblock %}