Улучшить unwrap, UI и админку; обновить документацию.
- Неверный пароль не сжигает wrap сразу: Argon2-гейт до consume и лимит попыток (по умолчанию 3) в настройках - Подсветка синтаксиса, автоопределение языка, спиннеры, размеры файлов, пагинация audit - make push (git, Ctrl-D) и актуальный README
This commit is contained in:
+31
-1
@@ -187,6 +187,9 @@ async def settings_save(
|
||||
"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.password_max_attempts = min(
|
||||
50, max(1, as_int("password_max_attempts", row.password_max_attempts or 3))
|
||||
)
|
||||
row.turnstile_site_key = str(form.get("turnstile_site_key") or "").strip()
|
||||
row.hcaptcha_site_key = str(form.get("hcaptcha_site_key") or "").strip()
|
||||
|
||||
@@ -274,6 +277,25 @@ async def danger_page(
|
||||
)
|
||||
|
||||
|
||||
def _audit_page_window(page: int, pages: int, radius: int = 2) -> list[int | None]:
|
||||
"""Page numbers with None as ellipsis gaps, e.g. [1, None, 4, 5, 6, None, 20]."""
|
||||
if pages <= 1:
|
||||
return [1] if pages == 1 else []
|
||||
keep = {1, pages, page}
|
||||
for i in range(page - radius, page + radius + 1):
|
||||
if 1 <= i <= pages:
|
||||
keep.add(i)
|
||||
ordered = sorted(keep)
|
||||
window: list[int | None] = []
|
||||
prev = 0
|
||||
for n in ordered:
|
||||
if prev and n - prev > 1:
|
||||
window.append(None)
|
||||
window.append(n)
|
||||
prev = n
|
||||
return window
|
||||
|
||||
|
||||
@router.get("/audit")
|
||||
async def audit_page(
|
||||
request: Request,
|
||||
@@ -284,7 +306,11 @@ async def audit_page(
|
||||
wrap_id = request.query_params.get("wrap_id") or None
|
||||
if event_type == "":
|
||||
event_type = None
|
||||
per_page = 50
|
||||
try:
|
||||
per_page = int(request.query_params.get("per_page") or "25")
|
||||
except ValueError:
|
||||
per_page = 25
|
||||
per_page = min(100, max(10, per_page))
|
||||
try:
|
||||
page = max(1, int(request.query_params.get("page") or "1"))
|
||||
except ValueError:
|
||||
@@ -317,11 +343,14 @@ async def audit_page(
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"per_page": per_page,
|
||||
"per_page_options": [10, 25, 50, 100],
|
||||
"page_window": _audit_page_window(page, pages),
|
||||
"total": total,
|
||||
"from_idx": from_idx,
|
||||
"to_idx": to_idx,
|
||||
"has_prev": page > 1,
|
||||
"has_next": page < pages,
|
||||
"show_pagination": total > 0 and pages > 1,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -340,6 +369,7 @@ async def admin_settings_api(
|
||||
"mime_allowlist": row.mime_allowlist,
|
||||
"captcha_provider": row.captcha_provider.value,
|
||||
"password_mode": row.password_mode.value,
|
||||
"password_max_attempts": row.password_max_attempts,
|
||||
"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,
|
||||
|
||||
+53
-12
@@ -7,7 +7,7 @@ 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.models import Wrap, WrapStatus
|
||||
from app.schemas import (
|
||||
PublicSettingsOut,
|
||||
UnwrapRequest,
|
||||
@@ -111,12 +111,13 @@ async def create_wrap(
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="mime_not_allowed")
|
||||
|
||||
# Always store Argon2 hash when a password is set so wrong guesses
|
||||
# are rejected *before* the one-time ciphertext is consumed.
|
||||
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)
|
||||
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"
|
||||
@@ -250,21 +251,61 @@ async def unwrap(
|
||||
)
|
||||
fail("unavailable")
|
||||
|
||||
if (
|
||||
wrap.has_password
|
||||
and wrap.password_mode == PasswordMode.server_gate
|
||||
and wrap.password_hash
|
||||
):
|
||||
if wrap.has_password and wrap.password_hash:
|
||||
max_attempts = max(1, int(settings.password_max_attempts or 3))
|
||||
if not body.password or not verify_password(wrap.password_hash, body.password):
|
||||
wrap.password_fail_count = int(wrap.password_fail_count or 0) + 1
|
||||
remaining = max(0, max_attempts - wrap.password_fail_count)
|
||||
now_fail = datetime.now(timezone.utc)
|
||||
|
||||
if wrap.password_fail_count >= max_attempts:
|
||||
wrap.status = WrapStatus.consumed
|
||||
wrap.consumed_at = now_fail
|
||||
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": "password_attempts_exceeded",
|
||||
"attempts": wrap.password_fail_count,
|
||||
"attempts_max": max_attempts,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"code": "password_locked",
|
||||
"attempts_remaining": 0,
|
||||
"attempts_max": max_attempts,
|
||||
},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await write_audit(
|
||||
db,
|
||||
event_type="wrap.unwrap",
|
||||
success=False,
|
||||
wrap_id=wrap_id,
|
||||
**meta,
|
||||
details={"reason": "bad_password"},
|
||||
details={
|
||||
"reason": "bad_password",
|
||||
"attempts": wrap.password_fail_count,
|
||||
"attempts_remaining": remaining,
|
||||
"attempts_max": max_attempts,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"code": "bad_password",
|
||||
"attempts_remaining": remaining,
|
||||
"attempts_max": max_attempts,
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="bad_password")
|
||||
|
||||
# Atomic consume
|
||||
from sqlalchemy import update
|
||||
|
||||
Reference in New Issue
Block a user