diff --git a/backend/app/admin_ui.py b/backend/app/admin_ui.py index b8a1777..04c6db4 100644 --- a/backend/app/admin_ui.py +++ b/backend/app/admin_ui.py @@ -4,9 +4,10 @@ import json from pathlib import Path import re from urllib.parse import urlencode +from urllib.request import Request as UrlRequest, urlopen from fastapi import APIRouter, Form, Request -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates from .auth import create_session_token, verify_credentials, verify_session_token @@ -41,6 +42,7 @@ ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = { "published": ("error",), "error": ("review", "rewrite"), } +IMAGE_PROXY_USER_AGENT = "rss-news-admin/1.0" def _admin_user(request: Request) -> str | None: @@ -134,6 +136,7 @@ def _build_image_entries(article: dict, extraction: dict, meta: dict) -> list[di entries.append( { "url": url, + "proxy_url": f"/admin/images/proxy?{urlencode({'url': url})}", "is_selected": selected_url == url, "is_excluded": url in excluded_set, "is_irrelevant_hint": _is_probably_irrelevant_image(url), @@ -276,6 +279,9 @@ def admin_dashboard(request: Request): article["image_entries"] = _build_image_entries(article, extraction, meta) image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} article["selected_image_url"] = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None + article["selected_image_proxy_url"] = ( + f"/admin/images/proxy?{urlencode({'url': article['selected_image_url']})}" if article.get("selected_image_url") else None + ) if not article.get("press_contact") and isinstance(extraction.get("press_contact"), str): article["press_contact"] = extraction.get("press_contact") article["extraction_error"] = extraction.get("extraction_error") if isinstance(extraction.get("extraction_error"), str) else None @@ -323,6 +329,9 @@ def admin_article_detail(request: Request, article_id: int): article["image_entries"] = _build_image_entries(article, extraction, meta) image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} article["selected_image_url"] = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None + article["selected_image_proxy_url"] = ( + f"/admin/images/proxy?{urlencode({'url': article['selected_image_url']})}" if article.get("selected_image_url") else None + ) article["days_old"] = article_age_days(article.get("published_at")) article["relevance"] = article_relevance(article.get("published_at")) feed = get_feed_by_id(int(article["feed_id"])) if article.get("feed_id") else None @@ -360,6 +369,28 @@ def admin_article_image_decision( return RedirectResponse(url=f"/admin/articles/{article_id}", status_code=303) +@router.get("/admin/images/proxy") +def admin_image_proxy(request: Request, url: str): + user = _admin_user(request) + if not user: + return Response(status_code=401) + + if not (url.startswith("http://") or url.startswith("https://")): + return Response(status_code=400) + + try: + req = UrlRequest(url=url, headers={"User-Agent": IMAGE_PROXY_USER_AGENT, "Referer": url}) + with urlopen(req, timeout=10) as resp: + body = resp.read() + content_type = resp.headers.get("Content-Type", "application/octet-stream") + except Exception: + return Response(status_code=404) + + if not content_type.lower().startswith("image/"): + return Response(status_code=415) + return Response(content=body, media_type=content_type) + + @router.post("/admin/articles/{article_id}/legal-review") def admin_article_legal_review(request: Request, article_id: int, approved: str = Form("0"), note: str = Form("")): user = _admin_user(request) diff --git a/backend/templates/admin_article_detail.html b/backend/templates/admin_article_detail.html index 7acb5c1..bdfc0af 100644 --- a/backend/templates/admin_article_detail.html +++ b/backend/templates/admin_article_detail.html @@ -70,13 +70,16 @@

Bilder: {{ article.image_entries|length if article.image_entries else 0 }}

{% if article.selected_image_url %}

Ausgewähltes Hauptbild: {{ article.selected_image_url }}

+ {% if article.selected_image_proxy_url %} + Ausgewähltes Hauptbild + {% endif %} {% endif %} {% if article.image_entries %}
{% for image in article.image_entries %}
- Artikelbild + Artikelbild
{% if image.is_selected %}Ausgewählt{% endif %} diff --git a/backend/templates/admin_dashboard.html b/backend/templates/admin_dashboard.html index 34a0f84..6709f65 100644 --- a/backend/templates/admin_dashboard.html +++ b/backend/templates/admin_dashboard.html @@ -157,7 +157,7 @@
Legal: {{ "OK" if a.legal_checked else "offen" }}
{% if a.selected_image_url %}
Hauptbild gesetzt
- Hauptbild + Hauptbild {% endif %} {% if a.summary %}
Summary: {{ a.summary }}
diff --git a/backend/tests/test_admin_ui.py b/backend/tests/test_admin_ui.py index ac8a615..666f0ea 100644 --- a/backend/tests/test_admin_ui.py +++ b/backend/tests/test_admin_ui.py @@ -2,6 +2,7 @@ import os import tempfile import unittest from pathlib import Path +from unittest.mock import patch from fastapi.testclient import TestClient @@ -139,6 +140,37 @@ class TestAdminUi(unittest.TestCase): self.assertIsNotNone(article) self.assertIn("selected_url", article.get("meta_json", "")) + @patch("backend.app.admin_ui.urlopen") + def test_image_proxy_returns_image_data(self, mock_urlopen) -> None: + class _FakeHeaders: + def get(self, key: str, default=None): + if key.lower() == "content-type": + return "image/jpeg" + return default + + class _FakeResponse: + headers = _FakeHeaders() + + def read(self): + return b"\xff\xd8\xff\xd9" + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + mock_urlopen.return_value = _FakeResponse() + + self.client.post( + "/admin/login", + data={"username": "admin", "password": "secret"}, + follow_redirects=True, + ) + res = self.client.get("/admin/images/proxy?url=https%3A%2F%2Fexample.org%2Fimg.jpg") + self.assertEqual(res.status_code, 200) + self.assertIn("image/jpeg", res.headers.get("content-type", "")) + if __name__ == "__main__": unittest.main()