Merge pull request 'Feature/user id support' (#6) from feature/user-id-support into main
pip-audit / audit (push) Successful in 30s
pip-audit / notify (push) Skipped

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
florian committed 2026-10-30 15:39:27 +00:00
commit 49af670d10
7 files changed
+166 -38

No files matched your search

+25 -5
View File
@@ -1,5 +1,25 @@
CREATE TABLE stories (id INT AUTO_INCREMENT PRIMARY KEY, CREATE TABLE `rss_feeds` (
royalroadId INT NOT NULL, `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
lastChapter INT DEFAULT 0, `type` enum('royalroad','gitea') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE `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`),
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
)
+1 -1
View File
@@ -13,7 +13,7 @@ ignore = ["E501"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = ["db_module", "metrics_server", "rabbitmq_handler", "secret_manager", 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] [tool.ruff.format]
indent-style = "tab" indent-style = "tab"
+1
View File
@@ -13,6 +13,7 @@ idna==3.20
lib-db-module==0.3.0 lib-db-module==0.3.0
lib-secret-manager==0.3.1 lib-secret-manager==0.3.1
lib-send-notifications==0.1.0 lib-send-notifications==0.1.0
lib-service-auth==0.1.0
mysql-connector-python==9.4.0 mysql-connector-python==9.4.0
opentelemetry-api==1.45.1 opentelemetry-api==1.45.1
prometheus_client==0.26.0 prometheus_client==0.26.0
+1
View File
@@ -12,6 +12,7 @@ SKIP = {
"lib-secret-manager", "lib-secret-manager",
"lib-send-notifications", "lib-send-notifications",
"simple-logger-handler", "simple-logger-handler",
"lib-service-auth"
} }
NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)") NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
+13
View File
@@ -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"),
})
+24 -8
View File
@@ -1,8 +1,10 @@
import time import time
from dataclasses import dataclass
from typing import Tuple from typing import Tuple
import feedparser import feedparser
import requests import requests
from pydantic import BaseModel
from simple_logger_handler import setup_logger from simple_logger_handler import setup_logger
@@ -11,6 +13,15 @@ logger = setup_logger(__name__)
_GITEA_INSTANCE_BASE_URL="https://git.gansejunge.com" _GITEA_INSTANCE_BASE_URL="https://git.gansejunge.com"
_GITEA_REPOSITORY_OWNER="utility" _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: 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. Fetches information from any RSS feed, with retries on network-related errors.
@@ -70,7 +81,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}") logger.debug(f"[Feed] Latest release for feed '{rss_feed_url}': {title} -> {link}")
return 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. Fetches the latest chapter information from a Royalroad RSS feed.
@@ -85,15 +96,20 @@ def grab_royal_road_chapter_information(rss_feed_url: str) -> Tuple[str, str, st
""" """
feed = get_rss_feed_data(rss_feed_url) feed = get_rss_feed_data(rss_feed_url)
latest_chapter_data = feed.entries[0] 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_with_story_title = latest_chapter_data["title"]
chapter_title = chapter_title_with_story_title.removeprefix(story_title).removeprefix(" - ") chapter_title = chapter_title_with_story_title.removeprefix(feed.feed.title).removeprefix(" - ")
chapter_id = str(latest_chapter_data["id"]) # str so it compares equal to the DB value
logger.info(f"[Feed] Latest chapter for story '{story_title}': {chapter_title}") royal_road_story = RoyalRoadStory (
logger.debug(f"[Feed] Latest chapter for story '{story_title}' (RSS URL {rss_feed_url}): {chapter_title} (ID {chapter_id}) -> {chapter_link}") chapter_title = chapter_title,
return chapter_title, chapter_link, story_title, chapter_id 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: def build_gitea_runner(version: str, gitea_token: str) -> bool:
url = ( url = (
+101 -24
View File
@@ -1,15 +1,20 @@
import asyncio import asyncio
from contextlib import asynccontextmanager
import os import os
from contextlib import asynccontextmanager
import uvicorn import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request 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 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 (
build_gitea_runner,
grab_gitea_release_information,
grab_royal_road_chapter_information,
)
from metrics_server import REQUEST_COUNTER, metrics_api from metrics_server import REQUEST_COUNTER, metrics_api
from secret_manager import cleanup_secret_files, get_secret from secret_manager import cleanup_secret_files, get_secret
from send_notifications import NotificationClient from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError
from simple_logger_handler import LOG_LEVEL, setup_logger from simple_logger_handler import LOG_LEVEL, setup_logger
logger = setup_logger(__name__) logger = setup_logger(__name__)
@@ -19,7 +24,7 @@ logger = setup_logger(__name__)
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("[FastAPI] Starting application...") logger.info("[FastAPI] Starting application...")
app.state.notifier = NotificationClient() 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...") logger.info("[FastAPI] Creating MySQL connection pool...")
create_connection_pool("royalRoadPool") create_connection_pool("royalRoadPool")
@@ -41,10 +46,11 @@ async def lifespan(app: FastAPI):
logger.info("[FastAPI] Shutdown complete.") logger.info("[FastAPI] Shutdown complete.")
api = FastAPI( api = FastAPI(
title="Docker Repository Query", title="Service Rss Feeds",
description="Queries Dockerhub and GHCR for new docker images", description="Queries RSS feeds for updates",
version="1.0.0", version="1.0.0",
lifespan=lifespan lifespan=lifespan,
dependencies=[Depends(auth.authenticate)],
) )
@api.middleware("http") @api.middleware("http")
@@ -63,7 +69,11 @@ async def prometheus_middleware(request: Request, call_next):
logger.debug(f"[FastAPI] Counter incremented for {request.method} {request.url.path} [{status}]") logger.debug(f"[FastAPI] Counter incremented for {request.method} {request.url.path} [{status}]")
return response 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.post("/gitea")
def get_release_information(request: Request, db = Depends(get_db)): def get_release_information(request: Request, db = Depends(get_db)):
""" """
Checks for new Gitea releases and updates the database accordingly. Checks for new Gitea releases and updates the database accordingly.
@@ -83,14 +93,14 @@ def get_release_information(request: Request, db = Depends(get_db)):
results = {"checked": 0, "new": 0, "errors": 0} results = {"checked": 0, "new": 0, "errors": 0}
with db.cursor() as cursor: with db.cursor() as cursor:
cursor.execute( 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", "FROM rss_feeds WHERE active = 1 AND type = %s",
("gitea",), ("gitea",),
) )
feeds = cursor.fetchall() feeds = cursor.fetchall()
logger.debug(f"[FastAPI] Found {len(feeds)} active Gitea feeds to check.") logger.debug(f"[FastAPI] Found {len(feeds)} active Gitea feeds to check.")
for feed_id, rss_feed, feed_name, last_entry in feeds: for feed_id, rss_feed, last_entry in feeds:
results["checked"] += 1 results["checked"] += 1
try: try:
release_title, link = grab_gitea_release_information(rss_feed) release_title, link = grab_gitea_release_information(rss_feed)
@@ -100,12 +110,43 @@ def get_release_information(request: Request, db = Depends(get_db)):
continue continue
logger.info(f"[FastAPI] Release change for ID {feed_id}: {release_title}") 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( request.app.state.notifier.send(
title=feed_name, title=feed_name or repo,
body=release_title, body=release_title,
link=link, link=link,
category="utility" 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( cursor.execute(
"UPDATE rss_feeds SET last_entry = %s WHERE id = %s", "UPDATE rss_feeds SET last_entry = %s WHERE id = %s",
(link, feed_id), (link, feed_id),
@@ -133,7 +174,7 @@ def get_release_information(request: Request, db = Depends(get_db)):
logger.info(f"[FastAPI] Gitea check complete: {results}") logger.info(f"[FastAPI] Gitea check complete: {results}")
return results return results
@api.get("/royalroad") @scheduler_router.post("/royalroad")
def get_chapters(request: Request, db = Depends(get_db)): def get_chapters(request: Request, db = Depends(get_db)):
""" """
Checks for new Royalroad chapters and updates the database accordingly. Checks for new Royalroad chapters and updates the database accordingly.
@@ -163,22 +204,51 @@ def get_chapters(request: Request, db = Depends(get_db)):
for feed_id, rss_feed, last_entry in feeds: for feed_id, rss_feed, last_entry in feeds:
results["checked"] += 1 results["checked"] += 1
try: try:
chapter_title, link, story_title, online_latest_entry = grab_royal_road_chapter_information(rss_feed) story = 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}") 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 continue
logger.info(f"[FastAPI] New chapter detected for story ID {feed_id}: {story_title}") logger.info(f"[FastAPI] New chapter detected for story ID {feed_id}: {story.story_title}")
request.app.state.notifier.send( with db.cursor() as cursor:
title=story_title, cursor.execute (
body=chapter_title, "SELECT user_id, feed_name "
link=link, "FROM rss_feed_subscriptions "
category="royal-road" "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( cursor.execute(
"UPDATE rss_feeds SET last_entry = %s WHERE id = %s", "UPDATE rss_feeds SET last_entry = %s WHERE id = %s",
(online_latest_entry, feed_id), (story.chapter_id, feed_id),
) )
db.commit() db.commit()
logger.debug(f"[FastAPI] Notification sent and database updated for story ID {feed_id}") logger.debug(f"[FastAPI] Notification sent and database updated for story ID {feed_id}")
@@ -199,9 +269,16 @@ def get_chapters(request: Request, db = Depends(get_db)):
logger.info(f"[FastAPI] Royalroad check complete: {results}") logger.info(f"[FastAPI] Royalroad check complete: {results}")
return results return results
api.include_router(user_router)
api.include_router(scheduler_router)
async def start_servers(): async def start_servers():
logger.info("[Server] Starting main API (port 5000) and metrics server (port 9000)...") 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")}") 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_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) config_metrics = uvicorn.Config(metrics_api, host="0.0.0.0", port=9000, log_level=LOG_LEVEL.lower(), log_config=None)