# 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/; старые (исторический баг) — media/media/ 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)