Compare commits

..

3 commits

Author SHA1 Message Date
c40715c3eb Revert "Serve RSS feeds through app with host rewrites"
This reverts commit e7b00b4129.
2026-03-31 17:55:08 +02:00
cccb2d5950 Revert "Move RSS response headers into app"
This reverts commit a6632ef769.
2026-03-31 17:55:08 +02:00
2e4f6ba66f Revert "Remove Vary header from RSS responses"
This reverts commit 3ce21f1a22.
2026-03-31 17:55:08 +02:00
3 changed files with 13 additions and 174 deletions

View file

@ -24,7 +24,7 @@ uv sync --all-groups
uv run repub 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: 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 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 ```sh
uv run repub serve --dev-mode 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://<Host header>`. 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, do not rely on Quart to serve published feeds. Configure the reverse proxy to serve `out/feeds/...` directly at `/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/...`.
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. 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. - The default database path is `republisher.db`. Set `REPUBLISHER_DB_PATH` to use a different SQLite file.
- Mirrored feeds are written under `out/feeds/<slug>/`. - Mirrored feeds are written under `out/feeds/<slug>/`.
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. - `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/`. - Job logs and stats artifacts are written under `out/logs/`.

View file

@ -4,7 +4,7 @@ import asyncio
import hashlib import hashlib
from collections.abc import AsyncGenerator, Awaitable, Callable from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime, timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import TypedDict, cast from typing import TypedDict, cast
from urllib.parse import urlparse from urllib.parse import urlparse
@ -31,7 +31,6 @@ from repub.model import (
delete_job_source, delete_job_source,
delete_source, delete_source,
initialize_database, initialize_database,
load_feed_url,
load_job_enabled, load_job_enabled,
load_settings_form, load_settings_form,
load_source_form, load_source_form,
@ -152,19 +151,15 @@ def create_app(*, dev_mode: bool = False) -> Quart:
@app.get("/feeds/<path:feed_path>") @app.get("/feeds/<path:feed_path>")
async def published_feed(feed_path: str) -> Response: 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"]): if not bool(app.config["REPUB_DEV_MODE"]):
return Response(status=404) return Response(status=404)
return await send_from_directory( response = await send_from_directory(
str(Path(app.config["REPUB_FEEDS_DIR"])), str(Path(app.config["REPUB_FEEDS_DIR"])),
feed_path, feed_path,
) )
if Path(feed_path).suffix == ".rss":
response.mimetype = "application/rss+xml"
return response
@app.get("/static/<string:asset_name>-<string:asset_hash>.<string:extension>") @app.get("/static/<string:asset_name>-<string:asset_hash>.<string:extension>")
async def versioned_static_asset( async def versioned_static_asset(
@ -587,57 +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)
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.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*"
return response
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: async def _clean_tab_state_periodically(app: Quart) -> None:
while True: while True:
await asyncio.sleep(TAB_STATE_CLEAN_INTERVAL.total_seconds()) await asyncio.sleep(TAB_STATE_CLEAN_INTERVAL.total_seconds())

View file

@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from repub.model import save_setting
from repub.web import create_app 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()) 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" db_path = tmp_path / "default-mode.db"
feeds_dir = tmp_path / "out" / "feeds" feeds_dir = tmp_path / "out" / "feeds"
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
@ -65,110 +66,6 @@ def test_default_mode_serves_published_rss_feeds(monkeypatch, tmp_path: Path) ->
client = app.test_client() client = app.test_client()
response = await client.get("/feeds/demo-source/feed.rss") 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) == "<rss/>\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 assert response.status_code == 404
asyncio.run(run()) 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(
(
"<rss><channel>"
"<url>https://ocb.bypasscensorship.org/feeds/"
"mn-america-latina/images/full/example.jpg</url>"
"<link>https://example.com/article</link>"
"</channel></rss>\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) == (
"<rss><channel>"
"<url>https://altmirror.example:8443/feeds/"
"mn-america-latina/images/full/example.jpg</url>"
"<link>https://example.com/article</link>"
"</channel></rss>\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(
"<rss><channel><title>Demo</title></channel></rss>\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())