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
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)

View file

@ -70,13 +70,16 @@
<p><strong>Bilder:</strong> {{ article.image_entries|length if article.image_entries else 0 }}</p>
{% 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>
{% if article.selected_image_proxy_url %}
<img src="{{ article.selected_image_proxy_url }}" alt="Ausgewähltes Hauptbild" class="thumb" loading="lazy" />
{% endif %}
{% endif %}
{% if article.image_entries %}
<div class="image-grid">
{% for image in article.image_entries %}
<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">
<img src="{{ image.url }}" alt="Artikelbild" loading="lazy" />
<img src="{{ image.proxy_url }}" alt="Artikelbild" loading="lazy" />
</a>
<div class="image-meta">
{% 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>
{% if a.selected_image_url %}
<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 %}
{% if a.summary %}
<div><strong>Summary:</strong> {{ a.summary }}</div>

View file

@ -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()