"""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, "Укажите тему") # Prefer auth typed in the form right now; fall back to saved defaults so # the test works whether or not settings were saved first. token = s.default_ntfy_token if data.token is None else data.token.strip() username = s.default_ntfy_username if data.username is None else data.username.strip() password = s.default_ntfy_password if data.password is None else data.password try: await ntfy.publish( server=server, topic=data.topic, title="RSS to ntfy", message="Тестовое уведомление — всё работает!", tags="white_check_mark", priority=3, token=token, username=username, password=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)