Отображение истории
Build and push Docker image / docker (push) Successful in 1m8s
Details
Build and push Docker image / docker (push) Successful in 1m8s
Details
This commit is contained in:
parent
a2163a5472
commit
134a47dfc5
|
|
@ -91,6 +91,7 @@ http://192.168.31.158:8099
|
|||
- фильтр по Telegram `user_id` (с подсказкой известных пользователей);
|
||||
- фильтр по статусу и режиму (`t2i`/`i2i`);
|
||||
- пагинация;
|
||||
- превью результата в списке (кэшируются в `storage/.thumbnails`);
|
||||
- карточка запроса с полным промптом, ошибкой, seed и изображениями.
|
||||
|
||||
Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN:
|
||||
|
|
|
|||
|
|
@ -297,6 +297,7 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
|
|||
|
||||
- [x] `web.py`: FastAPI + Jinja2, отдельный контейнер `web`.
|
||||
- [x] Список запросов с фильтрами `user_id`, `status`, `mode` и пагинацией.
|
||||
- [x] Превью результата в списке, генерация и кэш в `storage/.thumbnails`.
|
||||
- [x] Карточка запроса: промпт, seed, ошибка, `comfy_prompt_id`, вход/результат.
|
||||
- [x] Отдача изображений только из `STORAGE_DIR` (защита от path traversal).
|
||||
- [x] Доступ только из локальной сети: публикация на `WEB_BIND_IP`, без авторизации.
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ python-dotenv>=1.0,<2
|
|||
fastapi>=0.115,<1
|
||||
uvicorn>=0.30,<1
|
||||
jinja2>=3.1,<4
|
||||
pillow>=10,<13
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import sqlite3
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from config import Settings
|
||||
from web import create_app
|
||||
|
|
@ -70,7 +71,8 @@ def test_detail_and_media(tmp_path: Path) -> None:
|
|||
settings = make_settings(tmp_path)
|
||||
output = settings.storage_dir / "1" / "1_output.png"
|
||||
output.parent.mkdir(parents=True)
|
||||
output.write_bytes(b"fake-png")
|
||||
with Image.new("RGB", (320, 200), (180, 40, 40)) as image:
|
||||
image.save(output)
|
||||
|
||||
app = create_app(settings)
|
||||
with TestClient(app) as client:
|
||||
|
|
@ -85,9 +87,17 @@ def test_detail_and_media(tmp_path: Path) -> None:
|
|||
assert detail.status_code == 200
|
||||
assert "detail prompt" in detail.text
|
||||
|
||||
index = client.get("/")
|
||||
assert f"/thumb/{request_id}" in index.text
|
||||
|
||||
media = client.get(f"/media/{request_id}/output")
|
||||
assert media.status_code == 200
|
||||
assert media.content == b"fake-png"
|
||||
assert media.content.startswith(b"\x89PNG")
|
||||
|
||||
thumb = client.get(f"/thumb/{request_id}")
|
||||
assert thumb.status_code == 200
|
||||
assert thumb.headers["content-type"] == "image/jpeg"
|
||||
assert thumb.content.startswith(b"\xff\xd8")
|
||||
|
||||
missing = client.get(f"/media/{request_id}/input")
|
||||
assert missing.status_code == 404
|
||||
|
|
|
|||
78
web.py
78
web.py
|
|
@ -1,16 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from config import Settings
|
||||
from db import Database
|
||||
|
|
@ -23,6 +28,37 @@ STATIC_DIR = BASE_DIR / "web" / "static"
|
|||
STATUSES = ("queued", "running", "done", "failed")
|
||||
MODES = ("t2i", "i2i")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_storage_path(raw_path: str, storage_root: Path) -> Path | None:
|
||||
path = Path(raw_path).resolve()
|
||||
try:
|
||||
path.relative_to(storage_root)
|
||||
except ValueError:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _make_thumbnail(source: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = destination.with_name(f"{destination.name}.{uuid4().hex}.tmp")
|
||||
try:
|
||||
with Image.open(source) as image:
|
||||
image = ImageOps.exif_transpose(image)
|
||||
image.thumbnail((320, 320))
|
||||
if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
|
||||
rgba = image.convert("RGBA")
|
||||
background = Image.new("RGB", rgba.size, (15, 17, 21))
|
||||
background.paste(rgba, mask=rgba.getchannel("A"))
|
||||
image = background
|
||||
else:
|
||||
image = image.convert("RGB")
|
||||
image.save(temp_path, format="JPEG", quality=85, optimize=True)
|
||||
os.replace(temp_path, destination)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def fmt_dt(value: str | None) -> str:
|
||||
if not value:
|
||||
|
|
@ -163,15 +199,45 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||
|
||||
storage_root = settings.storage_dir.resolve()
|
||||
path = Path(raw_path).resolve()
|
||||
try:
|
||||
path.relative_to(storage_root)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Файл не найден") from None
|
||||
if not path.is_file():
|
||||
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):
|
||||
db: Database = request.app.state.db
|
||||
settings: Settings = request.app.state.settings
|
||||
item: HistoryRequest | None = await db.get_request(request_id)
|
||||
if item is None or not item.output_file_path:
|
||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||
|
||||
storage_root = settings.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:
|
||||
return FileResponse(
|
||||
cache_path,
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_make_thumbnail, source, cache_path)
|
||||
except Exception:
|
||||
logger.exception("Не удалось создать превью для запроса %s", request_id)
|
||||
return FileResponse(source)
|
||||
|
||||
return FileResponse(
|
||||
cache_path,
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,17 @@ tbody tr:last-child td { border-bottom: 0; }
|
|||
.empty { text-align: center; color: var(--muted); padding: 28px; }
|
||||
.details { white-space: nowrap; }
|
||||
|
||||
.thumb-cell { width: 78px; }
|
||||
.thumb {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Превью</th>
|
||||
<th>Дата</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Режим</th>
|
||||
|
|
@ -65,6 +66,15 @@
|
|||
{% for item in rows %}
|
||||
<tr>
|
||||
<td class="mono">#{{ item.id }}</td>
|
||||
<td class="thumb-cell">
|
||||
{% if item.output_file_path %}
|
||||
<a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
|
||||
<img class="thumb" src="/thumb/{{ item.id }}" alt="Результат #{{ item.id }}" loading="lazy">
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="nowrap">{{ item.timestamp|fmt_dt }}</td>
|
||||
<td><a href="/?user_id={{ item.user_id }}">{{ item.user_id }}</a></td>
|
||||
<td class="mono">{{ item.mode }}</td>
|
||||
|
|
@ -74,7 +84,7 @@
|
|||
<td><a class="details" href="/request/{{ item.id }}">Детали</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="empty">Ничего не найдено</td></tr>
|
||||
<tr><td colspan="9" class="empty">Ничего не найдено</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
Loading…
Reference in New Issue