from __future__ import annotations from html import escape from html.parser import HTMLParser import json from pathlib import Path import re import socket import ssl import time from urllib.parse import urlparse from urllib.parse import urlencode, quote_plus from urllib.request import Request as UrlRequest, urlopen from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates from .auth import create_session_token, verify_credentials, verify_session_token from .config import get_settings from .ingestion import run_ingestion from .pipeline import approve_article, post_rewrite_status from .policy import evaluate_source_policy from .publisher import enqueue_publish, run_publisher from .relevance import article_age_days, article_relevance from .rewrite import generate_article_tags, merge_generated_tags, rewrite_article_text from .repositories import ( FeedCreate, FeedUpdate, SourceCreate, SourceUpdate, delete_feed, delete_source, clear_article_editorial_review, create_feed, create_source, get_article_by_id, get_feed_by_id, list_articles, list_articles_page, bulk_update_wp_post_ids, list_feeds, list_publish_jobs, list_runs, list_sources, set_article_image_decision, upsert_article, update_feed, update_source, update_article_status, ArticleUpsert, ) from .workflow import ( ALLOWED_UI_TRANSITIONS, INTERNAL_STATUSES, UI_STATUS_LABELS, UI_STATUSES, internal_to_ui_status, ui_status_label, ui_to_internal_status, ) settings = get_settings() router = APIRouter(tags=["admin-ui"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = { "new": ("rewrite", "close"), "relevanz": ("rewrite", "close"), "rewrite": ("freigabe", "close"), "freigabe": ("publish", "rewrite", "close"), "publish": ("close",), "published": ("rewrite", "close"), "close": ("rewrite",), "no_image": ("rewrite", "close"), } IMAGE_PROXY_USER_AGENT = "rss-news-admin/1.0" _UNSET = object() def _admin_user(request: Request) -> str | None: token = request.cookies.get(settings.session_cookie_name) if not token: return None return verify_session_token(token) def _to_optional_int(raw: str | None) -> int | None: if raw is None: return None value = raw.strip() if value == "": return None return int(value) def _dashboard_redirect( *, msg: str | None = None, msg_type: str = "success", status_filter: str | None = None, ) -> RedirectResponse: query: dict[str, str] = {} if msg: query["msg"] = msg query["type"] = msg_type if status_filter: query["status_filter"] = status_filter suffix = f"?{urlencode(query)}" if query else "" return RedirectResponse(url=f"/admin/dashboard{suffix}", status_code=303) def _parse_meta_json(raw: str | None) -> dict: if not raw: return {} try: parsed = json.loads(raw) return parsed if isinstance(parsed, dict) else {} except Exception: return {} def _read_article_images(article: dict, extraction: dict) -> list[str]: images: list[str] = [] if article.get("image_urls_json"): try: parsed_images = json.loads(article["image_urls_json"]) if isinstance(parsed_images, list): images = [str(item) for item in parsed_images if item] except Exception: images = [] if not images and isinstance(extraction.get("images"), list): images = [str(item) for item in extraction.get("images") if item] # deduplicate preserving order seen: set[str] = set() deduped: list[str] = [] for image in images: if image not in seen: seen.add(image) deduped.append(image) return deduped def _is_probably_irrelevant_image(url: str) -> bool: lowered = url.lower() patterns = ( r"logo", r"icon", r"sprite", r"avatar", r"favicon", r"/ads/", r"tracking", r"pixel", r"banner", ) return any(re.search(pattern, lowered) for pattern in patterns) def _is_http_image_url(url: str) -> bool: try: parsed = urlparse(url) except Exception: return False return parsed.scheme in {"http", "https"} and bool(parsed.netloc) def _build_image_entries(article: dict, extraction: dict, meta: dict) -> list[dict[str, object]]: all_images = _read_article_images(article, extraction) image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} selected_url = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None excluded_urls = image_review.get("excluded_urls") if isinstance(image_review.get("excluded_urls"), list) else [] excluded_set = {str(item) for item in excluded_urls if item} entries: list[dict[str, object]] = [] for url in all_images: 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), } ) return entries _PREVIEW_ALLOWED_TAGS = { "p", "br", "h2", "h3", "h4", "ul", "ol", "li", "strong", "b", "em", "i", "blockquote", "a", } _PREVIEW_DROPPED_CONTENT_TAGS = {"script", "style"} class _PreviewSanitizer(HTMLParser): """Rebuild article HTML with a small whitelist of tags. The rewrite text comes from a language model fed with scraped source pages, and the Freigabe page renders it instead of showing raw markup — so it gets filtered rather than trusted. Disallowed tags are dropped, their text kept. """ def __init__(self) -> None: super().__init__(convert_charrefs=True) self.parts: list[str] = [] self._suppress_depth = 0 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if tag in _PREVIEW_DROPPED_CONTENT_TAGS: self._suppress_depth += 1 return if self._suppress_depth or tag not in _PREVIEW_ALLOWED_TAGS: return if tag == "a": href = next((v or "" for k, v in attrs if k == "href"), "") if href.lower().startswith(("http://", "https://")): self.parts.append(f'') else: self.parts.append("") return self.parts.append(f"<{tag}>") def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if not self._suppress_depth and tag == "br": self.parts.append("
") def handle_endtag(self, tag: str) -> None: if tag in _PREVIEW_DROPPED_CONTENT_TAGS: self._suppress_depth = max(0, self._suppress_depth - 1) return if self._suppress_depth or tag not in _PREVIEW_ALLOWED_TAGS or tag == "br": return self.parts.append(f"") def handle_data(self, data: str) -> None: if not self._suppress_depth: self.parts.append(escape(data)) def _sanitize_preview_html(raw: str | None) -> str: text = (raw or "").strip() if not text: return "" parser = _PreviewSanitizer() try: parser.feed(text) parser.close() except Exception: return f"

{escape(re.sub(r'<[^>]+>', ' ', text))}

" cleaned = "".join(parser.parts).strip() if not cleaned: return "" # Plain text without any block tag would collapse into one run. if "

" not in cleaned and "

" not in cleaned and "