Init
This commit is contained in:
@@ -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
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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/*",
|
||||
]
|
||||
@@ -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]
|
||||
@@ -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)]
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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")));
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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")));
|
||||
})();
|
||||
@@ -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 }}&event_type={{ event_type|urlencode }}&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 }}&event_type={{ event_type|urlencode }}&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 %}
|
||||
@@ -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>
|
||||
@@ -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 & 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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user