mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 18:05:03 +00:00
296 lines
12 KiB
Python
296 lines
12 KiB
Python
# VESTI Web — локальный интерфейс управления (FastAPI + Jinja2 + Bootstrap 5.3 + HTMX).
|
|
# Спека vesti-web: 127.0.0.1:8400, авторизация (пароль в .env), списки кандидатов,
|
|
# подтверждение/отклонение черновиков (→ tg-publisher + банк), опубликованные с метриками.
|
|
|
|
import json
|
|
import os
|
|
import secrets
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import markdown as md_lib
|
|
from fastapi import FastAPI, Form, HTTPException, Request, Response
|
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from markupsafe import Markup, escape
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
DB_PATH = BASE_DIR / "db" / "vesti.db"
|
|
TPL_DIR = Path(__file__).resolve().parent / "templates"
|
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
# Медиа постов: свежие — media/<file>; старые (исторический баг) — media/media/<file>
|
|
MEDIA_DIRS = [
|
|
BASE_DIR / "media",
|
|
BASE_DIR / "media" / "media",
|
|
]
|
|
|
|
# Авторизация: одна учётка, пароль из .env (VESTI_WEB_PASSWORD или ADMIN_PASSWORD — legacy имя)
|
|
WEB_PASSWORD = os.getenv("VESTI_WEB_PASSWORD") or os.getenv("ADMIN_PASSWORD") or "admin" # TODO: в проде только из .env
|
|
SESSION_COOKIE = "vesti_session"
|
|
|
|
app = FastAPI(title="VESTI Web")
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
tpl = Environment(loader=FileSystemLoader(str(TPL_DIR)))
|
|
tpl.filters["from_json"] = lambda s: json.loads(s or "[]")
|
|
|
|
|
|
def md_filter(text: str) -> Markup:
|
|
"""Markdown → безопасный HTML (экранируем HTML до парсинга, XSS-safe)."""
|
|
if not text:
|
|
return Markup("")
|
|
html = md_lib.markdown(escape(text), extensions=["nl2br", "sane_lists"])
|
|
return Markup(html)
|
|
|
|
|
|
def dt_filter(value) -> str:
|
|
"""2026-08-15T15:53 → 15:53 15.08.2026; None/мусор → ''."""
|
|
if not value:
|
|
return ""
|
|
try:
|
|
if isinstance(value, str):
|
|
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
return value.strftime("%H:%M %d.%m.%Y")
|
|
except (ValueError, TypeError):
|
|
return str(value)[:16] if value else ""
|
|
|
|
|
|
tpl.filters["markdown"] = md_filter
|
|
tpl.filters["dt"] = dt_filter
|
|
|
|
|
|
def _db() -> sqlite3.Connection:
|
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA busy_timeout=8000")
|
|
return conn
|
|
|
|
|
|
def _session_ok(request: Request) -> bool:
|
|
return request.cookies.get(SESSION_COOKIE) == os.getenv("VESTI_WEB_SESSION", "")
|
|
|
|
|
|
def _require_auth(request: Request):
|
|
if not _session_ok(request):
|
|
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index(request: Request):
|
|
_require_auth(request)
|
|
return RedirectResponse(url="/candidates", status_code=302)
|
|
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request):
|
|
if _session_ok(request):
|
|
return RedirectResponse(url="/candidates", status_code=302)
|
|
return HTMLResponse(tpl.get_template("login.html").render(request=request))
|
|
|
|
|
|
@app.post("/login")
|
|
def login(request: Request, password: str = Form(...)):
|
|
if secrets.compare_digest(password, WEB_PASSWORD):
|
|
sess = secrets.token_hex(16)
|
|
# сохраняем сессию (в проде — в env; для прототипа — атрибут)
|
|
os.environ["VESTI_WEB_SESSION"] = sess
|
|
resp = RedirectResponse(url="/candidates", status_code=302)
|
|
resp.set_cookie(SESSION_COOKIE, sess, httponly=True)
|
|
return resp
|
|
return RedirectResponse(url="/login?error=1", status_code=302)
|
|
|
|
|
|
@app.get("/candidates", response_class=HTMLResponse)
|
|
def candidates(request: Request, direction: str = "", status: str = "new", own: str = "", error: str = ""):
|
|
_require_auth(request)
|
|
conn = _db()
|
|
q = """SELECT p.*, s.name source_name FROM posts p
|
|
LEFT JOIN sources s ON s.id=p.source_id
|
|
WHERE 1=1"""
|
|
params = []
|
|
if direction:
|
|
q += " AND p.direction=?"
|
|
params.append(direction)
|
|
if status:
|
|
q += " AND p.status=?"
|
|
params.append(status)
|
|
if own in ("1", "0"):
|
|
q += " AND p.is_own=?"
|
|
params.append(int(own))
|
|
q += " ORDER BY p.published_at DESC LIMIT 100"
|
|
rows = conn.execute(q, params).fetchall()
|
|
conn.close()
|
|
html = tpl.get_template("candidates.html").render(
|
|
posts=rows,
|
|
direction=direction,
|
|
status=status,
|
|
own=own,
|
|
directions=["linux", "tech", "politics", "games", "electronics", "llm"],
|
|
)
|
|
return HTMLResponse(html)
|
|
|
|
|
|
@app.get("/published", response_class=HTMLResponse)
|
|
def published(request: Request):
|
|
_require_auth(request)
|
|
conn = _db()
|
|
rows = conn.execute(
|
|
"""SELECT p.*, s.name source_name,
|
|
pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs
|
|
FROM posts p
|
|
LEFT JOIN sources s ON s.id=p.source_id
|
|
LEFT JOIN published pub ON pub.post_id=p.id
|
|
WHERE p.status='published' ORDER BY p.published_at DESC LIMIT 100"""
|
|
).fetchall()
|
|
conn.close()
|
|
html = tpl.get_template("published.html").render(posts=rows)
|
|
return HTMLResponse(html)
|
|
|
|
|
|
@app.post("/posts/{post_id}/approve")
|
|
async def approve(post_id: int, request: Request):
|
|
"""Подтверждение черновика → публикация в TG (fan-out по направлениям) + создание бандла + статус published."""
|
|
_require_auth(request)
|
|
from web.publisher_client import publish as http_publish, get_views as http_views
|
|
from publisher.card import make_card
|
|
from web.store import create_bundle
|
|
|
|
conn = _db()
|
|
post = conn.execute("SELECT p.*, s.name source_name, s.lang FROM posts p LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?", (post_id,)).fetchone()
|
|
if not post:
|
|
conn.close()
|
|
return RedirectResponse(url="/candidates?error=notfound", status_code=302)
|
|
|
|
post = dict(post)
|
|
is_own = int(post.get("is_own") or 0) == 1
|
|
dirn = post.get("direction") or "linux" # направление поста (используется ниже)
|
|
lang = post.get("lang") or "ru"
|
|
|
|
# направления fan-out: из формы (чекбоксы) — по умолчанию направления классификации или направление поста
|
|
form = None
|
|
dirs_selected = []
|
|
comment = ""
|
|
if request.method == "POST":
|
|
try:
|
|
form = await request.form() # Starlette: form awaitable
|
|
dirs_selected = form.getlist("dirs")
|
|
comment = (form.get("comment") or "").strip()
|
|
except Exception:
|
|
dirs_selected = []
|
|
if not dirs_selected:
|
|
# по умолчанию: направления классификации (classifications) или направление поста
|
|
cls = conn.execute(
|
|
"SELECT direction FROM classifications WHERE post_id=? ORDER BY id", (post_id,)
|
|
).fetchall()
|
|
dirs_selected = [r["direction"] for r in cls] if cls else [dirn]
|
|
# дедуп, без пустых
|
|
dirs_selected = list(dict.fromkeys([d for d in dirs_selected if d]))
|
|
|
|
card = make_card(post, comment)
|
|
|
|
if is_own:
|
|
# СВОЙ контент → fan-out: сервис сам берёт каналы из конфига (VESTI_BOT_CHANNELS)
|
|
# направления передаём в card (для карточки), не как адресаты.
|
|
card["direction"] = dirn
|
|
card["lang"] = lang
|
|
try:
|
|
res = http_publish(card)
|
|
except RuntimeError as e:
|
|
conn.close()
|
|
return RedirectResponse(url=f"/candidates?error=publisher:{e}", status_code=302)
|
|
tg_message_id = next(iter((r or {}).get("message_id") or 0 for r in res.get("results", {}).values()), 0) if res else 0
|
|
views_by_dir = {
|
|
d: (res.get("results", {}).get(d, {}).get("views") or 0)
|
|
for d in res.get("results", {})
|
|
}
|
|
else:
|
|
# Внешний → без fan-out: сервис публикует в каналы по умолчанию
|
|
card["direction"] = dirn
|
|
card["lang"] = lang
|
|
try:
|
|
res = http_publish(card)
|
|
except RuntimeError as e:
|
|
conn.close()
|
|
return RedirectResponse(url=f"/candidates?publisher_error={e}", status_code=302)
|
|
# результаты ключаются по КАНАЛУ (@dedinit_vesti), а не по направлению — берём первый успешный
|
|
_res = res.get("results", {})
|
|
tg_message_id = next((r.get("message_id") or 0 for r in _res.values() if r.get("message_id")), 0)
|
|
views_by_dir = {d: (r.get("views") or 0) for d, r in _res.items()}
|
|
|
|
# банк статей (markdown-бандл + медиа) — по каждому направлению fan-out
|
|
try:
|
|
bundle = create_bundle(post, bundles_dir=BASE_DIR / "bundles", directions=dirs_selected)
|
|
except Exception as e:
|
|
bundle = {"error": str(e)}
|
|
|
|
# 3) статус + запись published (для своего — distributed_dirs + метрики)
|
|
import json as _json
|
|
conn.execute("UPDATE posts SET status='published' WHERE id=?", (post_id,))
|
|
distributed = _json.dumps(dirs_selected, ensure_ascii=False) if is_own else _json.dumps([dirn])
|
|
conn.execute(
|
|
"INSERT INTO published (post_id, bundle_path, tg_message_id, distributed_dirs, views) VALUES (?,?,?,?,?)",
|
|
(post_id, bundle.get("path", ""), tg_message_id, distributed, sum(views_by_dir.values())),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return RedirectResponse(url="/published", status_code=302)
|
|
|
|
|
|
@app.post("/posts/{post_id}/reject")
|
|
def reject(post_id: int, request: Request):
|
|
_require_auth(request)
|
|
conn = _db()
|
|
conn.execute("UPDATE posts SET status='rejected' WHERE id=?", (post_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return RedirectResponse(url="/candidates?status=rejected", status_code=302)
|
|
|
|
|
|
@app.get("/metrics", response_class=HTMLResponse)
|
|
def metrics(request: Request):
|
|
_require_auth(request)
|
|
conn = _db()
|
|
rows = conn.execute(
|
|
"""SELECT p.direction, COUNT(*) cnt, SUM(p.views) views, AVG(p.reactions_total) avg_reactions
|
|
FROM posts p GROUP BY p.direction"""
|
|
).fetchall()
|
|
runs = conn.execute("SELECT * FROM runs ORDER BY started_at DESC LIMIT 10").fetchall()
|
|
conn.close()
|
|
html = tpl.get_template("metrics.html").render(dirs=rows, runs=runs)
|
|
return HTMLResponse(html)
|
|
|
|
|
|
@app.get("/logout")
|
|
def logout():
|
|
resp = RedirectResponse(url="/login", status_code=302)
|
|
resp.delete_cookie(SESSION_COOKIE)
|
|
return resp
|
|
|
|
|
|
@app.get("/bundle/{bundle_path:path}")
|
|
def bundle(request: Request, bundle_path: str):
|
|
"""Показывает markdown-бандл как текст (ссылка из published)."""
|
|
_require_auth(request)
|
|
full = (BASE_DIR / "bundles" / bundle_path).resolve()
|
|
# защита от path traversal
|
|
if not str(full).startswith(str((BASE_DIR / "bundles").resolve())):
|
|
return HTMLResponse("bad path", status_code=400)
|
|
if not full.exists():
|
|
return HTMLResponse("not found", status_code=404)
|
|
return Response(full.read_text(encoding="utf-8"), media_type="text/markdown")
|
|
|
|
|
|
@app.get("/media/{filename}")
|
|
def media(request: Request, filename: str):
|
|
"""Отдаёт медиа-файл поста (из media/ или media/media/). Авторизация."""
|
|
_require_auth(request)
|
|
name = os.path.basename(filename) # защита от path traversal
|
|
if not name:
|
|
return HTMLResponse("bad filename", status_code=400)
|
|
for d in MEDIA_DIRS:
|
|
f = (d / name).resolve()
|
|
if f.exists() and f.is_file():
|
|
return FileResponse(f)
|
|
return HTMLResponse("not found", status_code=404) |