From 146c229e454c0388d03cf1bbe4192c7b377514c5 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 30 Oct 2026 13:02:36 +0100 Subject: [PATCH 1/2] Added authentication header and multi user support to the royalroad check --- database.sql | 20 ++++++--- pyproject.toml | 2 +- requirements.txt | 1 + scripts/filter_requirements.py | 1 + src/auth_config.py | 13 ++++++ src/feed_handler.py | 34 ++++++++++----- src/main.py | 79 +++++++++++++++++++++++++--------- 7 files changed, 113 insertions(+), 37 deletions(-) create mode 100644 src/auth_config.py diff --git a/database.sql b/database.sql index d430c63..9e11ebf 100644 --- a/database.sql +++ b/database.sql @@ -1,5 +1,15 @@ -CREATE TABLE stories (id INT AUTO_INCREMENT PRIMARY KEY, - royalroadId INT NOT NULL, - lastChapter INT DEFAULT 0, - active BOOLEAN NOT NULL DEFAULT TRUE - ); \ No newline at end of file +CREATE TABLE `rss_feeds` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) NOT NULL, + `type` enum('royalroad','gitea') COLLATE utf8mb4_unicode_ci NOT NULL, + `feed_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `rss_feed` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL, + `last_entry` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `active` tinyint(1) NOT NULL DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rss_feed` (`rss_feed`), + UNIQUE KEY `uq_user_rss_feed` (`user_id`,`rss_feed`), + KEY `idx_active_type` (`active`,`type`), + CONSTRAINT `fk_rss_feeds_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE +) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0cd9699..753e59b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ ignore = ["E501"] [tool.ruff.lint.isort] known-first-party = ["db_module", "metrics_server", "rabbitmq_handler", "secret_manager", - "simple_logger_handler", "uvicorn_logger_config", "validator","send_notifications"] + "simple_logger_handler", "service_auth", "send_notifications"] [tool.ruff.format] indent-style = "tab" diff --git a/requirements.txt b/requirements.txt index c8c8abb..bb6ef72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ idna==3.20 lib-db-module==0.3.0 lib-secret-manager==0.3.1 lib-send-notifications==0.1.0 +lib-service-auth==0.1.0 mysql-connector-python==9.4.0 opentelemetry-api==1.45.1 prometheus_client==0.26.0 diff --git a/scripts/filter_requirements.py b/scripts/filter_requirements.py index 3ed444f..03762f7 100644 --- a/scripts/filter_requirements.py +++ b/scripts/filter_requirements.py @@ -12,6 +12,7 @@ SKIP = { "lib-secret-manager", "lib-send-notifications", "simple-logger-handler", + "lib-service-auth" } NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)") diff --git a/src/auth_config.py b/src/auth_config.py new file mode 100644 index 0000000..363b938 --- /dev/null +++ b/src/auth_config.py @@ -0,0 +1,13 @@ +from enum import StrEnum + +from service_auth import KeySource, ServiceAuth + + +class Caller(StrEnum): + GATEWAY = "gateway" + SCHEDULER = "scheduler" + +auth = ServiceAuth({ + Caller.GATEWAY: KeySource("GATEWAY_KEY", "/etc/secrets/gateway_key"), + Caller.SCHEDULER: KeySource("SCHEDULER_KEY", "/etc/secrets/scheduler_key"), +}) diff --git a/src/feed_handler.py b/src/feed_handler.py index 23f1ed9..bd5d789 100644 --- a/src/feed_handler.py +++ b/src/feed_handler.py @@ -1,9 +1,9 @@ import time from typing import Tuple - +from pydantic import BaseModel import feedparser import requests - +from dataclasses import dataclass from simple_logger_handler import setup_logger logger = setup_logger(__name__) @@ -11,6 +11,15 @@ logger = setup_logger(__name__) _GITEA_INSTANCE_BASE_URL="https://git.gansejunge.com" _GITEA_REPOSITORY_OWNER="utility" + +@dataclass(frozen=True) +class RoyalRoadStory(BaseModel): + chapter_title: str + link: str + story_title: str + chapter_id: str + + def get_rss_feed_data(rss_feed_url: str, max_retries: int = 3) -> feedparser.FeedParserDict: """ Fetches information from any RSS feed, with retries on network-related errors. @@ -70,7 +79,7 @@ def grab_gitea_release_information(rss_feed_url: str) -> Tuple[str, str]: logger.debug(f"[Feed] Latest release for feed '{rss_feed_url}': {title} -> {link}") return title, link -def grab_royal_road_chapter_information(rss_feed_url: str) -> Tuple[str, str, str, str]: +def grab_royal_road_chapter_information(rss_feed_url: str) -> RoyalRoadStory : """ Fetches the latest chapter information from a Royalroad RSS feed. @@ -85,15 +94,20 @@ def grab_royal_road_chapter_information(rss_feed_url: str) -> Tuple[str, str, st """ feed = get_rss_feed_data(rss_feed_url) latest_chapter_data = feed.entries[0] - chapter_link = latest_chapter_data["link"] - story_title = feed.feed.title chapter_title_with_story_title = latest_chapter_data["title"] - chapter_title = chapter_title_with_story_title.removeprefix(story_title).removeprefix(" - ") - chapter_id = str(latest_chapter_data["id"]) # str so it compares equal to the DB value + chapter_title = chapter_title_with_story_title.removeprefix(feed.feed.title).removeprefix(" - ") - logger.info(f"[Feed] Latest chapter for story '{story_title}': {chapter_title}") - logger.debug(f"[Feed] Latest chapter for story '{story_title}' (RSS URL {rss_feed_url}): {chapter_title} (ID {chapter_id}) -> {chapter_link}") - return chapter_title, chapter_link, story_title, chapter_id + royal_road_story = RoyalRoadStory ( + chapter_title = chapter_title, + link = latest_chapter_data["link"], + story_title = feed.feed.title, + chapter_id = str(latest_chapter_data["id"]) + ) + + logger.info(f"[Feed] Latest chapter for story '{royal_road_story.story_title}': {royal_road_story.chapter_title}") + logger.debug(f"[Feed] Latest chapter for story '{royal_road_story.story_title}' (RSS URL {rss_feed_url}):" + f"{royal_road_story.chapter_title} (ID {royal_road_story.chapter_id}) -> {royal_road_story.link}") + return royal_road_story def build_gitea_runner(version: str, gitea_token: str) -> bool: url = ( diff --git a/src/main.py b/src/main.py index 3c7987a..351ffa7 100644 --- a/src/main.py +++ b/src/main.py @@ -3,14 +3,16 @@ from contextlib import asynccontextmanager import os import uvicorn -from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter from db_module import close_connection_pool, create_connection_pool, get_db, start_healthcheck_thread -from feed_handler import grab_gitea_release_information, grab_royal_road_chapter_information, build_gitea_runner +from feed_handler import grab_gitea_release_information, grab_royal_road_chapter_information, build_gitea_runner, RoyalRoadStory from metrics_server import REQUEST_COUNTER, metrics_api from secret_manager import cleanup_secret_files, get_secret -from send_notifications import NotificationClient +from send_notifications import NotificationClient, NotificationUnavailableError, NotificationError from simple_logger_handler import LOG_LEVEL, setup_logger +from auth_config import Caller, auth +from service_auth import UserId logger = setup_logger(__name__) @@ -19,7 +21,7 @@ logger = setup_logger(__name__) async def lifespan(app: FastAPI): logger.info("[FastAPI] Starting application...") app.state.notifier = NotificationClient() - app.state.gitea_token = get_secret(os.getenv("GITEA_TOKEN",""),"/etc/secrets/gitea_token") + app.state.gitea_token = get_secret("GITEA_TOKEN","/etc/secrets/gitea_token") logger.info("[FastAPI] Creating MySQL connection pool...") create_connection_pool("royalRoadPool") @@ -44,7 +46,8 @@ api = FastAPI( title="Docker Repository Query", description="Queries Dockerhub and GHCR for new docker images", version="1.0.0", - lifespan=lifespan + lifespan=lifespan, + dependencies=[Depends(auth.authenticate)], ) @api.middleware("http") @@ -63,7 +66,11 @@ async def prometheus_middleware(request: Request, call_next): logger.debug(f"[FastAPI] Counter incremented for {request.method} {request.url.path} [{status}]") return response -@api.get("/gitea") + +user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))]) +scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))]) + +@scheduler_router.get("/gitea") def get_release_information(request: Request, db = Depends(get_db)): """ Checks for new Gitea releases and updates the database accordingly. @@ -133,7 +140,7 @@ def get_release_information(request: Request, db = Depends(get_db)): logger.info(f"[FastAPI] Gitea check complete: {results}") return results -@api.get("/royalroad") +@scheduler_router.get("/royalroad") def get_chapters(request: Request, db = Depends(get_db)): """ Checks for new Royalroad chapters and updates the database accordingly. @@ -160,26 +167,56 @@ def get_chapters(request: Request, db = Depends(get_db)): feeds = cursor.fetchall() logger.debug(f"[FastAPI] Found {len(feeds)} active stories to check.") + cache: dict[str, str | None] = {} for feed_id, rss_feed, last_entry in feeds: results["checked"] += 1 try: - chapter_title, link, story_title, online_latest_entry = grab_royal_road_chapter_information(rss_feed) - logger.debug(f"[FastAPI] ID:{feed_id},RSS Feed {rss_feed}: db={last_entry}, online={online_latest_entry}") + story = grab_royal_road_chapter_information(rss_feed) + logger.debug(f"[FastAPI] ID:{feed_id},RSS Feed {rss_feed}: db={last_entry}, online={story.chapter_id}") - if online_latest_entry == last_entry: + if story.chapter_id == last_entry: continue - logger.info(f"[FastAPI] New chapter detected for story ID {feed_id}: {story_title}") - request.app.state.notifier.send( - title=story_title, - body=chapter_title, - link=link, - category="royal-road" - ) - cursor.execute( - "UPDATE rss_feeds SET last_entry = %s WHERE id = %s", - (online_latest_entry, feed_id), - ) + logger.info(f"[FastAPI] New chapter detected for story ID {feed_id}: {story.story_title}") + with db.cursor() as cursor: + cursor.execute ( + "SELECT user_id, feed_name " + "FROM rss_feed_subscriptions " + "WHERE rss_feed_id = %s AND active = 1", + (feed_id,), + ) + subscribers = cursor.fetchall() + + sent = 0 + failed = 0 + for user_id, feed_name in subscribers: + try: + request.app.state.notifier.send( + title=feed_name or story.story_title, + body=story.chapter_title, + link=story.link, + category="royal-road", + user_id=user_id, + ) + send += 1 + except NotificationUnavailableError as e: + failed = len(subscribers) - sent + logger.error (f"[FastAPI] Notifications backend unavailable, skipping" + f"{failed} remaining user(s) for story={story.story_title}") + break + except NotificationError as e: + failed += 1 + logger.error(f"[FastAPI] Failed to send notification to user_id={user_id} for story={story.story_title}" + f"status={getattr(e, 'status_code',None)}") + if subscribers and sent == 0: + results["errors"]+=failed + logger.error(f"[FastAPI] All notifications failed for Feed ID:{feed_id}, will retry later") + continue + with db.cursor() as cursor: + cursor.execute( + "UPDATE rss_feeds SET last_entry = %s WHERE id = %s", + (story.chapter_id, feed_id), + ) db.commit() logger.debug(f"[FastAPI] Notification sent and database updated for story ID {feed_id}") results["new"] += 1 -- 2.43.0 From 79fc87c9c59c525ec73fbe0383e3fab4e3969031 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 30 Oct 2026 16:35:09 +0100 Subject: [PATCH 2/2] Added multi user support for the Gitea check --- database.sql | 26 +++-- src/feed_handler.py | 6 +- src/main.py | 230 ++++++++++++++++++++++++++------------------ 3 files changed, 157 insertions(+), 105 deletions(-) diff --git a/database.sql b/database.sql index 9e11ebf..a5cdfa3 100644 --- a/database.sql +++ b/database.sql @@ -1,15 +1,25 @@ CREATE TABLE `rss_feeds` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` bigint(20) NOT NULL, - `type` enum('royalroad','gitea') COLLATE utf8mb4_unicode_ci NOT NULL, - `feed_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, - `rss_feed` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL, - `last_entry` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `type` enum('royalroad','gitea') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `rss_feed` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `last_entry` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `active` tinyint(1) NOT NULL DEFAULT '1', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uq_rss_feed` (`rss_feed`), - UNIQUE KEY `uq_user_rss_feed` (`user_id`,`rss_feed`), - KEY `idx_active_type` (`active`,`type`), - CONSTRAINT `fk_rss_feeds_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE + KEY `idx_active_type` (`active`,`type`) +) + +CREATE TABLE `rss_feed_subscriptions` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) NOT NULL, + `rss_feed_id` int(10) unsigned NOT NULL, + `feed_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `active` tinyint(1) NOT NULL DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_user_feed` (`user_id`,`rss_feed_id`), + KEY `idx_feed_active` (`rss_feed_id`,`active`), + CONSTRAINT `fk_sub_feed` FOREIGN KEY (`rss_feed_id`) REFERENCES `rss_feeds` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sub_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE ) \ No newline at end of file diff --git a/src/feed_handler.py b/src/feed_handler.py index bd5d789..4127156 100644 --- a/src/feed_handler.py +++ b/src/feed_handler.py @@ -1,9 +1,11 @@ import time +from dataclasses import dataclass from typing import Tuple -from pydantic import BaseModel + import feedparser import requests -from dataclasses import dataclass +from pydantic import BaseModel + from simple_logger_handler import setup_logger logger = setup_logger(__name__) diff --git a/src/main.py b/src/main.py index 351ffa7..5ae4dd2 100644 --- a/src/main.py +++ b/src/main.py @@ -1,18 +1,21 @@ import asyncio -from contextlib import asynccontextmanager import os +from contextlib import asynccontextmanager import uvicorn -from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request +from auth_config import Caller, auth from db_module import close_connection_pool, create_connection_pool, get_db, start_healthcheck_thread -from feed_handler import grab_gitea_release_information, grab_royal_road_chapter_information, build_gitea_runner, RoyalRoadStory +from feed_handler import ( + build_gitea_runner, + grab_gitea_release_information, + grab_royal_road_chapter_information, +) from metrics_server import REQUEST_COUNTER, metrics_api from secret_manager import cleanup_secret_files, get_secret -from send_notifications import NotificationClient, NotificationUnavailableError, NotificationError +from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError from simple_logger_handler import LOG_LEVEL, setup_logger -from auth_config import Caller, auth -from service_auth import UserId logger = setup_logger(__name__) @@ -43,8 +46,8 @@ async def lifespan(app: FastAPI): logger.info("[FastAPI] Shutdown complete.") api = FastAPI( - title="Docker Repository Query", - description="Queries Dockerhub and GHCR for new docker images", + title="Service Rss Feeds", + description="Queries RSS feeds for updates", version="1.0.0", lifespan=lifespan, dependencies=[Depends(auth.authenticate)], @@ -70,7 +73,7 @@ async def prometheus_middleware(request: Request, call_next): user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))]) scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))]) -@scheduler_router.get("/gitea") +@scheduler_router.post("/gitea") def get_release_information(request: Request, db = Depends(get_db)): """ Checks for new Gitea releases and updates the database accordingly. @@ -90,48 +93,79 @@ def get_release_information(request: Request, db = Depends(get_db)): results = {"checked": 0, "new": 0, "errors": 0} with db.cursor() as cursor: cursor.execute( - "SELECT id, rss_feed, feed_name, last_entry " + "SELECT id, rss_feed, last_entry " "FROM rss_feeds WHERE active = 1 AND type = %s", ("gitea",), ) feeds = cursor.fetchall() logger.debug(f"[FastAPI] Found {len(feeds)} active Gitea feeds to check.") - for feed_id, rss_feed, feed_name, last_entry in feeds: - results["checked"] += 1 - try: - release_title, link = grab_gitea_release_information(rss_feed) - logger.debug(f"[FastAPI] ID:{feed_id},RSS Feed {rss_feed}: db={last_entry}, online={link}") + for feed_id, rss_feed, last_entry in feeds: + results["checked"] += 1 + try: + release_title, link = grab_gitea_release_information(rss_feed) + logger.debug(f"[FastAPI] ID:{feed_id},RSS Feed {rss_feed}: db={last_entry}, online={link}") - if link == last_entry: - continue + if link == last_entry: + continue - logger.info(f"[FastAPI] Release change for ID {feed_id}: {release_title}") - request.app.state.notifier.send( - title=feed_name, - body=release_title, - link=link, - category="utility" + logger.info(f"[FastAPI] Release change for ID {feed_id}: {release_title}") + + with db.cursor() as cursor: + cursor.execute( + "SELECT user_id, feed_name " + "FROM rss_feed_subscriptions " + "WHERE rss_feed_id = %s AND active = 1", + (feed_id,), ) + subscribers = cursor.fetchall() + + sent = 0 + failed = 0 + for user_id,feed_name in subscribers: + try: + repo = link.split("/releases")[0].removeprefix("https://") + request.app.state.notifier.send( + title=feed_name or repo, + body=release_title, + link=link, + category="utility", + user_id=user_id, + ) + sent += 1 + except NotificationUnavailableError: + failed = len(subscribers) - sent + logger.error(f"[FastAPI] Notifications backend unavailable, skipping" + f"{failed} remaining user(s) for feed_id={feed_id}") + break + except NotificationError as e: + failed += 1 + logger.error(f"[FastAPI] Failed to send notification to user_id={user_id} for feed_id={feed_id} " + f"status={getattr(e, 'status_code',None)}") + if subscribers and sent == 0: + results["errors"] += 1 + logger.error(f"[FastAPI] All notifications failed for Feed ID:{feed_id}, will retry later") + continue + with db.cursor() as cursor: cursor.execute( "UPDATE rss_feeds SET last_entry = %s WHERE id = %s", (link, feed_id), ) - db.commit() - logger.debug(f"[FastAPI] Notification sent and database updated for Gitea feed ID {feed_id}") - results["new"] += 1 + db.commit() + logger.debug(f"[FastAPI] Notification sent and database updated for Gitea feed ID {feed_id}") + results["new"] += 1 - if link.startswith("https://gitea.com/gitea/runner/releases/tag/v"): - gitea_build_success = build_gitea_runner(release_title, request.app.state.gitea_token) - logger.info(f"[FastAPI] Gitea build ran successfully:{gitea_build_success}") + if link.startswith("https://gitea.com/gitea/runner/releases/tag/v"): + gitea_build_success = build_gitea_runner(release_title, request.app.state.gitea_token) + logger.info(f"[FastAPI] Gitea build ran successfully:{gitea_build_success}") - except Exception as e: - db.rollback() - results["errors"] += 1 - logger.error( - f"[FastAPI] Failed for feed ID {feed_id} ({rss_feed}): {e}", - exc_info=True, - ) + except Exception as e: + db.rollback() + results["errors"] += 1 + logger.error( + f"[FastAPI] Failed for feed ID {feed_id} ({rss_feed}): {e}", + exc_info=True, + ) if results["errors"]: logger.warning(f"[FastAPI] Gitea check finished with errors: {results}") @@ -140,7 +174,7 @@ def get_release_information(request: Request, db = Depends(get_db)): logger.info(f"[FastAPI] Gitea check complete: {results}") return results -@scheduler_router.get("/royalroad") +@scheduler_router.post("/royalroad") def get_chapters(request: Request, db = Depends(get_db)): """ Checks for new Royalroad chapters and updates the database accordingly. @@ -167,67 +201,66 @@ def get_chapters(request: Request, db = Depends(get_db)): feeds = cursor.fetchall() logger.debug(f"[FastAPI] Found {len(feeds)} active stories to check.") - cache: dict[str, str | None] = {} - for feed_id, rss_feed, last_entry in feeds: - results["checked"] += 1 - try: - story = grab_royal_road_chapter_information(rss_feed) - logger.debug(f"[FastAPI] ID:{feed_id},RSS Feed {rss_feed}: db={last_entry}, online={story.chapter_id}") + for feed_id, rss_feed, last_entry in feeds: + results["checked"] += 1 + try: + story = grab_royal_road_chapter_information(rss_feed) + logger.debug(f"[FastAPI] ID:{feed_id},RSS Feed {rss_feed}: db={last_entry}, online={story.chapter_id}") - if story.chapter_id == last_entry: - continue + if story.chapter_id == last_entry: + continue - logger.info(f"[FastAPI] New chapter detected for story ID {feed_id}: {story.story_title}") - with db.cursor() as cursor: - cursor.execute ( - "SELECT user_id, feed_name " - "FROM rss_feed_subscriptions " - "WHERE rss_feed_id = %s AND active = 1", - (feed_id,), - ) - subscribers = cursor.fetchall() - - sent = 0 - failed = 0 - for user_id, feed_name in subscribers: - try: - request.app.state.notifier.send( - title=feed_name or story.story_title, - body=story.chapter_title, - link=story.link, - category="royal-road", - user_id=user_id, - ) - send += 1 - except NotificationUnavailableError as e: - failed = len(subscribers) - sent - logger.error (f"[FastAPI] Notifications backend unavailable, skipping" - f"{failed} remaining user(s) for story={story.story_title}") - break - except NotificationError as e: - failed += 1 - logger.error(f"[FastAPI] Failed to send notification to user_id={user_id} for story={story.story_title}" - f"status={getattr(e, 'status_code',None)}") - if subscribers and sent == 0: - results["errors"]+=failed - logger.error(f"[FastAPI] All notifications failed for Feed ID:{feed_id}, will retry later") - continue - with db.cursor() as cursor: - cursor.execute( - "UPDATE rss_feeds SET last_entry = %s WHERE id = %s", - (story.chapter_id, feed_id), - ) - db.commit() - logger.debug(f"[FastAPI] Notification sent and database updated for story ID {feed_id}") - results["new"] += 1 - - except Exception as e: - db.rollback() - results["errors"] += 1 - logger.error( - f"[Royalroad] Failed for feed ID {feed_id} ({rss_feed}): {e}", - exc_info=True, + logger.info(f"[FastAPI] New chapter detected for story ID {feed_id}: {story.story_title}") + with db.cursor() as cursor: + cursor.execute ( + "SELECT user_id, feed_name " + "FROM rss_feed_subscriptions " + "WHERE rss_feed_id = %s AND active = 1", + (feed_id,), ) + subscribers = cursor.fetchall() + + sent = 0 + failed = 0 + for user_id, feed_name in subscribers: + try: + request.app.state.notifier.send( + title=feed_name or story.story_title, + body=story.chapter_title, + link=story.link, + category="royal-road", + user_id=user_id, + ) + sent += 1 + except NotificationUnavailableError: + failed = len(subscribers) - sent + logger.error (f"[FastAPI] Notifications backend unavailable, skipping " + f"{failed} remaining user(s) for story={story.story_title}") + break + except NotificationError as e: + failed += 1 + logger.error(f"[FastAPI] Failed to send notification to user_id={user_id} for story={story.story_title} " + f"status={getattr(e, 'status_code',None)}") + if subscribers and sent == 0: + results["errors"] += 1 + logger.error(f"[FastAPI] All notifications failed for Feed ID:{feed_id}, will retry later") + continue + with db.cursor() as cursor: + cursor.execute( + "UPDATE rss_feeds SET last_entry = %s WHERE id = %s", + (story.chapter_id, feed_id), + ) + db.commit() + logger.debug(f"[FastAPI] Notification sent and database updated for story ID {feed_id}") + results["new"] += 1 + + except Exception as e: + db.rollback() + results["errors"] += 1 + logger.error( + f"[Royalroad] Failed for feed ID {feed_id} ({rss_feed}): {e}", + exc_info=True, + ) if results["errors"]: logger.warning(f"[FastAPI] Royalroad check finished with errors: {results}") @@ -236,9 +269,16 @@ def get_chapters(request: Request, db = Depends(get_db)): logger.info(f"[FastAPI] Royalroad check complete: {results}") return results +api.include_router(user_router) +api.include_router(scheduler_router) + async def start_servers(): logger.info("[Server] Starting main API (port 5000) and metrics server (port 9000)...") logger.info(f"[Server] Build commit id={os.getenv("COMMIT_ID","unknown")}") + + auth.load_keys(api, Caller.GATEWAY, Caller.SCHEDULER) + cleanup_secret_files(auth.key_paths(Caller.GATEWAY, Caller.SCHEDULER)) + config_main = uvicorn.Config(api, host="0.0.0.0", port=5000, log_level=LOG_LEVEL.lower(), log_config=None) config_metrics = uvicorn.Config(metrics_api, host="0.0.0.0", port=9000, log_level=LOG_LEVEL.lower(), log_config=None) -- 2.43.0