Add phase 5: link import, Instagram fallback and share target

- SSRF-safe fetcher: public IPs only, connects to the checked address,
  redirects re-validated, size/time limits, honest user agent
- schema.org/Recipe JSON-LD extraction (graphs, HowToSections, ISO
  durations, yields, keywords, images) without extra dependencies;
  AI fallback on plain page text when no structured recipe exists
- Instagram: one login-free request to the public embed page; when
  Instagram withholds the caption, the import page explains how to paste
  the text or import a screenshot instead
- Suggested source photo can be downloaded and attached on save
- Text import works without AI (text becomes the description)
- Installable PWA with manifest, icons and Android share target
- Offline tests for SSRF rules, parsers, Instagram and import views

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kyle Dammann
2026-10-30 21:53:28 +01:00
co-authored by Claude Opus 5.5
parent 1bf8ef8b65
commit 60785e2437
22 changed files with 1081 additions and 16 deletions
+126
View File
@@ -0,0 +1,126 @@
"""Outgoing HTTP requests to user-supplied URLs, hardened against SSRF.
The host name is resolved once, every resolved address must be a public (global) IP, and
the connection is made to that checked IP (TLS still verifies the real host name). That
closes DNS-rebinding tricks. Redirects are followed manually and re-checked on every hop.
"""
import ipaddress
import socket
from dataclasses import dataclass
from urllib.parse import urljoin, urlsplit
import urllib3
# Honest identification as a bot; sites that refuse it are handled by the paste fallback.
USER_AGENT = "Mozilla/5.0 (compatible; Kochbuch-Rezeptimport/1.0; self-hosted recipe manager)"
MAX_REDIRECTS = 5
DEFAULT_MAX_BYTES = 3 * 1024 * 1024
class FetchError(Exception):
def __init__(self, user_message):
super().__init__(user_message)
self.user_message = user_message
@dataclass
class FetchResult:
url: str
status: int
content_type: str
body: bytes
@property
def text(self):
charset = "utf-8"
for part in self.content_type.split(";")[1:]:
key, _, value = part.strip().partition("=")
if key.lower() == "charset" and value:
charset = value.strip("\"'")
try:
return self.body.decode(charset, errors="replace")
except LookupError:
return self.body.decode("utf-8", errors="replace")
def _resolve_public(host, port):
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except (socket.gaierror, UnicodeError):
raise FetchError("Die Adresse konnte nicht gefunden werden.") from None
addresses = []
for info in infos:
ip = ipaddress.ip_address(info[4][0].split("%")[0])
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
ip = ip.ipv4_mapped
if not ip.is_global or ip.is_multicast:
raise FetchError("Diese Adresse ist nicht erlaubt (internes Netz).")
addresses.append(str(ip))
if not addresses:
raise FetchError("Die Adresse konnte nicht gefunden werden.")
return addresses[0]
def _validate_url(url):
parts = urlsplit(url)
if parts.scheme not in ("http", "https") or not parts.hostname:
raise FetchError("Bitte gib eine gültige http(s)-Adresse ein.")
if parts.username or parts.password:
raise FetchError("Adressen mit Zugangsdaten werden nicht unterstützt.")
port = parts.port or (443 if parts.scheme == "https" else 80)
if port not in (80, 443, 8080, 8443):
raise FetchError("Dieser Port wird nicht unterstützt.")
return parts, port
def _request_once(url, accept, timeout):
parts, port = _validate_url(url)
host = parts.hostname
ip = _resolve_public(host, port)
default_port = 443 if parts.scheme == "https" else 80
headers = {
"Host": host if port == default_port else f"{host}:{port}",
"User-Agent": USER_AGENT,
"Accept": accept,
"Accept-Language": "de-DE,de;q=0.9,en;q=0.7",
}
if parts.scheme == "https":
pool = urllib3.HTTPSConnectionPool(ip, port, server_hostname=host, assert_hostname=host,
cert_reqs="CERT_REQUIRED", retries=False, timeout=timeout)
else:
pool = urllib3.HTTPConnectionPool(ip, port, retries=False, timeout=timeout)
path = parts.path or "/"
if parts.query:
path += "?" + parts.query
try:
return pool.urlopen("GET", path, headers=headers, redirect=False, preload_content=False)
except urllib3.exceptions.TimeoutError:
raise FetchError("Die Seite hat nicht rechtzeitig geantwortet.") from None
except urllib3.exceptions.SSLError:
raise FetchError("Die sichere Verbindung zur Seite ist fehlgeschlagen.") from None
except urllib3.exceptions.HTTPError:
raise FetchError("Die Seite ist nicht erreichbar.") from None
def fetch(url, accept="text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", max_bytes=DEFAULT_MAX_BYTES, timeout=None):
timeout = timeout or urllib3.Timeout(connect=5, read=15)
for _ in range(MAX_REDIRECTS + 1):
response = _request_once(url, accept, timeout)
try:
if response.status in (301, 302, 303, 307, 308) and response.headers.get("Location"):
url = urljoin(url, response.headers["Location"])
continue
chunks, size = [], 0
for chunk in response.stream(64 * 1024, decode_content=True):
size += len(chunk)
if size > max_bytes:
raise FetchError("Die Seite ist zu groß.")
chunks.append(chunk)
return FetchResult(url=url, status=response.status,
content_type=response.headers.get("Content-Type", ""), body=b"".join(chunks))
except urllib3.exceptions.HTTPError:
raise FetchError("Die Seite konnte nicht vollständig geladen werden.") from None
finally:
response.release_conn()
raise FetchError("Zu viele Weiterleitungen.")