e696537fe1
build-and-push / docker (push) Has been cancelled
The "send test" action, admin alerts and feeds without their own credentials now use configurable default-server token / basic auth, so publishing works against ntfy servers with access control enabled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
551 lines
18 KiB
Python
551 lines
18 KiB
Python
"""FastAPI application: web UI + JSON API for the RSS → ntfy bridge."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import (
|
|
HTMLResponse,
|
|
JSONResponse,
|
|
PlainTextResponse,
|
|
RedirectResponse,
|
|
Response,
|
|
)
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy import Integer
|
|
from sqlmodel import Session, func, select
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from . import config, ntfy, opml, scheduler
|
|
from .auth import hash_password, verify_password
|
|
from .checker import check_feed, fetch_preview
|
|
from .database import engine, get_session, get_settings, init_db
|
|
from .models import Feed, Notification, SeenEntry, User
|
|
from .schemas import FeedIn, PreviewIn, SettingsIn, TestIn, UserIn
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
log = logging.getLogger("app")
|
|
|
|
BASE_DIR = Path(__file__).parent
|
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
init_db()
|
|
with Session(engine) as session:
|
|
interval = get_settings(session).check_interval
|
|
scheduler.start(interval)
|
|
log.info("Приложение запущено")
|
|
yield
|
|
scheduler.shutdown()
|
|
|
|
|
|
app = FastAPI(title="RSS → ntfy", lifespan=lifespan)
|
|
app.add_middleware(
|
|
SessionMiddleware, secret_key=config.SECRET_KEY, max_age=60 * 60 * 24 * 14
|
|
)
|
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Auth helpers
|
|
# --------------------------------------------------------------------------- #
|
|
def _current_user(request: Request, session: Session) -> User | None:
|
|
uid = request.session.get("uid")
|
|
if uid is None:
|
|
return None
|
|
return session.get(User, uid)
|
|
|
|
|
|
def _auth_on(session: Session) -> bool:
|
|
return get_settings(session).auth_enabled
|
|
|
|
|
|
def require_auth(request: Request, session: Session = Depends(get_session)) -> User:
|
|
"""Any logged-in user (or anyone when auth is disabled)."""
|
|
if not _auth_on(session):
|
|
# Auth disabled → act as a virtual admin.
|
|
return User(id=0, username="anonymous", role="admin")
|
|
user = _current_user(request, session)
|
|
if user is None:
|
|
raise HTTPException(401, "Требуется авторизация")
|
|
return user
|
|
|
|
|
|
def require_admin(user: User = Depends(require_auth)) -> User:
|
|
if user.role != "admin":
|
|
raise HTTPException(403, "Требуются права администратора")
|
|
return user
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Pages
|
|
# --------------------------------------------------------------------------- #
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index(request: Request, session: Session = Depends(get_session)):
|
|
if _auth_on(session) and _current_user(request, session) is None:
|
|
return RedirectResponse("/login", status_code=302)
|
|
return templates.TemplateResponse("index.html", {"request": request})
|
|
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request, session: Session = Depends(get_session)):
|
|
if not _auth_on(session) or _current_user(request, session) is not None:
|
|
return RedirectResponse("/", status_code=302)
|
|
return templates.TemplateResponse("login.html", {"request": request, "error": None})
|
|
|
|
|
|
@app.post("/login", response_class=HTMLResponse)
|
|
def login_submit(
|
|
request: Request,
|
|
username: str = Form(...),
|
|
password: str = Form(...),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
user = session.exec(select(User).where(User.username == username)).first()
|
|
if user and verify_password(password, user.password_hash):
|
|
request.session["uid"] = user.id
|
|
return RedirectResponse("/", status_code=302)
|
|
return templates.TemplateResponse(
|
|
"login.html",
|
|
{"request": request, "error": "Неверный логин или пароль"},
|
|
status_code=401,
|
|
)
|
|
|
|
|
|
@app.get("/logout")
|
|
def logout(request: Request):
|
|
request.session.clear()
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
|
|
@app.get("/api/me")
|
|
def whoami(
|
|
request: Request, session: Session = Depends(get_session), user: User = Depends(require_auth)
|
|
):
|
|
return {
|
|
"username": user.username,
|
|
"role": user.role,
|
|
"auth_enabled": _auth_on(session),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# API: feeds
|
|
# --------------------------------------------------------------------------- #
|
|
def _feed_dict(feed: Feed) -> dict:
|
|
return {
|
|
"id": feed.id,
|
|
"url": feed.url,
|
|
"title": feed.title,
|
|
"ntfy_server": feed.ntfy_server,
|
|
"ntfy_topic": feed.ntfy_topic,
|
|
"ntfy_token": feed.ntfy_token,
|
|
"ntfy_username": feed.ntfy_username,
|
|
"ntfy_password": feed.ntfy_password,
|
|
"priority": feed.priority,
|
|
"tags": feed.tags,
|
|
"attach_image": feed.attach_image,
|
|
"to_telegram": feed.to_telegram,
|
|
"to_webhook": feed.to_webhook,
|
|
"filter_include": feed.filter_include,
|
|
"filter_exclude": feed.filter_exclude,
|
|
"interval": feed.interval,
|
|
"enabled": feed.enabled,
|
|
"last_checked": feed.last_checked.isoformat() if feed.last_checked else None,
|
|
"last_status": feed.last_status,
|
|
"error_streak": feed.error_streak,
|
|
}
|
|
|
|
|
|
@app.get("/api/feeds")
|
|
def list_feeds(session: Session = Depends(get_session), _: User = Depends(require_auth)):
|
|
feeds = session.exec(select(Feed).order_by(Feed.id)).all()
|
|
return [_feed_dict(f) for f in feeds]
|
|
|
|
|
|
@app.post("/api/feeds")
|
|
def create_feed(
|
|
data: FeedIn,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_admin),
|
|
):
|
|
feed = Feed(**data.model_dump())
|
|
session.add(feed)
|
|
session.commit()
|
|
session.refresh(feed)
|
|
return _feed_dict(feed)
|
|
|
|
|
|
@app.put("/api/feeds/{feed_id}")
|
|
def update_feed(
|
|
feed_id: int,
|
|
data: FeedIn,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_admin),
|
|
):
|
|
feed = session.get(Feed, feed_id)
|
|
if feed is None:
|
|
raise HTTPException(404, "Лента не найдена")
|
|
for key, value in data.model_dump().items():
|
|
setattr(feed, key, value)
|
|
session.add(feed)
|
|
session.commit()
|
|
session.refresh(feed)
|
|
return _feed_dict(feed)
|
|
|
|
|
|
@app.delete("/api/feeds/{feed_id}")
|
|
def delete_feed(
|
|
feed_id: int,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_admin),
|
|
):
|
|
feed = session.get(Feed, feed_id)
|
|
if feed is None:
|
|
raise HTTPException(404, "Лента не найдена")
|
|
for entry in session.exec(select(SeenEntry).where(SeenEntry.feed_id == feed_id)).all():
|
|
session.delete(entry)
|
|
for note in session.exec(select(Notification).where(Notification.feed_id == feed_id)).all():
|
|
session.delete(note)
|
|
session.delete(feed)
|
|
session.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/feeds/{feed_id}/check")
|
|
async def check_now(
|
|
feed_id: int,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_auth),
|
|
):
|
|
feed = session.get(Feed, feed_id)
|
|
if feed is None:
|
|
raise HTTPException(404, "Лента не найдена")
|
|
status = await check_feed(feed)
|
|
session.refresh(feed)
|
|
return {"status": status, "feed": _feed_dict(feed)}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# API: OPML import / export
|
|
# --------------------------------------------------------------------------- #
|
|
@app.get("/api/feeds/export")
|
|
def export_feeds(session: Session = Depends(get_session), _: User = Depends(require_auth)):
|
|
feeds = session.exec(select(Feed).order_by(Feed.id)).all()
|
|
xml = opml.export_opml(feeds)
|
|
return Response(
|
|
xml,
|
|
media_type="text/x-opml",
|
|
headers={"Content-Disposition": 'attachment; filename="feeds.opml"'},
|
|
)
|
|
|
|
|
|
@app.post("/api/feeds/import")
|
|
async def import_feeds(
|
|
file: UploadFile,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_admin),
|
|
):
|
|
raw = (await file.read()).decode("utf-8", errors="replace")
|
|
items = opml.parse_opml(raw)
|
|
existing = {f.url for f in session.exec(select(Feed)).all()}
|
|
added = 0
|
|
for item in items:
|
|
if item["url"] in existing:
|
|
continue
|
|
session.add(Feed(**item))
|
|
existing.add(item["url"])
|
|
added += 1
|
|
session.commit()
|
|
return {"ok": True, "added": added, "total": len(items)}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# API: history & stats
|
|
# --------------------------------------------------------------------------- #
|
|
@app.get("/api/history")
|
|
def history(
|
|
limit: int = 100,
|
|
q: str = "",
|
|
only_errors: bool = False,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_auth),
|
|
):
|
|
limit = min(500, max(1, limit))
|
|
query = select(Notification)
|
|
if q.strip():
|
|
like = f"%{q.strip()}%"
|
|
query = query.where(
|
|
Notification.title.ilike(like) | Notification.feed_title.ilike(like)
|
|
)
|
|
if only_errors:
|
|
query = query.where(Notification.ok == False) # noqa: E712
|
|
notes = session.exec(
|
|
query.order_by(Notification.created_at.desc()).limit(limit)
|
|
).all()
|
|
return [
|
|
{
|
|
"id": n.id,
|
|
"feed_title": n.feed_title,
|
|
"title": n.title,
|
|
"link": n.link,
|
|
"channels": n.channels,
|
|
"ok": n.ok,
|
|
"detail": n.detail,
|
|
"created_at": n.created_at.isoformat(),
|
|
}
|
|
for n in notes
|
|
]
|
|
|
|
|
|
@app.delete("/api/history")
|
|
def clear_history(
|
|
session: Session = Depends(get_session), _: User = Depends(require_admin)
|
|
):
|
|
for note in session.exec(select(Notification)).all():
|
|
session.delete(note)
|
|
session.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/stats")
|
|
def stats(session: Session = Depends(get_session), _: User = Depends(require_auth)):
|
|
feeds = session.exec(select(Feed)).all()
|
|
total_sent = session.exec(
|
|
select(func.count()).select_from(Notification).where(Notification.ok == True) # noqa: E712
|
|
).one()
|
|
total_failed = session.exec(
|
|
select(func.count()).select_from(Notification).where(Notification.ok == False) # noqa: E712
|
|
).one()
|
|
return {
|
|
"feeds_total": len(feeds),
|
|
"feeds_enabled": sum(1 for f in feeds if f.enabled),
|
|
"feeds_failing": sum(1 for f in feeds if f.error_streak > 0),
|
|
"notifications_sent": total_sent,
|
|
"notifications_failed": total_failed,
|
|
}
|
|
|
|
|
|
@app.get("/api/stats/activity")
|
|
def activity(
|
|
days: int = 14,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_auth),
|
|
):
|
|
"""Notification counts grouped by day for the last `days` days."""
|
|
days = min(90, max(1, days))
|
|
day = func.date(Notification.created_at)
|
|
rows = session.exec(
|
|
select(
|
|
day,
|
|
func.sum(func.cast(Notification.ok, Integer)),
|
|
func.count(),
|
|
).group_by(day)
|
|
).all()
|
|
by_day = {str(d): (int(ok or 0), int(total)) for d, ok, total in rows}
|
|
|
|
out = []
|
|
today = datetime.now(timezone.utc).date()
|
|
for i in range(days - 1, -1, -1):
|
|
d = (today - timedelta(days=i)).isoformat()
|
|
sent, total = by_day.get(d, (0, 0))
|
|
out.append({"date": d, "sent": sent, "failed": total - sent})
|
|
return out
|
|
|
|
|
|
@app.post("/api/preview")
|
|
async def preview(
|
|
data: PreviewIn,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_auth),
|
|
):
|
|
try:
|
|
return await fetch_preview(data.url, data.filter_include, data.filter_exclude)
|
|
except ValueError as exc:
|
|
raise HTTPException(400, str(exc))
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(502, f"Не удалось загрузить ленту: {exc}")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# API: settings
|
|
# --------------------------------------------------------------------------- #
|
|
@app.get("/api/settings")
|
|
def read_settings(session: Session = Depends(get_session), _: User = Depends(require_auth)):
|
|
s = get_settings(session)
|
|
return {
|
|
"default_ntfy_server": s.default_ntfy_server,
|
|
"default_ntfy_token": s.default_ntfy_token,
|
|
"default_ntfy_username": s.default_ntfy_username,
|
|
"default_ntfy_password": s.default_ntfy_password,
|
|
"check_interval": s.check_interval,
|
|
"auth_enabled": s.auth_enabled,
|
|
"telegram_enabled": s.telegram_enabled,
|
|
"telegram_token": s.telegram_token,
|
|
"telegram_chat_id": s.telegram_chat_id,
|
|
"webhook_enabled": s.webhook_enabled,
|
|
"webhook_url": s.webhook_url,
|
|
"alerts_enabled": s.alerts_enabled,
|
|
"alert_topic": s.alert_topic,
|
|
"alert_threshold": s.alert_threshold,
|
|
}
|
|
|
|
|
|
@app.put("/api/settings")
|
|
def write_settings(
|
|
data: SettingsIn,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_admin),
|
|
):
|
|
s = get_settings(session)
|
|
interval_changed = s.check_interval != data.check_interval
|
|
|
|
if data.auth_enabled and not session.exec(select(User)).first():
|
|
raise HTTPException(400, "Создайте хотя бы одного пользователя перед включением авторизации")
|
|
|
|
s.default_ntfy_server = data.default_ntfy_server.strip() or "https://ntfy.sh"
|
|
s.default_ntfy_token = data.default_ntfy_token.strip()
|
|
s.default_ntfy_username = data.default_ntfy_username.strip()
|
|
s.default_ntfy_password = data.default_ntfy_password
|
|
s.check_interval = data.check_interval
|
|
s.auth_enabled = data.auth_enabled
|
|
s.telegram_enabled = data.telegram_enabled
|
|
s.telegram_token = data.telegram_token.strip()
|
|
s.telegram_chat_id = data.telegram_chat_id.strip()
|
|
s.webhook_enabled = data.webhook_enabled
|
|
s.webhook_url = data.webhook_url.strip()
|
|
s.alerts_enabled = data.alerts_enabled
|
|
s.alert_topic = data.alert_topic.strip()
|
|
s.alert_threshold = data.alert_threshold
|
|
|
|
session.add(s)
|
|
session.commit()
|
|
if interval_changed:
|
|
scheduler.reschedule(data.check_interval)
|
|
return {"ok": True}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# API: users
|
|
# --------------------------------------------------------------------------- #
|
|
def _user_dict(u: User) -> dict:
|
|
return {"id": u.id, "username": u.username, "role": u.role}
|
|
|
|
|
|
@app.get("/api/users")
|
|
def list_users(session: Session = Depends(get_session), _: User = Depends(require_admin)):
|
|
users = session.exec(select(User).order_by(User.id)).all()
|
|
return [_user_dict(u) for u in users]
|
|
|
|
|
|
@app.post("/api/users")
|
|
def create_user(
|
|
data: UserIn,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_admin),
|
|
):
|
|
if session.exec(select(User).where(User.username == data.username)).first():
|
|
raise HTTPException(400, "Пользователь с таким логином уже существует")
|
|
if not data.password.strip():
|
|
raise HTTPException(400, "Пароль обязателен для нового пользователя")
|
|
user = User(
|
|
username=data.username,
|
|
password_hash=hash_password(data.password),
|
|
role=data.role,
|
|
)
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
return _user_dict(user)
|
|
|
|
|
|
@app.put("/api/users/{user_id}")
|
|
def update_user(
|
|
user_id: int,
|
|
data: UserIn,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_admin),
|
|
):
|
|
user = session.get(User, user_id)
|
|
if user is None:
|
|
raise HTTPException(404, "Пользователь не найден")
|
|
# Don't allow demoting the last remaining admin.
|
|
if user.role == "admin" and data.role != "admin":
|
|
admins = session.exec(select(User).where(User.role == "admin")).all()
|
|
if len(admins) <= 1:
|
|
raise HTTPException(400, "Нельзя понизить последнего администратора")
|
|
user.username = data.username
|
|
user.role = data.role
|
|
if data.password.strip():
|
|
user.password_hash = hash_password(data.password)
|
|
session.add(user)
|
|
session.commit()
|
|
return _user_dict(user)
|
|
|
|
|
|
@app.delete("/api/users/{user_id}")
|
|
def delete_user(
|
|
user_id: int,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
me: User = Depends(require_admin),
|
|
):
|
|
user = session.get(User, user_id)
|
|
if user is None:
|
|
raise HTTPException(404, "Пользователь не найден")
|
|
if user.id == me.id:
|
|
raise HTTPException(400, "Нельзя удалить самого себя")
|
|
if user.role == "admin":
|
|
admins = session.exec(select(User).where(User.role == "admin")).all()
|
|
if len(admins) <= 1:
|
|
raise HTTPException(400, "Нельзя удалить последнего администратора")
|
|
session.delete(user)
|
|
session.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# API: test notification
|
|
# --------------------------------------------------------------------------- #
|
|
@app.post("/api/test")
|
|
async def test_notification(
|
|
data: TestIn,
|
|
session: Session = Depends(get_session),
|
|
_: User = Depends(require_auth),
|
|
):
|
|
s = get_settings(session)
|
|
server = data.server.strip() or s.default_ntfy_server
|
|
if not data.topic.strip():
|
|
raise HTTPException(400, "Укажите тему")
|
|
# Use a custom server's own auth only if it matches the default; otherwise
|
|
# fall back to the configured default-server credentials.
|
|
try:
|
|
await ntfy.publish(
|
|
server=server,
|
|
topic=data.topic,
|
|
title="RSS to ntfy",
|
|
message="Тестовое уведомление — всё работает!",
|
|
tags="white_check_mark",
|
|
priority=3,
|
|
token=s.default_ntfy_token,
|
|
username=s.default_ntfy_username,
|
|
password=s.default_ntfy_password,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(502, f"Не удалось отправить: {exc}")
|
|
return {"ok": True, "sent_to": f"{server.rstrip('/')}/{data.topic}"}
|
|
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def http_exc_handler(request: Request, exc: HTTPException):
|
|
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
|