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