From 2e4f6ba66fdfffa36a8809c2490efc3b5c72a6cf Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Tue, 31 Mar 2026 17:55:08 +0200 Subject: [PATCH 1/3] Revert "Remove Vary header from RSS responses" This reverts commit 3ce21f1a222a5928645e4d3aed94488bf77aced5. --- repub/web.py | 1 + 1 file changed, 1 insertion(+) diff --git a/repub/web.py b/repub/web.py index 22ca0d5..c76d0d2 100644 --- a/repub/web.py +++ b/repub/web.py @@ -602,6 +602,7 @@ def _rss_feed_response(feed_text: str | None) -> Response: response.cache_control.public = True response.cache_control.max_age = 300 response.expires = datetime.now(UTC) + timedelta(minutes=5) + response.vary.add("Host") response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "*" From cccb2d59506167ddf38c19ae56416101dac7302d Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Tue, 31 Mar 2026 17:55:08 +0200 Subject: [PATCH 2/3] Revert "Move RSS response headers into app" This reverts commit a6632ef7691c52f0465ad758e2c3beb85ac782ab. --- repub/web.py | 20 ++------------------ tests/test_dev_mode.py | 37 ------------------------------------- 2 files changed, 2 insertions(+), 55 deletions(-) diff --git a/repub/web.py b/repub/web.py index c76d0d2..b2783e5 100644 --- a/repub/web.py +++ b/repub/web.py @@ -4,7 +4,7 @@ import asyncio import hashlib from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import suppress -from datetime import UTC, datetime, timedelta +from datetime import timedelta from pathlib import Path from typing import TypedDict, cast from urllib.parse import urlparse @@ -590,23 +590,7 @@ def _load_sidebar_counts(app: Quart) -> dict[str, int]: def _rss_feed_response(feed_text: str | None) -> Response: if feed_text is None: return Response(status=404) - etag = hashlib.sha256(feed_text.encode("utf-8")).hexdigest() - if request.if_none_match.contains(etag): - response = Response(status=304) - else: - response = Response( - feed_text, - content_type="application/rss+xml; charset=utf-8", - ) - response.set_etag(etag) - response.cache_control.public = True - response.cache_control.max_age = 300 - response.expires = datetime.now(UTC) + timedelta(minutes=5) - response.vary.add("Host") - response.headers["Access-Control-Allow-Origin"] = "*" - response.headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS" - response.headers["Access-Control-Allow-Headers"] = "*" - return response + return Response(feed_text, mimetype="application/rss+xml") def _read_feed_text(*, feeds_dir: Path, feed_path: str) -> str | None: diff --git a/tests/test_dev_mode.py b/tests/test_dev_mode.py index 0e68f34..ae84740 100644 --- a/tests/test_dev_mode.py +++ b/tests/test_dev_mode.py @@ -133,42 +133,5 @@ def test_published_rss_rewrites_feed_url_to_https_host_header( "https://example.com/article" "\n" ) - assert response.headers["Access-Control-Allow-Origin"] == "*" - assert response.headers["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS" - assert response.headers["Access-Control-Allow-Headers"] == "*" - assert response.cache_control.public is True - assert response.cache_control.max_age == 300 - assert response.headers["ETag"] != "" - - asyncio.run(run()) - - -def test_published_rss_supports_conditional_requests( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "conditional-rss.db" - feeds_dir = tmp_path / "out" / "feeds" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - - async def run() -> None: - app = create_app() - app.config["REPUB_FEEDS_DIR"] = feeds_dir - feed_path = feeds_dir / "demo-source" / "feed.rss" - feed_path.parent.mkdir(parents=True) - feed_path.write_text( - "Demo\n", encoding="utf-8" - ) - - client = app.test_client() - first_response = await client.get("/feeds/demo-source/feed.rss") - etag = first_response.headers["ETag"] - second_response = await client.get( - "/feeds/demo-source/feed.rss", - headers={"If-None-Match": etag}, - ) - - assert second_response.status_code == 304 - assert await second_response.get_data(as_text=True) == "" - assert second_response.headers["ETag"] == etag asyncio.run(run()) From c40715c3ebd3387157bbe7facd8fd9c009773cf0 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Tue, 31 Mar 2026 17:55:08 +0200 Subject: [PATCH 3/3] Revert "Serve RSS feeds through app with host rewrites" This reverts commit e7b00b4129d0f3a3c1e02ddff32e6889e95c6162. --- README.md | 12 +++---- repub/web.py | 49 +++------------------------- tests/test_dev_mode.py | 72 ++---------------------------------------- 3 files changed, 12 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index de2260b..213f955 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ uv sync --all-groups uv run repub ``` -With no arguments, `uv run repub` starts the web UI in local dev mode. The Python app serves published `.rss` files from `/feeds/...` out of `out/feeds/...`, and in dev mode it also serves non-RSS feed artifacts from the same tree. +With no arguments, `uv run repub` starts the web UI in local dev mode and serves published feed files from `/feeds/...` out of `out/feeds/...`. By default the UI listens on `127.0.0.1:8080`. You can override that with `REPUBLISHER_HOST` and `REPUBLISHER_PORT`, or with: @@ -32,17 +32,15 @@ By default the UI listens on `127.0.0.1:8080`. You can override that with `REPUB uv run repub serve --host 0.0.0.0 --port 8080 ``` -If you invoke the `serve` subcommand explicitly, use `--dev-mode` to expose non-RSS feed artifacts directly from the Quart app: +If you invoke the `serve` subcommand explicitly, use `--dev-mode` to expose published feeds directly from the Quart app: ```sh uv run repub serve --dev-mode ``` -Requests for `/feeds/**/*.rss` are always handled by the Python app. It rewrites mirrored feed URLs on the fly by replacing the configured `Feed URL` origin with `https://`. +In `--dev-mode`, requests under `/feeds/...` are served from `out/feeds/...`. -In `--dev-mode`, non-RSS requests under `/feeds/...` are served from `out/feeds/...`. - -In production, keep `/feeds/**/*.rss` routed to the Python app. Non-RSS feed artifacts under `out/feeds/...` should still be served directly by the reverse proxy at `/feeds/...`. +In production, do not rely on Quart to serve published feeds. Configure the reverse proxy to serve `out/feeds/...` directly at `/feeds/...`. Important: the admin UI has no built-in authentication. Keep it bound to localhost or put it behind a trusted network layer such as Tailscale. @@ -59,7 +57,7 @@ Operational notes: - The default database path is `republisher.db`. Set `REPUBLISHER_DB_PATH` to use a different SQLite file. - Mirrored feeds are written under `out/feeds//`. - In production, route `/feeds/**/*.rss` to the Python app and expose the remaining `out/feeds/` artifacts directly from the reverse proxy at `/feeds/`. + In production, expose `out/feeds/` directly from the reverse proxy at `/feeds/`. - `Feed URL` is used to generate absolute media URLs and `atom:link rel="self"` in exported feeds. - Job logs and stats artifacts are written under `out/logs/`. diff --git a/repub/web.py b/repub/web.py index b2783e5..372e121 100644 --- a/repub/web.py +++ b/repub/web.py @@ -31,7 +31,6 @@ from repub.model import ( delete_job_source, delete_source, initialize_database, - load_feed_url, load_job_enabled, load_settings_form, load_source_form, @@ -152,19 +151,15 @@ def create_app(*, dev_mode: bool = False) -> Quart: @app.get("/feeds/") async def published_feed(feed_path: str) -> Response: - if Path(feed_path).suffix == ".rss": - return _rss_feed_response( - _read_feed_text( - feeds_dir=Path(app.config["REPUB_FEEDS_DIR"]), - feed_path=feed_path, - ) - ) if not bool(app.config["REPUB_DEV_MODE"]): return Response(status=404) - return await send_from_directory( + response = await send_from_directory( str(Path(app.config["REPUB_FEEDS_DIR"])), feed_path, ) + if Path(feed_path).suffix == ".rss": + response.mimetype = "application/rss+xml" + return response @app.get("/static/-.") async def versioned_static_asset( @@ -587,42 +582,6 @@ def _load_sidebar_counts(app: Quart) -> dict[str, int]: } -def _rss_feed_response(feed_text: str | None) -> Response: - if feed_text is None: - return Response(status=404) - return Response(feed_text, mimetype="application/rss+xml") - - -def _read_feed_text(*, feeds_dir: Path, feed_path: str) -> str | None: - resolved_path = _resolve_feed_path(feeds_dir=feeds_dir, feed_path=feed_path) - if resolved_path is None: - return None - return _rewrite_feed_text( - resolved_path.read_text(encoding="utf-8"), - configured_feed_url=load_feed_url(), - request_host=request.host, - ) - - -def _resolve_feed_path(*, feeds_dir: Path, feed_path: str) -> Path | None: - base_dir = feeds_dir.resolve() - candidate_path = (base_dir / feed_path).resolve() - try: - candidate_path.relative_to(base_dir) - except ValueError: - return None - return candidate_path if candidate_path.is_file() else None - - -def _rewrite_feed_text( - feed_text: str, *, configured_feed_url: str, request_host: str -) -> str: - configured_origin = configured_feed_url.rstrip("/") - if configured_origin == "": - return feed_text - return feed_text.replace(configured_origin, f"https://{request_host}") - - async def _clean_tab_state_periodically(app: Quart) -> None: while True: await asyncio.sleep(TAB_STATE_CLEAN_INTERVAL.total_seconds()) diff --git a/tests/test_dev_mode.py b/tests/test_dev_mode.py index ae84740..f58d640 100644 --- a/tests/test_dev_mode.py +++ b/tests/test_dev_mode.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from pathlib import Path -from repub.model import save_setting from repub.web import create_app @@ -50,7 +49,9 @@ def test_dev_mode_serves_feed_enclosure_assets(monkeypatch, tmp_path: Path) -> N asyncio.run(run()) -def test_default_mode_serves_published_rss_feeds(monkeypatch, tmp_path: Path) -> None: +def test_default_mode_does_not_serve_published_feeds( + monkeypatch, tmp_path: Path +) -> None: db_path = tmp_path / "default-mode.db" feeds_dir = tmp_path / "out" / "feeds" monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) @@ -65,73 +66,6 @@ def test_default_mode_serves_published_rss_feeds(monkeypatch, tmp_path: Path) -> client = app.test_client() response = await client.get("/feeds/demo-source/feed.rss") - assert response.status_code == 200 - assert response.mimetype == "application/rss+xml" - assert await response.get_data(as_text=True) == "\n" - - asyncio.run(run()) - - -def test_default_mode_does_not_serve_feed_enclosure_assets( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "default-mode-assets.db" - feeds_dir = tmp_path / "out" / "feeds" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - - async def run() -> None: - app = create_app() - app.config["REPUB_FEEDS_DIR"] = feeds_dir - enclosure_path = feeds_dir / "demo-source" / "audio" / "episode.mp3" - enclosure_path.parent.mkdir(parents=True) - enclosure_path.write_bytes(b"mp3-data") - - client = app.test_client() - response = await client.get("/feeds/demo-source/audio/episode.mp3") - assert response.status_code == 404 asyncio.run(run()) - - -def test_published_rss_rewrites_feed_url_to_https_host_header( - monkeypatch, tmp_path: Path -) -> None: - db_path = tmp_path / "rewrite-feed-url.db" - feeds_dir = tmp_path / "out" / "feeds" - monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) - - async def run() -> None: - app = create_app() - app.config["REPUB_FEEDS_DIR"] = feeds_dir - save_setting("feed_url", "https://ocb.bypasscensorship.org") - feed_path = feeds_dir / "mn-america-latina" / "feed.rss" - feed_path.parent.mkdir(parents=True) - feed_path.write_text( - ( - "" - "https://ocb.bypasscensorship.org/feeds/" - "mn-america-latina/images/full/example.jpg" - "https://example.com/article" - "\n" - ), - encoding="utf-8", - ) - - client = app.test_client() - response = await client.get( - "/feeds/mn-america-latina/feed.rss", - headers={"Host": "altmirror.example:8443"}, - ) - - assert response.status_code == 200 - assert response.mimetype == "application/rss+xml" - assert await response.get_data(as_text=True) == ( - "" - "https://altmirror.example:8443/feeds/" - "mn-america-latina/images/full/example.jpg" - "https://example.com/article" - "\n" - ) - - asyncio.run(run())