fix(ui): render article images via authenticated proxy thumbnails

This commit is contained in:
Oliver 2026-02-18 10:16:30 +01:00
parent efaf132936
commit 910ca72c81
4 changed files with 69 additions and 3 deletions

View file

@ -4,9 +4,10 @@ import json
from pathlib import Path from pathlib import Path
import re import re
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request as UrlRequest, urlopen
from fastapi import APIRouter, Form, Request 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 fastapi.templating import Jinja2Templates
from .auth import create_session_token, verify_credentials, verify_session_token from .auth import create_session_token, verify_credentials, verify_session_token
@ -41,6 +42,7 @@ ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = {
"published": ("error",), "published": ("error",),
"error": ("review", "rewrite"), "error": ("review", "rewrite"),
} }
IMAGE_PROXY_USER_AGENT = "rss-news-admin/1.0"
def _admin_user(request: Request) -> str | None: 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( entries.append(
{ {
"url": url, "url": url,
"proxy_url": f"/admin/images/proxy?{urlencode({'url': url})}",
"is_selected": selected_url == url, "is_selected": selected_url == url,
"is_excluded": url in excluded_set, "is_excluded": url in excluded_set,
"is_irrelevant_hint": _is_probably_irrelevant_image(url), "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) article["image_entries"] = _build_image_entries(article, extraction, meta)
image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} 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_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): if not article.get("press_contact") and isinstance(extraction.get("press_contact"), str):
article["press_contact"] = extraction.get("press_contact") article["press_contact"] = extraction.get("press_contact")
article["extraction_error"] = extraction.get("extraction_error") if isinstance(extraction.get("extraction_error"), str) else None 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) article["image_entries"] = _build_image_entries(article, extraction, meta)
image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} 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_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["days_old"] = article_age_days(article.get("published_at"))
article["relevance"] = article_relevance(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 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) 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") @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("")): def admin_article_legal_review(request: Request, article_id: int, approved: str = Form("0"), note: str = Form("")):
user = _admin_user(request) user = _admin_user(request)

View file

@ -70,13 +70,16 @@
<p><strong>Bilder:</strong> {{ article.image_entries|length if article.image_entries else 0 }}</p> <p><strong>Bilder:</strong> {{ article.image_entries|length if article.image_entries else 0 }}</p>
{% if article.selected_image_url %} {% if article.selected_image_url %}
<p><strong>Ausgewähltes Hauptbild:</strong> <a href="{{ article.selected_image_url }}" target="_blank" rel="noopener">{{ article.selected_image_url }}</a></p> <p><strong>Ausgewähltes Hauptbild:</strong> <a href="{{ article.selected_image_url }}" target="_blank" rel="noopener">{{ article.selected_image_url }}</a></p>
{% if article.selected_image_proxy_url %}
<img src="{{ article.selected_image_proxy_url }}" alt="Ausgewähltes Hauptbild" class="thumb" loading="lazy" />
{% endif %}
{% endif %} {% endif %}
{% if article.image_entries %} {% if article.image_entries %}
<div class="image-grid"> <div class="image-grid">
{% for image in article.image_entries %} {% for image in article.image_entries %}
<article class="image-card {{ 'image-selected' if image.is_selected else '' }} {{ 'image-excluded' if image.is_excluded else '' }}"> <article class="image-card {{ 'image-selected' if image.is_selected else '' }} {{ 'image-excluded' if image.is_excluded else '' }}">
<a href="{{ image.url }}" target="_blank" rel="noopener"> <a href="{{ image.url }}" target="_blank" rel="noopener">
<img src="{{ image.url }}" alt="Artikelbild" loading="lazy" /> <img src="{{ image.proxy_url }}" alt="Artikelbild" loading="lazy" />
</a> </a>
<div class="image-meta"> <div class="image-meta">
{% if image.is_selected %}<span class="badge ok">Ausgewählt</span>{% endif %} {% if image.is_selected %}<span class="badge ok">Ausgewählt</span>{% endif %}

View file

@ -157,7 +157,7 @@
<div class="subtle">Legal: {{ "OK" if a.legal_checked else "offen" }}</div> <div class="subtle">Legal: {{ "OK" if a.legal_checked else "offen" }}</div>
{% if a.selected_image_url %} {% if a.selected_image_url %}
<div class="subtle">Hauptbild gesetzt</div> <div class="subtle">Hauptbild gesetzt</div>
<a href="{{ a.selected_image_url }}" target="_blank" rel="noopener"><img src="{{ a.selected_image_url }}" alt="Hauptbild" class="thumb" loading="lazy" /></a> <a href="{{ a.selected_image_url }}" target="_blank" rel="noopener"><img src="{{ a.selected_image_proxy_url }}" alt="Hauptbild" class="thumb" loading="lazy" /></a>
{% endif %} {% endif %}
{% if a.summary %} {% if a.summary %}
<div><strong>Summary:</strong> {{ a.summary }}</div> <div><strong>Summary:</strong> {{ a.summary }}</div>

View file

@ -2,6 +2,7 @@ import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -139,6 +140,37 @@ class TestAdminUi(unittest.TestCase):
self.assertIsNotNone(article) self.assertIsNotNone(article)
self.assertIn("selected_url", article.get("meta_json", "")) 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__": if __name__ == "__main__":
unittest.main() unittest.main()