diff --git a/.env.example b/.env.example index 95f94fb..2a350e0 100644 --- a/.env.example +++ b/.env.example @@ -59,8 +59,11 @@ PAYMENTS_ENABLED=true STARS_PRICE=5 PREMIUM_STARS_PRICE=15 -# Веб-история: только локальная сеть, без авторизации +# Веб-история: только локальная сеть WEB_BIND_IP=192.168.31.158 WEB_PAGE_SIZE=50 +# Админ-панель /admin: HTTP Basic +WEB_ADMIN_USERNAME=admin +WEB_ADMIN_PASSWORD=change-me LOG_LEVEL=INFO diff --git a/README.md b/README.md index f2a8126..de88d00 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ BLOCKED_PUBLIC_FIGURES= ## Управление пользователями -В веб-интерфейсе есть раздел **«Пользователи»** (`/users`): +В админ-панели есть раздел **«Пользователи»** (`/admin/users`): - поиск по ID или username; - установка точного баланса генераций; @@ -248,6 +248,8 @@ http://192.168.31.158:8099 Возможности: +Публичная страница `/` показывает только историю: + - фильтр по Telegram `user_id` (с подсказкой известных пользователей); - в списке показывается `@username`, если он известен, иначе Telegram ID; - фильтр по статусу и режиму (`t2i`/`i2i`); @@ -255,14 +257,21 @@ http://192.168.31.158:8099 - превью результата в списке (кэшируются в `storage/.thumbnails`); - карточка запроса с полным промптом, ошибкой, seed и изображениями. -Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN: +Админ-панель находится на `/admin` и защищена HTTP Basic: + +- `/admin` — история, включая скрытые запросы; +- `/admin/users` — управление пользователями; +- скрытие/показ запроса из публичной истории; +- скрытые запросы не видны в публичном списке, их карточки и медиа отдают `404`. ```dotenv WEB_BIND_IP=192.168.31.158 WEB_PAGE_SIZE=50 +WEB_ADMIN_USERNAME=admin +WEB_ADMIN_PASSWORD=change-me ``` -Авторизации нет. Не публикуй порт `8099` наружу и не делай проброс на роутере. +Если `WEB_ADMIN_PASSWORD` пустой, админ-панель отключена. Basic Auth передаёт пароль без шифрования, поэтому используй только LAN или VPN; не публикуй порт `8099` наружу. После успешной сборки обновление контейнера: @@ -281,6 +290,27 @@ docker compose up -d Если меняются workflow, обновите `mapping.json`. При старте бот проверяет, что все узлы и поля существуют. +## Очистка ComfyUI + +После успешного скачивания результата бот удаляет запись истории ComfyUI по `prompt_id`: + +```json +POST /history +{"delete": [""]} +``` + +Это убирает запрос и его ассеты из истории ComfyUI, но не удаляет файлы с диска. Для файлов `output/` и `temp/` есть скрипт для машины с ComfyUI: + +```powershell +.\scripts\cleanup_comfy_assets.ps1 -ComfyRoot "C:\ComfyUI" -RetentionHours 6 +``` + +Запускай его по расписанию, например раз в час. Сначала можно проверить без удаления: + +```powershell +.\scripts\cleanup_comfy_assets.ps1 -ComfyRoot "C:\ComfyUI" -RetentionHours 6 -WhatIf +``` + ## Тесты ```powershell diff --git a/checklist.md b/checklist.md index 042967d..8a2f9a8 100644 --- a/checklist.md +++ b/checklist.md @@ -8,7 +8,7 @@ - Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18), возрастной gate (19) и надёжность Telegram (20). - Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история. -- 62 теста: 38 проходят локально без БД, 24 теста БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст. +- 66 тестов: 39 проходят локально без БД, 27 тестов БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст, админ-панель, скрытие истории. - Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой. ## 0. Допущения и решения по умолчанию @@ -392,6 +392,19 @@ ON requests(user_id) WHERE status IN ('queued', 'running'); **DoD:** продолжение сцены работает по тексту, `/reset` возвращает к новой сцене. +## 22. Админ-панель и очистка ComfyUI + +- [x] Публичная страница `/` показывает только видимую историю. +- [x] `/admin` и `/admin/users` защищены HTTP Basic (`WEB_ADMIN_USERNAME`/`WEB_ADMIN_PASSWORD`). +- [x] Управление пользователями перенесено в `/admin/users`. +- [x] Скрытие/показ запросов из публичной истории; админ видит скрытые. +- [x] Скрытые карточки и медиа недоступны публично (`404`). +- [x] После успешного скачивания бот удаляет историю ComfyUI по `prompt_id`. +- [x] Скрипт очистки файлов `scripts/cleanup_comfy_assets.ps1`. +- [x] Тесты БД и веб-маршрутов. + +**DoD:** публичная история не показывает скрытые запросы, админ-панель требует пароль, история ComfyUI чистится после генерации. + ## Порядок работ 1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow. diff --git a/comfy_client.py b/comfy_client.py index 2d2bda2..34e05cb 100644 --- a/comfy_client.py +++ b/comfy_client.py @@ -246,6 +246,26 @@ class ComfyClient: entry = payload.get(prompt_id) return entry if isinstance(entry, dict) else None + async def delete_history(self, prompt_id: str) -> None: + try: + response = await self._client.post( + "/history", + json={"delete": [prompt_id]}, + timeout=10.0, + ) + except httpx.HTTPError as exc: + raise ComfyConnectionError(f"POST /history: {exc}") from exc + if response.status_code in {400, 404, 405}: + logger.warning( + "ComfyUI не поддерживает удаление истории по prompt_id" + ) + return + if response.status_code >= 400: + raise ComfyHTTPError( + f"POST /history: HTTP {response.status_code}:" + f" {response.text[:500]}" + ) + async def wait_for_history(self, prompt_id: str, *, timeout: float) -> dict[str, Any]: loop = asyncio.get_running_loop() deadline = loop.time() + timeout diff --git a/config.py b/config.py index b8907fa..a0c0066 100644 --- a/config.py +++ b/config.py @@ -104,6 +104,8 @@ class Settings: premium_stars_price: int = 15 web_page_size: int = 50 web_bind_ip: str = "127.0.0.1" + web_admin_username: str = "admin" + web_admin_password: str = "" @property def comfy_ws_url(self) -> str: @@ -177,6 +179,11 @@ class Settings: web_page_size=max(10, min(200, _int("WEB_PAGE_SIZE", 50))), web_bind_ip=os.getenv("WEB_BIND_IP", "127.0.0.1").strip() or "127.0.0.1", + web_admin_username=os.getenv( + "WEB_ADMIN_USERNAME", "admin" + ).strip() + or "admin", + web_admin_password=os.getenv("WEB_ADMIN_PASSWORD", "").strip(), ) return settings diff --git a/db.py b/db.py index 099f4eb..865a18a 100644 --- a/db.py +++ b/db.py @@ -70,6 +70,7 @@ class Request: payment_charge_id: str | None payment_refunded: bool is_premium: bool + hidden: bool mode: str seed: int | None error_text: str | None @@ -97,6 +98,7 @@ class Request: payment_charge_id=row["payment_charge_id"], payment_refunded=bool(row["payment_refunded"]), is_premium=bool(row["is_premium"]), + hidden=bool(row["hidden"]), mode=row["mode"], seed=row["seed"], error_text=row["error_text"], @@ -250,6 +252,7 @@ CREATE TABLE IF NOT EXISTS requests ( payment_charge_id TEXT, payment_refunded BOOLEAN NOT NULL DEFAULT FALSE, is_premium BOOLEAN NOT NULL DEFAULT FALSE, + hidden BOOLEAN NOT NULL DEFAULT FALSE, mode TEXT NOT NULL CHECK (mode IN ('t2i', 'i2i')), seed BIGINT, error_text TEXT, @@ -338,6 +341,10 @@ MIGRATIONS = ( "ALTER TABLE requests ADD COLUMN IF NOT EXISTS enhanced_prompt TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_base_prompt TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_prompt TEXT", + ( + "ALTER TABLE requests ADD COLUMN IF NOT EXISTS hidden BOOLEAN" + " NOT NULL DEFAULT FALSE" + ), "ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_charge_id TEXT", ( "ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_refunded BOOLEAN" @@ -779,6 +786,7 @@ class Database: user_id: int | None, status: str | None, mode: str | None, + include_hidden: bool = True, ) -> tuple[str, list[Any]]: clauses: list[str] = [] params: list[Any] = [] @@ -791,6 +799,8 @@ class Database: if mode: clauses.append("mode = %s") params.append(mode) + if not include_hidden: + clauses.append("hidden = FALSE") where = f" WHERE {' AND '.join(clauses)}" if clauses else "" return where, params @@ -800,10 +810,13 @@ class Database: user_id: int | None = None, status: str | None = None, mode: str | None = None, + include_hidden: bool = True, limit: int = 50, offset: int = 0, ) -> list[Request]: - where, params = self._filters(user_id, status, mode) + where, params = self._filters( + user_id, status, mode, include_hidden=include_hidden + ) query = ( f"SELECT * FROM requests{where} ORDER BY id DESC LIMIT %s OFFSET %s" ) @@ -818,8 +831,11 @@ class Database: user_id: int | None = None, status: str | None = None, mode: str | None = None, + include_hidden: bool = True, ) -> int: - where, params = self._filters(user_id, status, mode) + where, params = self._filters( + user_id, status, mode, include_hidden=include_hidden + ) async with self._connection_pool().connection() as conn: cursor = await conn.execute( f"SELECT COUNT(*) AS total FROM requests{where}", params @@ -827,10 +843,12 @@ class Database: row = await cursor.fetchone() return int(row["total"]) if row is not None else 0 - async def stats(self) -> dict[str, Any]: + async def stats(self, *, include_hidden: bool = True) -> dict[str, Any]: + where = "" if include_hidden else " WHERE hidden = FALSE" async with self._connection_pool().connection() as conn: cursor = await conn.execute( - "SELECT status, COUNT(*) AS total FROM requests GROUP BY status" + "SELECT status, COUNT(*) AS total FROM requests" + f"{where} GROUP BY status" ) rows = await cursor.fetchall() by_status = { @@ -838,6 +856,7 @@ class Database: } cursor = await conn.execute( "SELECT COUNT(DISTINCT user_id) AS total FROM requests" + f"{where}" ) row = await cursor.fetchone() return { @@ -846,10 +865,16 @@ class Database: "by_status": by_status, } - async def list_users(self, limit: int = 100) -> list[UserStat]: + async def list_users( + self, limit: int = 100, *, include_hidden: bool = True + ) -> list[UserStat]: + where = "" if include_hidden else " WHERE hidden = FALSE" + username_filter = ( + "" if include_hidden else " AND r2.hidden = FALSE" + ) async with self._connection_pool().connection() as conn: cursor = await conn.execute( - """ + f""" SELECT user_id, COUNT(*) AS total, @@ -858,9 +883,11 @@ class Database: SELECT username FROM requests AS r2 WHERE r2.user_id = requests.user_id AND r2.username IS NOT NULL + {username_filter} ORDER BY r2.id DESC LIMIT 1 ) AS username FROM requests + {where} GROUP BY user_id ORDER BY last_seen DESC LIMIT %s @@ -949,6 +976,14 @@ class Database: (request_id,), ) + async def set_hidden(self, request_id: int, hidden: bool) -> bool: + async with self._connection_pool().connection() as conn: + cursor = await conn.execute( + "UPDATE requests SET hidden = %s WHERE id = %s RETURNING id", + (hidden, request_id), + ) + return await cursor.fetchone() is not None + async def create_pending_payment( self, *, diff --git a/queue_manager.py b/queue_manager.py index b56dff0..47c94a0 100644 --- a/queue_manager.py +++ b/queue_manager.py @@ -311,6 +311,15 @@ class QueueManager: ) await self._comfy.download_output(entry, output_path) + try: + await self._comfy.delete_history(prompt_id) + except Exception: + logger.warning( + "Не удалось очистить историю ComfyUI для %s", + prompt_id, + exc_info=True, + ) + await self._db.set_done(request_id, str(output_path)) request = await self._refresh(request_id) self._active_users.discard(request.user_id) diff --git a/scripts/cleanup_comfy_assets.ps1 b/scripts/cleanup_comfy_assets.ps1 new file mode 100644 index 0000000..5523486 --- /dev/null +++ b/scripts/cleanup_comfy_assets.ps1 @@ -0,0 +1,26 @@ +param( + [string]$ComfyRoot = "C:\ComfyUI", + [int]$RetentionHours = 6, + [switch]$WhatIf +) + +$cutoff = (Get-Date).AddHours(-[Math]::Abs($RetentionHours)) +$paths = @( + (Join-Path $ComfyRoot "output"), + (Join-Path $ComfyRoot "temp") +) + +foreach ($path in $paths) { + if (-not (Test-Path -LiteralPath $path)) { + continue + } + Get-ChildItem -LiteralPath $path -Recurse -File | + Where-Object { $_.LastWriteTime -lt $cutoff } | + ForEach-Object { + if ($WhatIf) { + Write-Output $_.FullName + } else { + Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/tests/conftest.py b/tests/conftest.py index 6885f36..9880aca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,8 @@ def web_settings(tmp_path: Path) -> Iterator[Settings]: bot_token="", database_url=url, storage_dir=tmp_path / "storage", + web_admin_username="admin", + web_admin_password="secret", ) try: yield settings diff --git a/tests/test_comfy.py b/tests/test_comfy.py index 5c70c77..a72fbde 100644 --- a/tests/test_comfy.py +++ b/tests/test_comfy.py @@ -1,5 +1,7 @@ from __future__ import annotations +import httpx + from comfy_client import ComfyClient, _format_api_error @@ -44,3 +46,26 @@ def test_find_output_text() -> None: entry = {"outputs": {"3": {"text": ["A red apple"]}}} text = ComfyClient.find_output_text(None, entry) # type: ignore[arg-type] assert text == "A red apple" + + +async def test_delete_history_posts_prompt_id() -> None: + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["path"] = request.url.path + seen["json"] = request.content.decode() + return httpx.Response(200) + + client = ComfyClient("http://comfy", "ws://comfy") + await client.close() + client._client = httpx.AsyncClient( # type: ignore[attr-defined] + transport=httpx.MockTransport(handler), + base_url="http://comfy", + ) + try: + await client.delete_history("prompt-1") + finally: + await client.close() + + assert seen["path"] == "/history" + assert '"prompt-1"' in str(seen["json"]) diff --git a/tests/test_db.py b/tests/test_db.py index 409ff7b..357b069 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -273,6 +273,48 @@ async def test_user_credits_referral_and_daily_accrual(db: Database) -> None: assert await db.accrue_daily_credits(future) == 0 +async def test_hidden_requests_are_excluded_from_public_queries( + db: Database, +) -> None: + visible = await db.create_request( + user_id=1, + username="alice", + mode="t2i", + prompt_text="visible", + input_file_path=None, + seed=1, + ) + await db.set_done(visible.id, "/tmp/visible.png") + hidden = await db.create_request( + user_id=2, + username="bob", + mode="t2i", + prompt_text="hidden", + input_file_path=None, + seed=2, + ) + await db.set_done(hidden.id, "/tmp/hidden.png") + assert await db.set_hidden(hidden.id, True) is True + + public = await db.list_requests(include_hidden=False) + assert [item.id for item in public] == [visible.id] + assert await db.count_requests(include_hidden=False) == 1 + + admin = await db.list_requests(include_hidden=True) + assert {item.id for item in admin} == {visible.id, hidden.id} + + stats = await db.stats(include_hidden=False) + assert stats["total"] == 1 + assert stats["users"] == 1 + + public_users = await db.list_users(include_hidden=False) + assert {item.user_id for item in public_users} == {1} + + loaded = await db.get_request(hidden.id) + assert loaded is not None + assert loaded.hidden is True + + async def test_user_context_lifecycle(db: Database) -> None: assert await db.get_user_context(1) is None diff --git a/tests/test_queue.py b/tests/test_queue.py index 8d0c954..dab7c3f 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -20,6 +20,7 @@ class FakeComfy: def __init__(self) -> None: self.workflows: list[dict] = [] self.uploads: list[tuple[Path, str]] = [] + self.deleted_history: list[str] = [] async def open_progress(self, client_id: str): return None @@ -44,6 +45,9 @@ class FakeComfy: self.uploads.append((path, remote_name)) return remote_name + async def delete_history(self, prompt_id: str) -> None: + self.deleted_history.append(prompt_id) + class FakeEnhancer: enabled = True @@ -154,6 +158,7 @@ async def test_text_to_image_job(db: Database, tmp_path: Path) -> None: settings = make_settings(tmp_path) queue = make_queue(db, settings, events) + comfy = queue._comfy # type: ignore[attr-defined] await queue.start() try: request, _ = await queue.enqueue( @@ -166,6 +171,7 @@ async def test_text_to_image_job(db: Database, tmp_path: Path) -> None: output = Path(event.request.output_file_path or "") assert output.exists() assert output.name == f"{request.id}_output.png" + assert comfy.deleted_history == ["prompt-1"] finally: await queue.stop() diff --git a/tests/test_web.py b/tests/test_web.py index 00c6d5a..c3e5177 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from datetime import UTC, date, datetime import psycopg @@ -10,6 +11,11 @@ from psycopg.rows import dict_row from config import Settings from web import create_app +ADMIN_HEADERS = { + "Authorization": "Basic " + + base64.b64encode(b"admin:secret").decode() +} + def insert_request( database_url: str, @@ -21,14 +27,15 @@ def insert_request( output: str | None = None, username: str | None = None, enhanced: str | None = None, + hidden: bool = False, ) -> int: with psycopg.connect(database_url, row_factory=dict_row) as connection: row = connection.execute( """ INSERT INTO requests (user_id, username, timestamp, prompt_text, enhanced_prompt, - status, mode, seed, output_file_path) - VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s) + status, mode, seed, output_file_path, hidden) + VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s, %s) RETURNING id """, ( @@ -40,6 +47,7 @@ def insert_request( status, mode, output, + hidden, ), ).fetchone() if row is None: @@ -161,17 +169,36 @@ def test_user_management(web_settings: Settings) -> None: credits=0, ) - page = client.get("/users") + page = client.get("/admin/users", headers=ADMIN_HEADERS) assert page.status_code == 200 assert "@alice" in page.text assert "@bob" in page.text assert ( - client.post("/users/1/credits", data={"credits": "9"}).status_code - == 200 + client.post( + "/admin/users/1/credits", + data={"credits": "9"}, + headers=ADMIN_HEADERS, + follow_redirects=False, + ).status_code + == 303 + ) + assert ( + client.post( + "/admin/users/1/admin", + headers=ADMIN_HEADERS, + follow_redirects=False, + ).status_code + == 303 + ) + assert ( + client.post( + "/admin/users/2/block", + headers=ADMIN_HEADERS, + follow_redirects=False, + ).status_code + == 303 ) - assert client.post("/users/1/admin").status_code == 200 - assert client.post("/users/2/block").status_code == 200 with psycopg.connect( web_settings.database_url, row_factory=dict_row @@ -192,6 +219,77 @@ def test_user_management(web_settings: Settings) -> None: assert second["is_blocked"] is True +def test_admin_auth_and_public_users_redirect( + web_settings: Settings, +) -> None: + app = create_app(web_settings) + with TestClient(app) as client: + assert ( + client.get("/admin", follow_redirects=False).status_code == 401 + ) + assert ( + client.get( + "/admin/users", follow_redirects=False + ).status_code + == 401 + ) + redirect = client.get("/users", follow_redirects=False) + assert redirect.status_code == 307 + assert redirect.headers["location"] == "/admin/users" + assert ( + client.get("/admin", headers=ADMIN_HEADERS).status_code == 200 + ) + assert ( + client.get( + "/admin/users", headers=ADMIN_HEADERS + ).status_code + == 200 + ) + + +def test_hidden_requests_are_admin_only(web_settings: Settings) -> None: + app = create_app(web_settings) + with TestClient(app) as client: + visible = insert_request( + web_settings.database_url, + user_id=1, + prompt="visible prompt", + ) + hidden = insert_request( + web_settings.database_url, + user_id=2, + prompt="hidden prompt", + hidden=True, + ) + + public = client.get("/") + assert "visible prompt" in public.text + assert "hidden prompt" not in public.text + assert client.get(f"/request/{hidden}").status_code == 404 + assert client.get(f"/media/{hidden}/output").status_code == 404 + assert client.get(f"/thumb/{hidden}").status_code == 404 + + admin = client.get("/admin", headers=ADMIN_HEADERS) + assert "hidden prompt" in admin.text + assert ( + client.get( + f"/admin/request/{hidden}", headers=ADMIN_HEADERS + ).status_code + == 200 + ) + + response = client.post( + f"/admin/requests/{visible}/hide", + headers=ADMIN_HEADERS, + follow_redirects=False, + ) + assert response.status_code == 303 + public = client.get("/") + assert "visible prompt" not in public.text + admin = client.get("/admin", headers=ADMIN_HEADERS) + assert "visible prompt" in admin.text + + def test_user_search(web_settings: Settings) -> None: app = create_app(web_settings) with TestClient(app) as client: @@ -202,7 +300,9 @@ def test_user_search(web_settings: Settings) -> None: web_settings.database_url, user_id=2, username="bob" ) - page = client.get("/users?q=alice") + page = client.get( + "/admin/users?q=alice", headers=ADMIN_HEADERS + ) assert page.status_code == 200 assert "@alice" in page.text assert "@bob" not in page.text diff --git a/web.py b/web.py index 413eae1..435671e 100644 --- a/web.py +++ b/web.py @@ -4,6 +4,7 @@ import asyncio import logging import math import os +import secrets from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path @@ -11,8 +12,9 @@ from typing import Annotated, Any from urllib.parse import urlencode from uuid import uuid4 -from fastapi import FastAPI, Form, HTTPException, Request +from fastapi import APIRouter, Depends, FastAPI, Form, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse +from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from PIL import Image, ImageOps @@ -29,6 +31,7 @@ STATUSES = ("queued", "running", "done", "failed") MODES = ("t2i", "i2i") logger = logging.getLogger(__name__) +security = HTTPBasic(auto_error=False) def _safe_storage_path(raw_path: str, storage_root: Path) -> Path | None: @@ -96,67 +99,48 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.state.settings = settings app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") - @app.get("/healthz") - async def healthz() -> dict[str, bool]: - return {"ok": True} - - @app.get("/users", response_class=HTMLResponse) - async def users(request: Request, q: str = ""): - db: Database = request.app.state.db - rows = await db.list_users_admin(1000) - needle = q.strip().lower().lstrip("@") - if needle: - rows = [ - user - for user in rows - if needle in str(user.user_id) - or (user.username and needle in user.username.lower()) - ] - return templates.TemplateResponse( - request=request, - name="users.html", - context={"users": rows, "q": q}, + async def require_admin( + request: Request, + credentials: Annotated[ + HTTPBasicCredentials | None, Depends(security) + ], + ) -> None: + current: Settings = request.app.state.settings + if not current.web_admin_password: + raise HTTPException( + status_code=503, + detail="Админ-панель отключена: задай WEB_ADMIN_PASSWORD", + ) + if credentials is None: + raise HTTPException( + status_code=401, + detail="Требуется авторизация", + headers={"WWW-Authenticate": "Basic"}, + ) + username_ok = secrets.compare_digest( + credentials.username, current.web_admin_username ) + password_ok = secrets.compare_digest( + credentials.password, current.web_admin_password + ) + if not (username_ok and password_ok): + raise HTTPException( + status_code=401, + detail="Неверный логин или пароль", + headers={"WWW-Authenticate": "Basic"}, + ) - @app.post("/users/{user_id}/credits") - async def user_credits( + async def history_page( request: Request, - user_id: int, - credits: Annotated[int, Form()], - ) -> RedirectResponse: - db: Database = request.app.state.db - if not await db.set_credits(user_id, max(0, credits)): - raise HTTPException(status_code=404, detail="Пользователь не найден") - return RedirectResponse("/users", status_code=303) - - @app.post("/users/{user_id}/admin") - async def user_admin(request: Request, user_id: int) -> RedirectResponse: - db: Database = request.app.state.db - user = await db.get_user(user_id) - if user is None: - raise HTTPException(status_code=404, detail="Пользователь не найден") - await db.set_admin(user_id, not user.is_admin) - return RedirectResponse("/users", status_code=303) - - @app.post("/users/{user_id}/block") - async def user_block(request: Request, user_id: int) -> RedirectResponse: - db: Database = request.app.state.db - user = await db.get_user(user_id) - if user is None: - raise HTTPException(status_code=404, detail="Пользователь не найден") - await db.set_blocked(user_id, not user.is_blocked) - return RedirectResponse("/users", status_code=303) - - @app.get("/", response_class=HTMLResponse) - async def index( - request: Request, - user_id: str = "", - status: str = "", - mode: str = "", - page: int = 1, + *, + admin: bool, + user_id: str, + status: str, + mode: str, + page: int, ): db: Database = request.app.state.db - settings: Settings = request.app.state.settings + current: Settings = request.app.state.settings error = "" uid: int | None = None @@ -170,8 +154,13 @@ def create_app(settings: Settings | None = None) -> FastAPI: status = status if status in STATUSES else "" mode = mode if mode in MODES else "" - total = await db.count_requests(user_id=uid, status=status or None, mode=mode or None) - page_size = settings.web_page_size + total = await db.count_requests( + user_id=uid, + status=status or None, + mode=mode or None, + include_hidden=admin, + ) + page_size = current.web_page_size pages = max(1, math.ceil(total / page_size)) page = max(1, min(page, pages)) offset = (page - 1) * page_size @@ -180,11 +169,12 @@ def create_app(settings: Settings | None = None) -> FastAPI: user_id=uid, status=status or None, mode=mode or None, + include_hidden=admin, limit=page_size, offset=offset, ) - users = await db.list_users() - stats = await db.stats() + users = await db.list_users(include_hidden=admin) + stats = await db.stats(include_hidden=admin) base_filters = { key: value @@ -195,13 +185,15 @@ def create_app(settings: Settings | None = None) -> FastAPI: ) if value } + base_path = "/admin" if admin else "" + history_path = base_path or "/" def page_url(number: int) -> str: params = dict(base_filters) if number > 1: params["page"] = str(number) query = urlencode(params) - return f"/?{query}" if query else "/" + return f"{history_path}?{query}" if query else history_path context: dict[str, Any] = { "rows": rows, @@ -216,6 +208,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: "page": page, "pages": pages, "total": total, + "admin": admin, + "base_path": base_path, + "history_path": history_path, "prev_url": page_url(page - 1) if page > 1 else None, "next_url": page_url(page + 1) if page < pages else None, } @@ -223,54 +218,68 @@ def create_app(settings: Settings | None = None) -> FastAPI: request=request, name="index.html", context=context ) - @app.get("/request/{request_id}", response_class=HTMLResponse) - async def detail(request: Request, request_id: int): + async def detail_page( + request: Request, request_id: int, *, admin: bool + ): db: Database = request.app.state.db item = await db.get_request(request_id) - if item is None: + if item is None or (item.hidden and not admin): raise HTTPException(status_code=404, detail="Запрос не найден") return templates.TemplateResponse( request=request, name="detail.html", - context={"item": item}, + context={ + "item": item, + "admin": admin, + "base_path": "/admin" if admin else "", + }, ) - @app.get("/media/{request_id}/{kind}") - async def media(request: Request, request_id: int, kind: str): + async def media_response( + request: Request, request_id: int, kind: str, *, admin: bool + ): if kind not in {"input", "output"}: raise HTTPException(status_code=404, detail="Не найдено") db: Database = request.app.state.db - settings: Settings = request.app.state.settings + current: Settings = request.app.state.settings item: HistoryRequest | None = await db.get_request(request_id) - if item is None: + if item is None or (item.hidden and not admin): raise HTTPException(status_code=404, detail="Запрос не найден") - raw_path = item.input_file_path if kind == "input" else item.output_file_path + raw_path = ( + item.input_file_path if kind == "input" else item.output_file_path + ) if not raw_path: raise HTTPException(status_code=404, detail="Файл не найден") - storage_root = settings.storage_dir.resolve() + storage_root = current.storage_dir.resolve() path = _safe_storage_path(raw_path, storage_root) if path is None or not path.is_file(): raise HTTPException(status_code=404, detail="Файл не найден") return FileResponse(path) - @app.get("/thumb/{request_id}") - async def thumbnail(request: Request, request_id: int): + async def thumbnail_response( + request: Request, request_id: int, *, admin: bool + ): db: Database = request.app.state.db - settings: Settings = request.app.state.settings + current: Settings = request.app.state.settings item: HistoryRequest | None = await db.get_request(request_id) - if item is None or not item.output_file_path: + if item is None or (item.hidden and not admin): + raise HTTPException(status_code=404, detail="Файл не найден") + if not item.output_file_path: raise HTTPException(status_code=404, detail="Файл не найден") - storage_root = settings.storage_dir.resolve() + storage_root = current.storage_dir.resolve() source = _safe_storage_path(item.output_file_path, storage_root) if source is None or not source.is_file(): raise HTTPException(status_code=404, detail="Файл не найден") cache_dir = storage_root / ".thumbnails" cache_path = cache_dir / f"{request_id}.jpg" - if cache_path.is_file() and cache_path.stat().st_mtime >= source.stat().st_mtime: + if ( + cache_path.is_file() + and cache_path.stat().st_mtime >= source.stat().st_mtime + ): return FileResponse( cache_path, media_type="image/jpeg", @@ -280,7 +289,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: try: await asyncio.to_thread(_make_thumbnail, source, cache_path) except Exception: - logger.exception("Не удалось создать превью для запроса %s", request_id) + logger.exception( + "Не удалось создать превью для запроса %s", request_id + ) return FileResponse(source) return FileResponse( @@ -289,6 +300,147 @@ def create_app(settings: Settings | None = None) -> FastAPI: headers={"Cache-Control": "public, max-age=3600"}, ) + @app.get("/healthz") + async def healthz() -> dict[str, bool]: + return {"ok": True} + + @app.get("/users") + async def users_redirect() -> RedirectResponse: + return RedirectResponse("/admin/users", status_code=307) + + @app.get("/", response_class=HTMLResponse) + async def index( + request: Request, + user_id: str = "", + status: str = "", + mode: str = "", + page: int = 1, + ): + return await history_page( + request, + admin=False, + user_id=user_id, + status=status, + mode=mode, + page=page, + ) + + @app.get("/request/{request_id}", response_class=HTMLResponse) + async def detail(request: Request, request_id: int): + return await detail_page(request, request_id, admin=False) + + @app.get("/media/{request_id}/{kind}") + async def media(request: Request, request_id: int, kind: str): + return await media_response(request, request_id, kind, admin=False) + + @app.get("/thumb/{request_id}") + async def thumbnail(request: Request, request_id: int): + return await thumbnail_response(request, request_id, admin=False) + + admin_router = APIRouter( + prefix="/admin", dependencies=[Depends(require_admin)] + ) + + @admin_router.get("", response_class=HTMLResponse) + @admin_router.get("/", response_class=HTMLResponse) + async def admin_index( + request: Request, + user_id: str = "", + status: str = "", + mode: str = "", + page: int = 1, + ): + return await history_page( + request, + admin=True, + user_id=user_id, + status=status, + mode=mode, + page=page, + ) + + @admin_router.get("/users", response_class=HTMLResponse) + async def admin_users(request: Request, q: str = ""): + db: Database = request.app.state.db + rows = await db.list_users_admin(1000) + needle = q.strip().lower().lstrip("@") + if needle: + rows = [ + user + for user in rows + if needle in str(user.user_id) + or (user.username and needle in user.username.lower()) + ] + return templates.TemplateResponse( + request=request, + name="users.html", + context={"users": rows, "q": q, "admin": True}, + ) + + @admin_router.post("/users/{user_id}/credits") + async def admin_user_credits( + request: Request, + user_id: int, + credits: Annotated[int, Form()], + ) -> RedirectResponse: + db: Database = request.app.state.db + if not await db.set_credits(user_id, max(0, credits)): + raise HTTPException( + status_code=404, detail="Пользователь не найден" + ) + return RedirectResponse("/admin/users", status_code=303) + + @admin_router.post("/users/{user_id}/admin") + async def admin_user_admin( + request: Request, user_id: int + ) -> RedirectResponse: + db: Database = request.app.state.db + user = await db.get_user(user_id) + if user is None: + raise HTTPException( + status_code=404, detail="Пользователь не найден" + ) + await db.set_admin(user_id, not user.is_admin) + return RedirectResponse("/admin/users", status_code=303) + + @admin_router.post("/users/{user_id}/block") + async def admin_user_block( + request: Request, user_id: int + ) -> RedirectResponse: + db: Database = request.app.state.db + user = await db.get_user(user_id) + if user is None: + raise HTTPException( + status_code=404, detail="Пользователь не найден" + ) + await db.set_blocked(user_id, not user.is_blocked) + return RedirectResponse("/admin/users", status_code=303) + + @admin_router.post("/requests/{request_id}/hide") + async def admin_hide_request( + request: Request, request_id: int + ) -> RedirectResponse: + db: Database = request.app.state.db + item = await db.get_request(request_id) + if item is None: + raise HTTPException(status_code=404, detail="Запрос не найден") + if not await db.set_hidden(request_id, not item.hidden): + raise HTTPException(status_code=404, detail="Запрос не найден") + return RedirectResponse("/admin", status_code=303) + + @admin_router.get("/request/{request_id}", response_class=HTMLResponse) + async def admin_detail(request: Request, request_id: int): + return await detail_page(request, request_id, admin=True) + + @admin_router.get("/media/{request_id}/{kind}") + async def admin_media(request: Request, request_id: int, kind: str): + return await media_response(request, request_id, kind, admin=True) + + @admin_router.get("/thumb/{request_id}") + async def admin_thumbnail(request: Request, request_id: int): + return await thumbnail_response(request, request_id, admin=True) + + app.include_router(admin_router) return app diff --git a/web/templates/base.html b/web/templates/base.html index 9818017..d8a0a05 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -10,8 +10,7 @@
MpakoImageAIbot История запросов
diff --git a/web/templates/detail.html b/web/templates/detail.html index bf9bb0b..a890733 100644 --- a/web/templates/detail.html +++ b/web/templates/detail.html @@ -1,12 +1,27 @@ {% extends "base.html" %} {% block title %}Запрос #{{ item.id }} — MpakoImageAIbot{% endblock %} +{% block nav %} +{% if admin %} +Админ-история +Пользователи +Публичная история +{% else %} +История +{% endif %} +{% endblock %} {% block content %} -← К истории +← К истории

Запрос #{{ item.id }}

+{% if admin %} +
+ +
+{% if item.hidden %}
Запрос скрыт из публичной истории.
{% endif %} +{% endif %}
Пользователь
-
{{ item.display_user }}
+
{{ item.display_user }}
Статус
{{ item.status }}
Режим
@@ -36,16 +51,16 @@
{% if item.input_file_path %}
- - Входное изображение + + Входное изображение
Вход
{% endif %} {% if item.output_file_path %}
- - Результат + + Результат
Результат
diff --git a/web/templates/index.html b/web/templates/index.html index b5f1518..4706226 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1,5 +1,14 @@ {% extends "base.html" %} -{% block title %}История запросов — MpakoImageAIbot{% endblock %} +{% block title %}{{ "Админ-история" if admin else "История запросов" }} — MpakoImageAIbot{% endblock %} +{% block nav %} +{% if admin %} +Админ-история +Пользователи +Публичная история +{% else %} +История +{% endif %} +{% endblock %} {% block content %}
{{ stats.total }}Всего запросов
@@ -8,7 +17,7 @@
{{ stats.by_status.get('failed', 0) }}Ошибок
-
+