Features: feed CRUD, per-feed ntfy target (incl. private servers), Telegram/webhook channels, keyword filters, image attachments, per-feed intervals, OPML import/export, notification history & stats, users with roles, admin alerts, RU/EN i18n, light/dark theme, notification preview, history search, activity chart. Dockerized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+539
@@ -0,0 +1,539 @@
|
||||
"""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,
|
||||
"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.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, "Укажите тему")
|
||||
try:
|
||||
await ntfy.publish(
|
||||
server=server,
|
||||
topic=data.topic,
|
||||
title="RSS to ntfy",
|
||||
message="Тестовое уведомление — всё работает!",
|
||||
tags="white_check_mark",
|
||||
priority=3,
|
||||
)
|
||||
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)
|
||||
Reference in New Issue
Block a user