Files
ros_control/app/ui/routes.py
T
ayurishchevandClaude Opus 5.5 c4079724c5 Безопасность: проверка секретов, блокировка перебора входа, безопасные редиректы
Безопасность, пункты 1–4 ревью (docs/changes/021):
- приложение не стартует со слабыми секретами: API_TOKEN и SESSION_SECRET
  пустые, change-me или короче 32 символов, ADMIN_PASSWORD короче 12,
  невалидный SECRET_KEY; значения секретов в ошибку не попадают;
- вход в UI: 5 неверных попыток за 10 минут блокируют IP на 10 минут
  (429, пароль не проверяется); неверный пароль — 401; событие
  auth.locked пишется один раз, журнал не засыпается перебором;
- SESSION_COOKIE_SECURE — флаг Secure у cookie сессии (по умолчанию выкл.);
- _safe_next: редирект только на локальный путь (/ui/move, удаление бэкапов);
- .env.bak-* в .gitignore.

Тесты: 28 из 28. Стенд: change-me → 401, новый токен → 200, отказ старта
со слабым токеном, блокировка входа и редиректы проверены, боевые данные
не изменены. Ручная проверка UI пользователем на момент коммита не
подтверждена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:41:58 +03:00

585 lines
29 KiB
Python

"""WEB UI: Jinja2 + HTMX. Использует те же сервисы, что и JSON API."""
import json
from datetime import date
from pathlib import Path
from urllib.parse import urlencode, urlsplit
from fastapi import APIRouter, Depends, Form, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app import ids, s3, security
from app.config import get_settings
from app.ros.operations import CHANNELS, version_newer
from app.services import backups, devices, events, groups, jobs, ops, settings
from app.ui.icons import ICONS
def plural(n, one: str, few: str, many: str) -> str:
"""Русское склонение по числу: 1 файл, 2 файла, 5 файлов."""
n = abs(int(n))
if n % 10 == 1 and n % 100 != 11:
return one
if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14:
return few
return many
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
templates.env.filters["dt"] = lambda v: v.strftime("%Y-%m-%d %H:%M") if v else "—"
templates.env.filters["size"] = lambda n: f"{n / 1024 / 1024:.1f} МБ" if n >= 1024 * 1024 else f"{n / 1024:.0f} КБ"
templates.env.filters["plural"] = plural
templates.env.filters["short_id"] = ids.short_id
templates.env.filters["num"] = lambda n: f"{int(n):,}".replace(",", "\u00a0")
templates.env.filters["dts"] = lambda v: v.strftime("%Y-%m-%d %H:%M:%S") if v else "—"
# смысловая окраска типов событий (как в утверждённом макете)
_EV_KIND = {"bad": ("device.offline", "job.failed", "auth.failed", "auth.locked", "backup.failed", "journal.clear_denied",
"journal.clear_locked"),
"ok": ("device.online", "job.done", "backup.done", "auth.login"),
"run": ("job.started",),
"warn": ("backup.deleted", "device.deleted", "group.deleted", "system.migrated", "journal.cleared", "journal.rotated")}
_EV_KIND_BY_TYPE = {t: k for k, ts in _EV_KIND.items() for t in ts}
_EV_ENTITY = {"device": "Устройство", "group": "Группа", "job": "Задача", "backup": "Копия", "session": "Сессия",
"system": "Система", "journal": "Журнал"}
templates.env.globals["ev_kind"] = lambda t: _EV_KIND_BY_TYPE.get(t, "plain")
templates.env.globals["ev_entity"] = lambda t: _EV_ENTITY.get(t, t)
templates.env.globals["ICONS"] = ICONS
templates.env.globals["ros_newer"] = version_newer
class LoginRequired(Exception):
pass
async def require_login(request: Request) -> None:
user = request.session.get("user")
if not user:
raise LoginRequired()
events.set_actor(f"ui:{user}") # async: ContextVar остаётся в контексте обработчика запроса
router = APIRouter(include_in_schema=False)
def _render(request: Request, name: str, status_code: int = 200, **ctx):
return templates.TemplateResponse(request, name, ctx, status_code=status_code)
def _is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request") == "true"
def _done(request: Request, fallback: str = "/"):
"""Успех формы: из окна (HTMX) — обновить страницу, обычной формой — редирект."""
if _is_htmx(request):
return Response(headers={"HX-Refresh": "true"})
return RedirectResponse(fallback, status_code=303)
def _opt_id(value: str, prefix: str) -> str | None:
"""ID нужного типа или None («без группы», пустое значение, чужой тип, мусор)."""
value = str(value).strip()
return value if ids.is_id(value, prefix) else None
FILTER_FIELDS = ("group", "q", "status", "updates", "channel") # в форме и URL — с префиксом f_
FILTER_VALUES = {"status": ("online", "offline"), "updates": ("ros", "fw", "any", "none")}
def _flt(params) -> dict:
"""Фильтр списка устройств из query/формы (поля f_*)."""
flt = {k: str(params.get(f"f_{k}") or "").strip() for k in FILTER_FIELDS}
for k, allowed in FILTER_VALUES.items():
if flt[k] not in allowed:
flt[k] = ""
if flt["group"] not in ("", "none") and not ids.is_id(flt["group"], "grp"):
flt["group"] = ""
return flt
def _page_url(flt: dict, **override) -> str:
"""Адрес страницы устройств с фильтром (только непустые поля)."""
merged = {**flt, **override}
query = urlencode({f"f_{k}": v for k, v in merged.items() if v})
return "/?" + query if query else "/"
def _visible(flt: dict):
return devices.filter_devices(devices.list_devices(), **flt)
def _devices_ctx(flt: dict) -> dict:
everyone = devices.list_devices()
stats = {"total": len(everyone), "online": sum(d.online is True for d in everyone),
"offline": sum(d.online is False for d in everyone)}
return {"devices": devices.filter_devices(everyone, **flt), "channels": CHANNELS, "filtered": any(flt.values()),
"total": len(everyone), "stats": stats,
"group_names": {g["id"]: g["name"] for g in groups.list_groups()}}
def _tabs(flt: dict) -> list[dict]:
"""Вкладки групп: «Все», «Без группы» и каждая группа со счётчиком устройств."""
all_devices = devices.list_devices()
glist = groups.list_groups()
items = [("Все", "", len(all_devices)),
("Без группы", "none", sum(1 for d in all_devices if d.group_id is None))]
items += [(g["name"], str(g["id"]), g["device_count"]) for g in glist]
return [{"label": label, "count": n, "href": _page_url(flt, group=value), "active": flt["group"] == value}
for label, value, n in items]
# --- вход / выход ---
@router.get("/login", response_class=HTMLResponse)
def login_form(request: Request):
return _render(request, "login.html", error=None)
@router.post("/login")
def login(request: Request, username: str = Form(), password: str = Form()):
"""Вход в UI: попытки ограничены по IP клиента (не по имени — иначе одного администратора
можно заблокировать чужими неверными попытками). При блокировке пароль не проверяется."""
ip = request.client.host if request.client else "unknown"
key = f"login:{ip}"
events.set_actor("anonymous")
lock = security.lockout_remaining(key)
if lock:
# событие auth.locked уже записано в момент начала блокировки (ветка left == 0 ниже);
# повторные попытки во время блокировки в журнал не пишем — иначе перебор засыпает его и вытесняет историю ротацией
minutes = max(1, -(-lock // 60))
return _render(request, "login.html", status_code=429,
error=f"Слишком много попыток входа. Повторите через {minutes} мин.")
if not security.check_admin(username, password):
left = security.register_failure(key)
if left == 0:
events.record("auth.locked", "session", None, "Вход заблокирован: превышено число попыток входа",
data={"ip": ip, "user": username[:64]})
else:
events.record("auth.failed", "session", None, "Неудачная попытка входа в UI",
data={"ip": ip, "user": username[:64], "attempts_left": left})
return _render(request, "login.html", status_code=401, error="Неверный логин или пароль")
security.reset_failures(key)
request.session["user"] = username
events.set_actor(f"ui:{username}")
events.record("auth.login", "session", None, f"Вход в UI: {username}", data={"user": username, "ip": ip})
return RedirectResponse("/", status_code=303)
@router.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
# --- дашборд ---
@router.get("/", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dashboard(request: Request):
flt = _flt(request.query_params)
return _render(request, "dashboard.html", section="devices", jobs=jobs.list_jobs(15), flt=flt, tabs=_tabs(flt),
groups=groups.list_groups(), page_url=_page_url(flt),
flt_active=sum(1 for k in ("q", "status", "updates", "channel") if flt[k]), **_devices_ctx(flt))
@router.get("/ui/devices", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def devices_table(request: Request):
flt = _flt(request.query_params)
resp = _render(request, "_devices.html", oob=True, **_devices_ctx(flt))
if request.query_params.get("poll") != "1": # автообновление не должно засорять историю браузера
resp.headers["HX-Push-Url"] = _page_url(flt) # фильтр переживает перезагрузку страницы
return resp
@router.get("/ui/jobs", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def jobs_table(request: Request):
return _render(request, "_jobs.html", jobs=jobs.list_jobs(15))
@router.post("/ui/refresh", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def refresh(request: Request, device_ids: list[str] = Form(default=[])):
flt = _flt(await request.form())
# без выбора обновляем все устройства текущего представления (группа + фильтры)
await ops.refresh_many(device_ids or [d.id for d in _visible(flt)])
return _render(request, "_devices.html", oob=True, **_devices_ctx(flt))
@router.post("/ui/batch/{action}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def batch(request: Request, action: str, device_ids: list[str] = Form(default=[]),
channel: str = Form(default="")):
if not device_ids:
return _render(request, "_jobs.html", jobs=jobs.list_jobs(15), flash="Выберите устройства")
if action == "channel":
if channel not in CHANNELS:
raise ValueError(f"Неизвестный канал: {channel}")
jobs.start_jobs("set_channel", device_ids, {"channel": channel})
else:
jobs.start_jobs(action, device_ids)
return _render(request, "_jobs.html", jobs=jobs.list_jobs(15))
@router.post("/ui/devices/{device_id}/{action}", response_class=HTMLResponse,
dependencies=[Depends(require_login)])
async def device_action(request: Request, device_id: str, action: str, channel: str = Form(default="")):
"""Действие над одним устройством (пункты меню «⋯» в строке таблицы)."""
if action == "refresh":
await ops.refresh_status(device_id)
elif action == "channel":
await ops.set_channel(device_id, channel)
else:
jobs.start_jobs(action, [device_id])
return _render(request, "_jobs.html", jobs=jobs.list_jobs(15))
return _render(request, "_devices.html", oob=True, **_devices_ctx(_flt(await request.form())))
def _safe_next(value: str, default: str = "/", prefix: str = "/") -> str:
"""Адрес для редиректа после формы, присланный пользователем (`next`/`back`): только локальный путь,
иначе открытый редирект (в т.ч. `\\evil.com`, который браузеры трактуют как `//evil.com`)."""
value = value or ""
if (not value.startswith(prefix) or value.startswith("//")
or any(ord(ch) < 0x20 or ch in ("\\", "\x7f") for ch in value)):
return default
parts = urlsplit(value)
return value if not parts.scheme and not parts.netloc else default
@router.post("/ui/move", dependencies=[Depends(require_login)])
def move(device_ids: list[str] = Form(default=[]), group_id: str = Form(""), next: str = Form("/")):
"""Перенос отмеченных устройств в группу (обычный POST + редирект: счётчики вкладок обновляются)."""
if device_ids:
groups.move_devices(device_ids, _opt_id(group_id, "grp"))
return RedirectResponse(_safe_next(next), status_code=303)
# --- окна (dialog) и формы: одна разметка для окна и запасной страницы ---
def _device_values(device=None, **form) -> dict:
"""Значения полей формы устройства: из отправленной формы (при ошибке), из устройства или значения по умолчанию."""
if form:
return form
if device is not None:
return dict(name=device.name, host=device.host, port=device.port, username=device.username,
group_id=str(device.group_id or ""), new_group="", use_tls=device.use_tls,
verify_tls=device.verify_tls, note=device.note or "")
return dict(name="", host="", port=443, username="", group_id="", new_group="", use_tls=True, verify_tls=False, note="")
def _device_form(request: Request, device, v: dict, error: str | None = None):
ctx = dict(device=device, v=v, error=error, groups=groups.list_groups(), section="devices")
if _is_htmx(request):
return _render(request, "_device_form.html", in_modal=True, **ctx)
return _render(request, "device_form.html", in_modal=False, **ctx)
def _group_form(request: Request, group, name: str, error: str | None = None):
ctx = dict(group=group, gname=name, error=error)
return _render(request, "_group_form.html", in_modal=_is_htmx(request), **ctx)
@router.get("/ui/dialog/device", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_device_new(request: Request, group: str = ""):
v = _device_values()
v["group_id"] = group if ids.is_id(group, "grp") else ""
return _render(request, "_device_form.html", in_modal=True, device=None, v=v, error=None, groups=groups.list_groups())
@router.get("/ui/dialog/device/{device_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_device_edit(request: Request, device_id: str):
d = devices.get_device(device_id)
return _render(request, "_device_form.html", in_modal=True, device=d, v=_device_values(d), error=None,
groups=groups.list_groups())
@router.get("/ui/dialog/group", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_group_new(request: Request):
return _render(request, "_group_form.html", in_modal=True, group=None, gname="", error=None)
@router.get("/ui/dialog/group/{group_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_group_edit(request: Request, group_id: str):
g = groups.get_group(group_id)
return _render(request, "_group_form.html", in_modal=True, group=g, gname=g.name, error=None)
# --- группы ---
@router.get("/groups", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def groups_page(request: Request, error: str = ""):
everyone = devices.list_devices()
return _render(request, "groups.html", section="groups", groups=groups.list_groups(), error=error,
total=len(everyone), ungrouped=sum(1 for d in everyone if d.group_id is None))
def _group_action(request: Request, fn, *args, name: str = "", group=None):
try:
fn(*args)
except ValueError as e:
if _is_htmx(request): # ошибка показывается в окне
return _group_form(request, group, name, str(e))
return RedirectResponse("/groups?" + urlencode({"error": str(e)}), status_code=303)
return _done(request, "/groups")
@router.post("/groups/new", dependencies=[Depends(require_login)])
def group_create(request: Request, name: str = Form()):
return _group_action(request, groups.create_group, name, name=name)
@router.post("/groups/{group_id}/rename", dependencies=[Depends(require_login)])
def group_rename(request: Request, group_id: str, name: str = Form()):
return _group_action(request, groups.rename_group, group_id, name, name=name, group=groups.get_group(group_id))
@router.post("/groups/{group_id}/delete", dependencies=[Depends(require_login)])
def group_delete(request: Request, group_id: str):
return _group_action(request, groups.delete_group, group_id)
# --- управление списком устройств ---
def _group_choice(group_id: str, new_group: str) -> tuple[str | None, str | None]:
"""Выбор в списке «Группа»: id, «без группы» или «Новая группа…» (тогда нужно название)."""
if group_id == "__new__":
if not new_group.strip():
raise ValueError("Введите название новой группы")
return None, new_group.strip()
return _opt_id(group_id, "grp"), None
@router.get("/devices/new", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def device_new(request: Request, group: str = ""):
v = _device_values()
v["group_id"] = group if ids.is_id(group, "grp") else ""
return _device_form(request, None, v)
@router.post("/devices/new", dependencies=[Depends(require_login)])
async def device_create(request: Request, name: str = Form(), host: str = Form(),
port: int = Form(443), username: str = Form(), password: str = Form(),
verify_tls: bool = Form(False), use_tls: bool = Form(False),
group_id: str = Form(""), new_group: str = Form(""), note: str = Form("")):
v = dict(name=name, host=host, port=port, username=username, group_id=group_id, new_group=new_group,
use_tls=use_tls, verify_tls=verify_tls, note=note)
try:
gid, ng = _group_choice(group_id, new_group)
d = devices.create_device(name, host, port, username, password, verify_tls, use_tls, gid, ng, note)
except ValueError as e:
return _device_form(request, None, v, str(e))
await ops.refresh_status(d.id)
return _done(request)
@router.get("/devices/{device_id}/edit", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def device_edit(request: Request, device_id: str):
d = devices.get_device(device_id)
return _device_form(request, d, _device_values(d))
@router.post("/devices/{device_id}/edit", dependencies=[Depends(require_login)])
def device_update(request: Request, device_id: str, host: str = Form(), port: int = Form(443),
username: str = Form(), password: str = Form(""), verify_tls: bool = Form(False),
use_tls: bool = Form(False), group_id: str = Form(""), new_group: str = Form(""),
note: str = Form("")):
"""Имя устройства при изменении не принимается: оно задаётся только при создании."""
current = devices.get_device(device_id)
v = dict(name=current.name, host=host, port=port, username=username, group_id=group_id, new_group=new_group,
use_tls=use_tls, verify_tls=verify_tls, note=note)
try:
gid, ng = _group_choice(group_id, new_group)
devices.update_device(device_id, host=host, port=port, username=username, password=password,
verify_tls=verify_tls, use_tls=use_tls, group_id=gid, new_group=ng, note=note)
except ValueError as e:
return _device_form(request, current, v, str(e))
return _done(request)
@router.post("/devices/{device_id}/delete", dependencies=[Depends(require_login)])
def device_delete(request: Request, device_id: str):
devices.delete_device(device_id)
return _done(request)
# --- резервные копии в бакете ---
def _opt_date(value: str) -> date | None:
try:
return date.fromisoformat(value)
except ValueError:
return None
@router.get("/backups", response_class=HTMLResponse, dependencies=[Depends(require_login)])
async def backups_page(request: Request, device: str = "", group: str = "", kind: str = "",
date_from: str = "", date_to: str = "", q: str = "", deleted: int = -1, failed: int = 0,
refresh: bool = False):
kind = kind if kind in ("backup", "rsc") else ""
items, stats, error = [], {"total": 0, "size": 0}, None
try:
items, stats = await backups.search(device, group, kind, _opt_date(date_from), _opt_date(date_to), q, refresh=refresh)
except Exception as e: # noqa: BLE001 — показываем ошибку S3 на странице
error = str(e)
flt = dict(device=device, group=group, kind=kind, date_from=date_from, date_to=date_to, q=q)
non_empty = {k: v for k, v in flt.items() if v}
return _render(request, "backups.html", section="backups", items=items, stats=stats, error=error, flt=flt,
flt_active=sum(1 for v in flt.values() if v), bucket=get_settings().s3_bucket,
page_url="/backups" + ("?" + urlencode(non_empty) if non_empty else ""),
refresh_url="/backups?" + urlencode({**non_empty, "refresh": "1"}),
deleted=deleted, failed=failed,
devices=devices.list_devices(), groups=groups.list_groups())
@router.post("/backups/delete-many", dependencies=[Depends(require_login)])
async def backups_delete_many(key: list[str] = Form(default=[]), next: str = Form("/backups")):
"""Групповое удаление выбранных файлов; возврат на страницу с теми же фильтрами и итогом."""
deleted, failed = await backups.delete_many(key)
back = _safe_next(next, default="/backups", prefix="/backups")
return RedirectResponse(back + ("&" if "?" in back else "?") + urlencode({"deleted": deleted, "failed": failed}), status_code=303)
@router.get("/backups/download", dependencies=[Depends(require_login)])
def backup_download(key: str):
if not s3.key_allowed(key):
raise ValueError("Недопустимый ключ")
return RedirectResponse(s3.presign_get(key))
@router.post("/backups/delete", dependencies=[Depends(require_login)])
async def backup_delete(key: str = Form()):
"""Одиночное удаление — тот же сервис, что и групповое: метаданные и событие backup.deleted не теряются."""
deleted, failed = await backups.delete_many([key])
return RedirectResponse(f"/backups?deleted={deleted}&failed={failed}", status_code=303)
# --- журнал событий ---
EVENTS_PAGE = 100
EV_ACTORS = ("ui", "api", "poller", "system", "anonymous")
def _ev_filters(params) -> dict:
p = lambda k: str(params.get(k) or "").strip() # noqa: E731
f = dict(type=p("type"), actor=p("actor"), device=p("device"), date_from=p("date_from"), date_to=p("date_to"), q=p("q"))
if f["device"] and not ids.is_id(f["device"], "dev"):
f["device"] = ""
if f["actor"] not in EV_ACTORS:
f["actor"] = ""
return f
def _ev_query(f: dict) -> dict:
return dict(type_=f["type"] or None, actor=f["actor"] or None, device_id=f["device"] or None,
date_from=_opt_date(f["date_from"]), date_to=_opt_date(f["date_to"]), text=f["q"] or None)
def _ev_page(f: dict, before: str | None = None, shown: int = 0) -> dict:
query = _ev_query(f)
rows = events.list_events(before=before, limit=EVENTS_PAGE + 1, **query)
more = len(rows) > EVENTS_PAGE
rows = rows[:EVENTS_PAGE]
return dict(rows=rows, more=more, next_before=rows[-1].id if rows else None, total=events.count(**query),
shown=shown + len(rows), qs=urlencode({k: v for k, v in f.items() if v}))
def _rotation_text(cfg: dict) -> str:
days, rows = cfg["retention_days"], cfg["max_rows"]
if not days and not rows:
return "без ограничений"
d = f"{days} {plural(days, 'день', 'дня', 'дней')}" if days else "без ограничения по сроку"
r = f"{rows:,}".replace(",", "\u00a0") + " записей" if rows else "без ограничения по числу"
return f"{d} / {r}"
@router.get("/events", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def events_page(request: Request):
f = _ev_filters(request.query_params)
st = events.stats()
all_types = events.types()
return _render(request, "events.html", section="events", flt=f, flt_active=sum(1 for v in f.values() if v),
st=st, cfg=settings.journal(), rotation=_rotation_text(settings.journal()), oob=False,
categories=sorted({t.split(".")[0] for t in all_types}), types=all_types, actors=EV_ACTORS,
devices=devices.list_devices(), **_ev_page(f))
@router.get("/ui/events", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def events_more(request: Request, before: str = "", shown: int = 0):
"""Следующие записи журнала (кнопка «Показать ещё»): строки + обновлённая кнопка и подвал (OOB)."""
f = _ev_filters(request.query_params)
return _render(request, "_events_chunk.html", oob=True, **_ev_page(f, before=before or None, shown=shown))
def _event_ctx(event_id: str) -> dict:
ids.check(event_id, "evt")
e = events.get_event(event_id)
device = None
if e.device_id:
try:
device = devices.get_device(e.device_id)
except LookupError:
device = None
data = json.dumps(json.loads(e.data), ensure_ascii=False, indent=2) if e.data else ""
return dict(e=e, device=device, data_json=data)
@router.get("/ui/dialog/event/{event_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_event(request: Request, event_id: str):
return _render(request, "_event_dialog.html", in_modal=True, **_event_ctx(event_id))
@router.get("/events/{event_id}", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def event_page(request: Request, event_id: str):
return _render(request, "event_page.html", section="events", in_modal=False, **_event_ctx(event_id))
def _settings_dialog(request: Request, error: str | None = None, values: dict | None = None):
return _render(request, "_events_settings.html", in_modal=_is_htmx(request), error=error,
values=values or settings.journal(), st=events.stats())
@router.get("/ui/dialog/events-settings", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_events_settings(request: Request):
return _settings_dialog(request)
@router.post("/events/settings", dependencies=[Depends(require_login)])
def events_settings_save(request: Request, retention_days: str = Form(""), max_rows: str = Form("")):
try:
settings.save_journal(retention_days, max_rows)
except ValueError as e:
return _settings_dialog(request, str(e), {"retention_days": retention_days, "max_rows": max_rows})
return _done(request, "/events")
def _clear_dialog(request: Request, error: str | None = None):
user = request.session.get("user", "")
lock = security.lockout_remaining(user)
return _render(request, "_events_clear.html", in_modal=_is_htmx(request), error=error, user=user,
locked=lock > 0, minutes=max(1, -(-lock // 60)), st=events.stats())
@router.get("/ui/dialog/events-clear", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_events_clear(request: Request):
return _clear_dialog(request)
@router.post("/events/clear", dependencies=[Depends(require_login)])
def events_clear(request: Request, password: str = Form("")):
"""Очистка журнала — только через окно подтверждения паролем пользователя сессии (через API её нет)."""
if not _is_htmx(request):
raise ValueError("Очистка журнала выполняется только через окно подтверждения")
user = request.session["user"]
if security.lockout_remaining(user):
return _clear_dialog(request)
if not password:
return _clear_dialog(request, "Введите пароль")
if not security.verify_password(user, password):
left = security.register_failure(user)
events.record("journal.clear_denied", "journal", None, "Неверный пароль при очистке журнала", data={"attempts_left": left})
if left == 0:
events.record("journal.clear_locked", "journal", None, "Очистка журнала заблокирована после неверных попыток",
data={"lock_seconds": security.LOCK_S})
return _clear_dialog(request)
return _clear_dialog(request, f"Неверный пароль. Осталось попыток: {left}")
security.reset_failures(user)
events.clear(user)
return _done(request, "/events")