Compare commits

..

No commits in common. "96551c2788c8ad1412a57542a0660dd190ad4204" and "87288561b927d399b45b8dcae06b9b8b64a0f1b3" have entirely different histories.

11 changed files with 22 additions and 600 deletions

View file

@ -42,11 +42,7 @@ In `--dev-mode`, 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, do not rely on Quart to serve published feeds. Configure the reverse proxy to serve `out/feeds/...` directly at `/feeds/...`.
By default the UI runs with `REPUBLISHER_AUTH_MODE=disabled` for local development. 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.
For production, set `REPUBLISHER_AUTH_MODE=trusted-headers`, keep the app bound to `127.0.0.1`, and put it behind nginx plus oauth2-proxy.
In trusted-header mode, nginx must overwrite the `X-Republisher-*` identity headers before proxying to the app.
Once the UI is running: Once the UI is running:
@ -88,8 +84,8 @@ uv run repub cleanup-media --feeds-dir out/feeds --days 25 --dry-run
``` ```
- Remove `--dry-run` to delete matching files. The command protects media - Remove `--dry-run` to delete matching files. The command protects media
referenced by the latest published feed, lists each matched file before the referenced by the latest published feed and uses a lock to avoid racing with
aggregate summary, and uses a lock to avoid racing with active crawls. active crawls.
- For config-driven deployments, pass the runtime config so cleanup uses the - For config-driven deployments, pass the runtime config so cleanup uses the
configured `out_dir` and media directory names: configured `out_dir` and media directory names:

View file

@ -42,8 +42,7 @@ uv run repub cleanup-media --config demo/repub.toml --dry-run
With `--config`, cleanup scans `demo/out/feeds/` and honors any With `--config`, cleanup scans `demo/out/feeds/` and honors any
`REPUBLISHER_*_DIR` media directory overrides in the config. Remove `--dry-run` `REPUBLISHER_*_DIR` media directory overrides in the config. Remove `--dry-run`
to delete old unreferenced media. The default retention window is 25 days; use to delete old unreferenced media. The default retention window is 25 days; use
`--days N` to override it. Cleanup prints each matched path before the aggregate `--days N` to override it.
summary.
## Local File Feed ## Local File Feed

View file

@ -1,77 +0,0 @@
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Literal, cast
AUTH_MODE_DISABLED = "disabled"
AUTH_MODE_TRUSTED_HEADERS = "trusted-headers"
AUTH_MODE_ENV = "REPUBLISHER_AUTH_MODE"
AuthMode = Literal["disabled", "trusted-headers"]
AuthRole = Literal["admin", "publisher"]
ROLE_HEADER = "X-Republisher-Auth-Role"
PROVIDER_HEADER = "X-Republisher-Auth-Provider"
USER_HEADER = "X-Republisher-Auth-User"
EMAIL_HEADER = "X-Republisher-Auth-Email"
PREFERRED_USERNAME_HEADER = "X-Republisher-Auth-Preferred-Username"
GROUPS_HEADER = "X-Republisher-Auth-Groups"
VALID_ROLES = frozenset({"admin", "publisher"})
@dataclass(frozen=True)
class TrustedIdentity:
role: AuthRole
provider: str
user: str
email: str
preferred_username: str
groups: tuple[str, ...]
def load_auth_mode(environ: Mapping[str, str] | None = None) -> AuthMode:
raw_mode = (environ or os.environ).get(AUTH_MODE_ENV, AUTH_MODE_DISABLED).strip()
if raw_mode in {AUTH_MODE_DISABLED, AUTH_MODE_TRUSTED_HEADERS}:
return cast(AuthMode, raw_mode)
raise ValueError(
f"Unsupported {AUTH_MODE_ENV}: {raw_mode!r}. "
f"Expected {AUTH_MODE_DISABLED!r} or {AUTH_MODE_TRUSTED_HEADERS!r}."
)
def load_trusted_identity(headers: Mapping[str, str]) -> TrustedIdentity | None:
role = _read_header(headers, ROLE_HEADER)
if role not in VALID_ROLES:
return None
provider = _read_header(headers, PROVIDER_HEADER)
user = _read_header(headers, USER_HEADER)
email = _read_header(headers, EMAIL_HEADER)
if provider is None or user is None or email is None:
return None
preferred_username = _read_header(headers, PREFERRED_USERNAME_HEADER) or user
return TrustedIdentity(
role=cast(AuthRole, role),
provider=provider,
user=user,
email=email,
preferred_username=preferred_username,
groups=_read_groups(headers.get(GROUPS_HEADER, "")),
)
def _read_header(headers: Mapping[str, str], name: str) -> str | None:
value = headers.get(name)
if value is None:
return None
stripped = value.strip()
return stripped or None
def _read_groups(value: str) -> tuple[str, ...]:
return tuple(
group for group in (part.strip() for part in value.split(",")) if group
)

View file

@ -160,10 +160,6 @@ def cleanup_media(
if path.resolve() in protected: if path.resolve() in protected:
continue continue
result.matched_files += 1 result.matched_files += 1
print(
f"media cleanup: matched path={path.resolve()} bytes={stat.st_size}",
file=output,
)
if dry_run: if dry_run:
continue continue
try: try:

View file

@ -166,7 +166,7 @@ class JobRuntime:
worker = self._workers.pop(execution_id) worker = self._workers.pop(execution_id)
if worker.process.poll() is None: if worker.process.poll() is None:
worker.process.kill() worker.process.kill()
worker.process.wait(timeout=10) worker.process.wait(timeout=2)
worker.log_handle.close() worker.log_handle.close()
if self._started: if self._started:

View file

@ -1,5 +1,4 @@
from repub.pages.dashboard import dashboard_page, dashboard_page_with_data from repub.pages.dashboard import dashboard_page, dashboard_page_with_data
from repub.pages.publisher import publisher_page
from repub.pages.runs import execution_logs_page, runs_page from repub.pages.runs import execution_logs_page, runs_page
from repub.pages.settings import settings_page from repub.pages.settings import settings_page
from repub.pages.shim import shim_page from repub.pages.shim import shim_page
@ -11,7 +10,6 @@ __all__ = [
"dashboard_page_with_data", "dashboard_page_with_data",
"edit_source_page", "edit_source_page",
"execution_logs_page", "execution_logs_page",
"publisher_page",
"runs_page", "runs_page",
"settings_page", "settings_page",
"shim_page", "shim_page",

View file

@ -1,19 +0,0 @@
from __future__ import annotations
import htpy as h
from htpy import Renderable
from repub.components import app_shell
def publisher_page(*, current_path: str) -> Renderable:
return app_shell(
current_path=current_path,
content=(
h.section[
h.h1(class_="text-3xl font-semibold tracking-tight text-slate-950")[
"Hello publishers"
]
],
),
)

View file

@ -2,12 +2,11 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import suppress from contextlib import suppress
from datetime import timedelta from datetime import timedelta
from functools import wraps
from pathlib import Path from pathlib import Path
from typing import Any, TypedDict, cast from typing import TypedDict, cast
from urllib.parse import urlparse from urllib.parse import urlparse
import htpy as h import htpy as h
@ -18,13 +17,6 @@ from htpy import Renderable
from peewee import IntegrityError from peewee import IntegrityError
from quart import Quart, Response, request, send_from_directory, url_for from quart import Quart, Response, request, send_from_directory, url_for
from repub.auth_headers import (
AUTH_MODE_DISABLED,
AUTH_MODE_TRUSTED_HEADERS,
AuthRole,
load_auth_mode,
load_trusted_identity,
)
from repub.datastar import RefreshBroker, TabStateStore, render_stream from repub.datastar import RefreshBroker, TabStateStore, render_stream
from repub.jobs import ( from repub.jobs import (
COMPLETED_EXECUTION_PAGE_SIZE, COMPLETED_EXECUTION_PAGE_SIZE,
@ -52,7 +44,6 @@ from repub.pages import (
dashboard_page_with_data, dashboard_page_with_data,
edit_source_page, edit_source_page,
execution_logs_page, execution_logs_page,
publisher_page,
runs_page, runs_page,
settings_page, settings_page,
shim_page, shim_page,
@ -152,14 +143,11 @@ def create_app(*, dev_mode: bool = False) -> Quart:
app.config.setdefault("REPUB_LOG_DIR", DEFAULT_LOG_DIR) app.config.setdefault("REPUB_LOG_DIR", DEFAULT_LOG_DIR)
app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR) app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR)
app.config["REPUB_DEV_MODE"] = dev_mode app.config["REPUB_DEV_MODE"] = dev_mode
app.config["REPUB_AUTH_MODE"] = load_auth_mode()
app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() app.extensions[REFRESH_BROKER_KEY] = RefreshBroker()
app.extensions[JOB_RUNTIME_KEY] = None app.extensions[JOB_RUNTIME_KEY] = None
app.extensions[TAB_STATE_STORE_KEY] = TabStateStore() app.extensions[TAB_STATE_STORE_KEY] = TabStateStore()
app.extensions[TAB_STATE_CLEANER_TASK_KEY] = None app.extensions[TAB_STATE_CLEANER_TASK_KEY] = None
app.extensions[SHUTDOWN_EVENT_KEY] = None app.extensions[SHUTDOWN_EVENT_KEY] = None
admin_required = _require_role(app, "admin")
publisher_required = _require_role(app, "publisher")
@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:
@ -189,16 +177,6 @@ def create_app(*, dev_mode: bool = False) -> Quart:
response = await send_from_directory(str(STATIC_DIR), requested_filename) response = await send_from_directory(str(STATIC_DIR), requested_filename)
return response return response
@app.get("/publisher")
@publisher_required
async def publisher_home() -> Response:
return _shim_page_response(current_path="/publisher")
@app.get("/admin/publisher")
@admin_required
async def admin_publisher_home() -> Response:
return _shim_page_response(current_path="/admin/publisher")
@app.get("/") @app.get("/")
@app.get("/sources") @app.get("/sources")
@app.get("/sources/create") @app.get("/sources/create")
@ -206,62 +184,51 @@ def create_app(*, dev_mode: bool = False) -> Quart:
@app.get("/runs") @app.get("/runs")
@app.get("/settings") @app.get("/settings")
@app.get("/job/<int:job_id>/execution/<int:execution_id>/logs") @app.get("/job/<int:job_id>/execution/<int:execution_id>/logs")
@admin_required
async def page_shim( async def page_shim(
slug: str | None = None, slug: str | None = None,
job_id: int | None = None, job_id: int | None = None,
execution_id: int | None = None, execution_id: int | None = None,
) -> Response: ) -> Response:
del slug, job_id, execution_id del slug, job_id, execution_id
return _shim_page_response(current_path=request.path) body, etag = _render_shim_page(
stylesheet_href=versioned_static_asset_href("app.css"),
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"),
current_path=request.path,
)
if request.if_none_match.contains(etag):
response = Response(status=304)
response.set_etag(etag)
return response
response = Response(body, mimetype="text/html")
response.set_etag(etag)
return response
@app.post("/") @app.post("/")
@admin_required
async def dashboard_patch() -> DatastarResponse: async def dashboard_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_dashboard(app)) return await _page_patch_response(app, lambda _tab_id: render_dashboard(app))
@app.post("/publisher")
@publisher_required
async def publisher_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_publisher(current_path="/publisher"),
)
@app.post("/admin/publisher")
@admin_required
async def admin_publisher_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_publisher(current_path="/admin/publisher"),
)
@app.post("/sources") @app.post("/sources")
@admin_required
async def sources_patch() -> DatastarResponse: async def sources_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_sources(app)) return await _page_patch_response(app, lambda _tab_id: render_sources(app))
@app.post("/sources/create") @app.post("/sources/create")
@admin_required
async def create_source_patch() -> DatastarResponse: async def create_source_patch() -> DatastarResponse:
return await _page_patch_response( return await _page_patch_response(
app, lambda _tab_id: render_create_source(app) app, lambda _tab_id: render_create_source(app)
) )
@app.post("/sources/<string:slug>/edit") @app.post("/sources/<string:slug>/edit")
@admin_required
async def edit_source_patch(slug: str) -> DatastarResponse: async def edit_source_patch(slug: str) -> DatastarResponse:
return await _page_patch_response( return await _page_patch_response(
app, lambda _tab_id: render_edit_source(slug, app) app, lambda _tab_id: render_edit_source(slug, app)
) )
@app.post("/settings") @app.post("/settings")
@admin_required
async def settings_patch() -> DatastarResponse: async def settings_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_settings(app)) return await _page_patch_response(app, lambda _tab_id: render_settings(app))
@app.post("/actions/sources/create") @app.post("/actions/sources/create")
@admin_required
async def create_source_action() -> DatastarResponse: async def create_source_action() -> DatastarResponse:
signals = cast(dict[str, object], await read_signals()) signals = cast(dict[str, object], await read_signals())
source, error = validate_source_form( source, error = validate_source_form(
@ -287,7 +254,6 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return DatastarResponse(SSE.redirect("/sources")) return DatastarResponse(SSE.redirect("/sources"))
@app.post("/actions/sources/<string:slug>/edit") @app.post("/actions/sources/<string:slug>/edit")
@admin_required
async def edit_source_action(slug: str) -> DatastarResponse: async def edit_source_action(slug: str) -> DatastarResponse:
signals = cast(dict[str, object], await read_signals()) signals = cast(dict[str, object], await read_signals())
source, error = validate_source_form( source, error = validate_source_form(
@ -313,7 +279,6 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return DatastarResponse(SSE.redirect("/sources")) return DatastarResponse(SSE.redirect("/sources"))
@app.post("/actions/sources/<string:slug>/delete") @app.post("/actions/sources/<string:slug>/delete")
@admin_required
async def delete_source_action(slug: str) -> Response: async def delete_source_action(slug: str) -> Response:
delete_source(slug) delete_source(slug)
get_job_runtime(app).sync_jobs() get_job_runtime(app).sync_jobs()
@ -321,7 +286,6 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return Response(status=204) return Response(status=204)
@app.post("/actions/settings") @app.post("/actions/settings")
@admin_required
async def update_settings_action() -> DatastarResponse: async def update_settings_action() -> DatastarResponse:
signals = cast(dict[str, object], await read_signals()) signals = cast(dict[str, object], await read_signals())
settings, error = validate_settings_form(signals) settings, error = validate_settings_form(signals)
@ -337,7 +301,6 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return DatastarResponse(SSE.redirect("/settings")) return DatastarResponse(SSE.redirect("/settings"))
@app.post("/runs") @app.post("/runs")
@admin_required
async def runs_patch() -> DatastarResponse: async def runs_patch() -> DatastarResponse:
return await _page_patch_response( return await _page_patch_response(
app, app,
@ -345,7 +308,6 @@ def create_app(*, dev_mode: bool = False) -> Quart:
) )
@app.post("/actions/runs/completed-page/<int:page>") @app.post("/actions/runs/completed-page/<int:page>")
@admin_required
async def set_completed_runs_page_action(page: int) -> Response: async def set_completed_runs_page_action(page: int) -> Response:
signals = await _read_optional_signals() signals = await _read_optional_signals()
tab_id = _read_tab_id(signals) tab_id = _read_tab_id(signals)
@ -360,14 +322,12 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return Response(status=204) return Response(status=204)
@app.post("/actions/jobs/<int:job_id>/run-now") @app.post("/actions/jobs/<int:job_id>/run-now")
@admin_required
async def run_job_now_action(job_id: int) -> Response: async def run_job_now_action(job_id: int) -> Response:
get_job_runtime(app).run_job_now(job_id, reason="manual") get_job_runtime(app).run_job_now(job_id, reason="manual")
trigger_refresh(app) trigger_refresh(app)
return Response(status=204) return Response(status=204)
@app.post("/actions/jobs/<int:job_id>/toggle-enabled") @app.post("/actions/jobs/<int:job_id>/toggle-enabled")
@admin_required
async def toggle_job_enabled_action(job_id: int) -> Response: async def toggle_job_enabled_action(job_id: int) -> Response:
enabled = load_job_enabled(job_id) enabled = load_job_enabled(job_id)
if enabled is not None: if enabled is not None:
@ -376,7 +336,6 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return Response(status=204) return Response(status=204)
@app.post("/actions/jobs/<int:job_id>/delete") @app.post("/actions/jobs/<int:job_id>/delete")
@admin_required
async def delete_job_action(job_id: int) -> Response: async def delete_job_action(job_id: int) -> Response:
delete_job_source(job_id) delete_job_source(job_id)
get_job_runtime(app).sync_jobs() get_job_runtime(app).sync_jobs()
@ -384,40 +343,34 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return Response(status=204) return Response(status=204)
@app.post("/actions/executions/<int:execution_id>/cancel") @app.post("/actions/executions/<int:execution_id>/cancel")
@admin_required
async def cancel_execution_action(execution_id: int) -> Response: async def cancel_execution_action(execution_id: int) -> Response:
get_job_runtime(app).request_execution_cancel(execution_id) get_job_runtime(app).request_execution_cancel(execution_id)
trigger_refresh(app) trigger_refresh(app)
return Response(status=204) return Response(status=204)
@app.post("/actions/queued-executions/<int:execution_id>/cancel") @app.post("/actions/queued-executions/<int:execution_id>/cancel")
@admin_required
async def cancel_queued_execution_action(execution_id: int) -> Response: async def cancel_queued_execution_action(execution_id: int) -> Response:
get_job_runtime(app).cancel_queued_execution(execution_id) get_job_runtime(app).cancel_queued_execution(execution_id)
trigger_refresh(app) trigger_refresh(app)
return Response(status=204) return Response(status=204)
@app.post("/actions/queued-executions/<int:execution_id>/move-up") @app.post("/actions/queued-executions/<int:execution_id>/move-up")
@admin_required
async def move_queued_execution_up_action(execution_id: int) -> Response: async def move_queued_execution_up_action(execution_id: int) -> Response:
get_job_runtime(app).move_queued_execution(execution_id, direction="up") get_job_runtime(app).move_queued_execution(execution_id, direction="up")
return Response(status=204) return Response(status=204)
@app.post("/actions/queued-executions/<int:execution_id>/move-down") @app.post("/actions/queued-executions/<int:execution_id>/move-down")
@admin_required
async def move_queued_execution_down_action(execution_id: int) -> Response: async def move_queued_execution_down_action(execution_id: int) -> Response:
get_job_runtime(app).move_queued_execution(execution_id, direction="down") get_job_runtime(app).move_queued_execution(execution_id, direction="down")
return Response(status=204) return Response(status=204)
@app.post("/actions/completed-executions/clear") @app.post("/actions/completed-executions/clear")
@admin_required
async def clear_completed_executions_action() -> Response: async def clear_completed_executions_action() -> Response:
clear_completed_executions(log_dir=app.config["REPUB_LOG_DIR"]) clear_completed_executions(log_dir=app.config["REPUB_LOG_DIR"])
trigger_refresh(app) trigger_refresh(app)
return Response(status=204) return Response(status=204)
@app.post("/job/<int:job_id>/execution/<int:execution_id>/logs") @app.post("/job/<int:job_id>/execution/<int:execution_id>/logs")
@admin_required
async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse: async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse:
async def render() -> Renderable: async def render() -> Renderable:
return await render_execution_logs( return await render_execution_logs(
@ -447,55 +400,6 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return app return app
def _shim_page_response(*, current_path: str) -> Response:
body, etag = _render_shim_page(
stylesheet_href=versioned_static_asset_href("app.css"),
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"),
current_path=current_path,
)
if request.if_none_match.contains(etag):
response = Response(status=304)
response.set_etag(etag)
return response
response = Response(body, mimetype="text/html")
response.set_etag(etag)
return response
def _require_role(
app: Quart, *roles: AuthRole
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
def decorate(
handler: Callable[..., Awaitable[Any]],
) -> Callable[..., Awaitable[Any]]:
@wraps(handler)
async def wrapped(*args: object, **kwargs: object) -> Any:
failure = _authorization_failure(app, roles)
if failure is not None:
return failure
return await handler(*args, **kwargs)
return wrapped
return decorate
def _authorization_failure(app: Quart, roles: Sequence[AuthRole]) -> Response | None:
auth_mode = cast(str, app.config["REPUB_AUTH_MODE"])
if auth_mode == AUTH_MODE_DISABLED:
return None
if auth_mode != AUTH_MODE_TRUSTED_HEADERS:
return Response(status=401)
identity = load_trusted_identity(cast(Mapping[str, str], request.headers))
if identity is None:
return Response(status=401)
if identity.role not in roles:
return Response(status=403)
return None
def get_refresh_broker(app: Quart) -> RefreshBroker: def get_refresh_broker(app: Quart) -> RefreshBroker:
return cast(RefreshBroker, app.extensions[REFRESH_BROKER_KEY]) return cast(RefreshBroker, app.extensions[REFRESH_BROKER_KEY])
@ -538,10 +442,6 @@ async def render_dashboard(app: Quart | None = None) -> Renderable:
) )
async def render_publisher(*, current_path: str) -> Renderable:
return publisher_page(current_path=current_path)
async def render_sources(app: Quart | None = None) -> Renderable: async def render_sources(app: Quart | None = None) -> Renderable:
if app is None: if app is None:
return sources_page() return sources_page()

View file

@ -102,29 +102,6 @@ def test_cleanup_media_dry_run_reports_matches_without_deleting(tmp_path: Path)
assert result.failures == 0 assert result.failures == 0
def test_cleanup_media_lists_matched_files_before_summary(tmp_path: Path) -> None:
feeds_dir = tmp_path / "feeds"
old_file = feeds_dir / "demo" / "audio" / "old.mp3"
write_media(old_file, b"audio", age_days=40)
output = io.StringIO()
result = cleanup_media(
feeds_dir=feeds_dir,
retention_days=25,
now=NOW,
dry_run=True,
output=output,
)
assert old_file.exists()
assert result.matched_files == 1
output_lines = output.getvalue().splitlines()
assert (
output_lines[0] == f"media cleanup: matched path={old_file.resolve()} bytes=5"
)
assert "matched_files=1" in output_lines[-1]
def test_cleanup_media_uses_configured_media_dirs(tmp_path: Path) -> None: def test_cleanup_media_uses_configured_media_dirs(tmp_path: Path) -> None:
feeds_dir = tmp_path / "feeds" feeds_dir = tmp_path / "feeds"
demo_dir = feeds_dir / "demo" demo_dir = feeds_dir / "demo"

View file

@ -1,344 +0,0 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any, cast
from repub.auth_headers import load_trusted_identity
from repub.web import create_app
def _trusted_headers(*, role: str, provider: str | None = None) -> dict[str, str]:
resolved_provider = provider or ("gp" if role == "admin" else "ocb")
return {
"X-Republisher-Auth-Role": role,
"X-Republisher-Auth-Provider": resolved_provider,
"X-Republisher-Auth-User": f"{role}-user",
"X-Republisher-Auth-Email": f"{role}@example.org",
"X-Republisher-Auth-Preferred-Username": f"{role}-user",
"X-Republisher-Auth-Groups": (
"/ocb-republisher-admins, /staff"
if role == "admin"
else "/ocb-republisher-publishers, /publishers"
),
}
def _configure_trusted_auth(monkeypatch, tmp_path: Path, name: str) -> None:
monkeypatch.setenv("REPUBLISHER_AUTH_MODE", "trusted-headers")
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(tmp_path / f"{name}.db"))
def _assert_datastar_shell(body: str) -> None:
assert body.startswith("<!doctype html>")
assert 'id="js"' in body
assert 'src="/static/datastar@1.0.0-RC.8.js"' in body
assert 'data-init="@post(window.location.pathname +' in body
assert '<main id="morph"' in body
assert "Connecting" in body
def test_load_trusted_identity_parses_groups_and_defaults_preferred_username() -> None:
identity = load_trusted_identity(
{
"X-Republisher-Auth-Role": "admin",
"X-Republisher-Auth-Provider": "gp",
"X-Republisher-Auth-User": "abel",
"X-Republisher-Auth-Email": "abel@example.org",
"X-Republisher-Auth-Groups": " /staff, ,/ocb-republisher-admins ",
}
)
assert identity is not None
assert identity.preferred_username == "abel"
assert identity.groups == ("/staff", "/ocb-republisher-admins")
def test_trusted_header_mode_rejects_admin_route_without_identity(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "missing-identity")
async def run() -> None:
client = create_app().test_client()
response = await client.get("/")
assert response.status_code == 401
asyncio.run(run())
def test_trusted_header_mode_ignores_generic_forwarded_identity_headers(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "generic-forwarded")
async def run() -> None:
client = create_app().test_client()
response = await client.get(
"/",
headers={
"X-Forwarded-User": "mallory",
"X-Forwarded-Email": "mallory@example.org",
"X-Forwarded-Groups": "/ocb-republisher-admins",
},
)
assert response.status_code == 401
asyncio.run(run())
def test_trusted_header_mode_rejects_malformed_trusted_identity_headers(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "malformed-identity")
async def run() -> None:
client = create_app().test_client()
response = await client.get(
"/",
headers={
"X-Republisher-Auth-Role": "admin",
"X-Republisher-Auth-Provider": "gp",
},
)
assert response.status_code == 401
asyncio.run(run())
def test_trusted_header_mode_allows_admin_identity_on_admin_route(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "admin-allowed")
async def run() -> None:
client = create_app().test_client()
response = await client.get("/", headers=_trusted_headers(role="admin"))
assert response.status_code == 200
asyncio.run(run())
def test_trusted_header_mode_rejects_publisher_identity_on_admin_route(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "publisher-admin-rejected")
async def run() -> None:
client = create_app().test_client()
response = await client.get("/", headers=_trusted_headers(role="publisher"))
assert response.status_code == 403
asyncio.run(run())
def test_trusted_header_mode_rejects_admin_action_without_identity(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "action-missing-identity")
async def run() -> None:
app = create_app()
app.config["REPUB_LOG_DIR"] = tmp_path / "logs"
client = app.test_client()
response = await client.post("/actions/completed-executions/clear")
assert response.status_code == 401
asyncio.run(run())
def test_trusted_header_mode_rejects_publisher_identity_on_admin_action(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "action-publisher-rejected")
async def run() -> None:
app = create_app()
app.config["REPUB_LOG_DIR"] = tmp_path / "logs"
client = app.test_client()
response = await client.post(
"/actions/completed-executions/clear",
headers=_trusted_headers(role="publisher"),
)
assert response.status_code == 403
asyncio.run(run())
def test_trusted_header_mode_allows_publisher_identity_on_publisher_route(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "publisher-allowed")
async def run() -> None:
client = create_app().test_client()
response = await client.get(
"/publisher",
headers=_trusted_headers(role="publisher"),
)
body = await response.get_data(as_text=True)
assert response.status_code == 200
_assert_datastar_shell(body)
asyncio.run(run())
def test_trusted_header_mode_publisher_post_serves_hello_publishers_morph(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "publisher-post")
async def run() -> None:
client = create_app().test_client()
async with client.request(
"/publisher?u=shim",
method="POST",
headers=_trusted_headers(role="publisher"),
) as connection:
await connection.send_complete()
chunk = await asyncio.wait_for(connection.receive(), timeout=1)
raw_connection = cast(Any, connection)
assert raw_connection.status_code == 200
assert raw_connection.headers["Content-Type"] == "text/event-stream"
assert b"event: datastar-patch-elements" in chunk
assert b'<main id="morph"' in chunk
assert b"Hello publishers" in chunk
await connection.disconnect()
asyncio.run(run())
def test_trusted_header_mode_rejects_admin_identity_on_publisher_route(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "admin-publisher-rejected")
async def run() -> None:
client = create_app().test_client()
response = await client.get(
"/publisher", headers=_trusted_headers(role="admin")
)
assert response.status_code == 403
asyncio.run(run())
def test_trusted_header_mode_allows_admin_identity_on_admin_publisher_alias(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "admin-alias-allowed")
async def run() -> None:
client = create_app().test_client()
response = await client.get(
"/admin/publisher",
headers=_trusted_headers(role="admin"),
)
body = await response.get_data(as_text=True)
assert response.status_code == 200
_assert_datastar_shell(body)
asyncio.run(run())
def test_trusted_header_mode_admin_publisher_post_serves_hello_publishers_morph(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "admin-alias-post")
async def run() -> None:
client = create_app().test_client()
async with client.request(
"/admin/publisher?u=shim",
method="POST",
headers=_trusted_headers(role="admin"),
) as connection:
await connection.send_complete()
chunk = await asyncio.wait_for(connection.receive(), timeout=1)
raw_connection = cast(Any, connection)
assert raw_connection.status_code == 200
assert raw_connection.headers["Content-Type"] == "text/event-stream"
assert b"event: datastar-patch-elements" in chunk
assert b'<main id="morph"' in chunk
assert b"Hello publishers" in chunk
await connection.disconnect()
asyncio.run(run())
def test_trusted_header_mode_rejects_publisher_identity_on_admin_publisher_alias(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "publisher-alias-rejected")
async def run() -> None:
client = create_app().test_client()
response = await client.get(
"/admin/publisher",
headers=_trusted_headers(role="publisher"),
)
assert response.status_code == 403
asyncio.run(run())
def test_trusted_header_mode_keeps_static_assets_public(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "static-public")
async def run() -> None:
client = create_app().test_client()
response = await client.get("/static/datastar@1.0.0-RC.8.js")
assert response.status_code == 200
asyncio.run(run())
def test_trusted_header_mode_keeps_dev_feeds_public(
monkeypatch, tmp_path: Path
) -> None:
_configure_trusted_auth(monkeypatch, tmp_path, "feeds-public")
async def run() -> None:
feeds_dir = tmp_path / "out" / "feeds"
feed_path = feeds_dir / "demo-source" / "feed.rss"
feed_path.parent.mkdir(parents=True)
feed_path.write_text("<rss/>\n", encoding="utf-8")
app = create_app(dev_mode=True)
app.config["REPUB_FEEDS_DIR"] = feeds_dir
client = app.test_client()
response = await client.get("/feeds/demo-source/feed.rss")
assert response.status_code == 200
asyncio.run(run())

View file

@ -781,9 +781,7 @@ def test_job_runtime_start_reattaches_live_worker_after_app_restart(
assert running_execution.running_status == JobExecutionStatus.RUNNING assert running_execution.running_status == JobExecutionStatus.RUNNING
assert running_execution.ended_at is None assert running_execution.ended_at is None
completed_execution = _wait_for_terminal_execution( completed_execution = _wait_for_terminal_execution(int(execution.get_id()))
int(execution.get_id()), timeout_seconds=10.0
)
assert completed_execution.running_status == JobExecutionStatus.SUCCEEDED assert completed_execution.running_status == JobExecutionStatus.SUCCEEDED
assert "reattached" in artifacts.log_path.read_text(encoding="utf-8") assert "reattached" in artifacts.log_path.read_text(encoding="utf-8")
finally: finally:
@ -863,9 +861,7 @@ def test_job_runtime_start_restores_live_worker_marked_failed_by_restart_bug(
assert restored_execution.running_status == JobExecutionStatus.RUNNING assert restored_execution.running_status == JobExecutionStatus.RUNNING
assert restored_execution.ended_at is None assert restored_execution.ended_at is None
completed_execution = _wait_for_terminal_execution( completed_execution = _wait_for_terminal_execution(int(execution.get_id()))
int(execution.get_id()), timeout_seconds=10.0
)
assert completed_execution.running_status == JobExecutionStatus.SUCCEEDED assert completed_execution.running_status == JobExecutionStatus.SUCCEEDED
assert "restored execution state" in artifacts.log_path.read_text( assert "restored execution state" in artifacts.log_path.read_text(
encoding="utf-8" encoding="utf-8"