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
+8 -1
View File
@@ -9,7 +9,7 @@ port; TLS, domain and proxy are out of scope.
- [x] Phase 2: Recipes CRUD, visibility (private/public), photos, notes, search, favourites
- [x] Phase 3: Comments, serving calculator (server-side `?portionen=`), cooking mode (wake lock)
- [x] Phase 4: AI (OpenAI): "Mit KI aufbereiten" on the form + photo/screenshot and text import
- [ ] Phase 5: Link import (schema.org JSON-LD, Instagram with paste fallback, Android PWA share target)
- [x] Phase 5: Link import (schema.org JSON-LD, Instagram best effort + paste/screenshot fallback, PWA share target)
- [ ] Phase 6: Backup script, full README
## Stack
@@ -36,6 +36,13 @@ no JS build step: plain forms with small progressive enhancements in `static/js/
re-validated by Pydantic (`AIRecipe`), `map_api_error` → German user messages, per-user/global daily
limits (0 = off). `_create_completion` is the only network call (stubbed in tests). Drafts go into
`RecipeForm(initial=…)` and are never saved directly. `import_views.py`: `/rezepte/import/` (photo, text).
- `recipes/fetch.py` – the ONLY way to request user-supplied URLs: resolves once, requires public IPs,
connects to the checked IP (no DNS rebinding), manual redirects re-checked, size/time limits, honest UA.
`schema_org.py` parses JSON-LD Recipe + page text (stdlib HTMLParser). `instagram.py` makes one honest
request to the post's embed page; Instagram mostly answers with an empty shell → UI falls back to
paste/screenshot. Do not add UA spoofing, cookies or login-based scraping (owner's requirement).
- `core/pwa.py` – manifest (share_target → `/teilen/`) and a no-cache service worker. Icons via
`scripts/make_icons.py`.
- `templates/`, `static/` – global templates, `app.css` (mobile first, dark mode), `app.js`.
- `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn.
- `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker).
+48
View File
@@ -0,0 +1,48 @@
"""Web app manifest and service worker (installable app, Android share target)."""
from django.contrib.auth.decorators import login_not_required
from django.http import HttpResponse, JsonResponse
from django.templatetags.static import static
from django.urls import reverse
from django.views.decorators.cache import cache_control
from django.views.decorators.http import require_GET
# No caching of pages or API responses: private recipes must never sit in a shared cache,
# and the app is online-only. The worker exists so browsers offer installation.
SERVICE_WORKER = """self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
"""
@login_not_required
@require_GET
@cache_control(max_age=3600)
def manifest(request):
data = {
"name": "Kochbuch",
"short_name": "Kochbuch",
"lang": "de",
"start_url": reverse("recipes:list"),
"scope": "/",
"display": "standalone",
"background_color": "#faf7f2",
"theme_color": "#b4532a",
"icons": [
{"src": static("icons/icon-192.png"), "sizes": "192x192", "type": "image/png"},
{"src": static("icons/icon-512.png"), "sizes": "512x512", "type": "image/png"},
{"src": static("icons/icon-maskable-512.png"), "sizes": "512x512", "type": "image/png", "purpose": "maskable"},
],
"share_target": {
"action": reverse("recipes:share"),
"method": "GET",
"params": {"title": "title", "text": "text", "url": "url"},
},
}
return JsonResponse(data, content_type="application/manifest+json", json_dumps_params={"ensure_ascii": False})
@login_not_required
@require_GET
@cache_control(max_age=0, no_cache=True)
def service_worker(request):
return HttpResponse(SERVICE_WORKER, content_type="application/javascript")
+3 -1
View File
@@ -1,9 +1,11 @@
from django.urls import path
from . import views
from . import pwa, views
app_name = "core"
urlpatterns = [
path("healthz", views.healthz, name="healthz"),
path("manifest.webmanifest", pwa.manifest, name="manifest"),
path("sw.js", pwa.service_worker, name="service_worker"),
]
+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.")
+8 -1
View File
@@ -59,6 +59,9 @@ class RecipeForm(forms.ModelForm):
)
source_url = forms.URLField(label="Quelle (URL)", required=False, max_length=1000, assume_scheme="https")
photos = photo_field()
# Link import: photo suggested by the source page, downloaded only when the user keeps the tick.
import_image_url = forms.URLField(required=False, max_length=2000, widget=forms.HiddenInput, assume_scheme="https")
use_import_image = forms.BooleanField(label="Bild der Quelle als Foto übernehmen", required=False, initial=True)
class Meta:
model = Recipe
@@ -74,7 +77,8 @@ class RecipeForm(forms.ModelForm):
}
field_order = ["title", "description", "servings", "servings_label", "prep_minutes", "cook_minutes",
"ingredients_text", "steps_text", "tags_text", "photos", "author_notes", "source_url", "visibility"]
"ingredients_text", "steps_text", "tags_text", "use_import_image", "photos", "author_notes",
"source_url", "visibility"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -85,6 +89,9 @@ class RecipeForm(forms.ModelForm):
self.initial.setdefault("ingredients_text", parsing.ingredients_to_text(recipe.ingredients.all()))
self.initial.setdefault("steps_text", parsing.steps_to_text([s.text for s in recipe.steps.all()]))
self.initial.setdefault("tags_text", ", ".join(t.name for t in recipe.tags.all()))
if recipe.pk or not (self.initial.get("import_image_url") or self.data.get("import_image_url")):
del self.fields["import_image_url"]
del self.fields["use_import_image"]
if recipe.pk:
remaining = settings.IMAGE_MAX_PER_RECIPE - recipe.images.count()
self.fields["photos"].label = "Weitere Fotos"
+112 -4
View File
@@ -4,15 +4,30 @@ Every path ends in the normal recipe form, pre-filled as an editable draft and p
recipes:create. Nothing is saved before the user presses "Speichern".
"""
import re
from urllib.parse import urlencode
from django import forms
from django.contrib import messages
from django.shortcuts import redirect, render
from django.urls import reverse
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_GET, require_POST
from . import ai, images
from . import ai, images, instagram, schema_org
from .fetch import FetchError, fetch
from .forms import MultipleFileInput, MultipleImageField, RecipeForm
_URL_RE = re.compile(r"https?://[^\s<>\"']+")
class LinkImportForm(forms.Form):
url = forms.URLField(
label="Link",
max_length=2000,
assume_scheme="https",
widget=forms.URLInput(attrs={"placeholder": "https://… (Rezeptseite oder Instagram)", "inputmode": "url"}),
)
class PhotoImportForm(forms.Form):
photos = MultipleImageField(
@@ -48,11 +63,15 @@ def render_draft(request, initial, notice, ai_draft=None, extra_context=None):
})
def import_start(request, photo_form=None, text_form=None):
def import_start(request, photo_form=None, text_form=None, link_form=None, instagram_failed=False, status=200):
if link_form is None:
link_form = LinkImportForm(initial={"url": request.GET.get("url", "")})
return render(request, "recipes/import.html", {
"photo_form": photo_form or PhotoImportForm(),
"text_form": text_form or TextImportForm(),
})
"link_form": link_form,
"instagram_failed": instagram_failed,
}, status=status)
@require_POST
@@ -74,6 +93,11 @@ def import_text(request):
form = TextImportForm(request.POST)
if not form.is_valid():
return import_start(request, text_form=form)
if not ai.is_configured():
# Without AI the pasted text becomes the description; the user splits it up by hand.
initial = {"description": form.cleaned_data["text"], "source_url": form.cleaned_data["source_url"]}
return render_draft(request, initial, "Text übernommen (KI nicht eingerichtet – bitte Zutaten und "
"Schritte aus der Beschreibung in die passenden Felder übertragen)")
try:
draft = ai.structure_text(request.user, form.cleaned_data["text"])
except ai.AIError as error:
@@ -81,3 +105,87 @@ def import_text(request):
return import_start(request, text_form=form)
initial = {**draft.to_form_initial(), "source_url": form.cleaned_data["source_url"]}
return render_draft(request, initial, "Aus dem Text erkannt", ai_draft=draft)
@require_POST
def import_link(request):
form = LinkImportForm(request.POST)
if not form.is_valid():
return import_start(request, link_form=form, status=400)
url = form.cleaned_data["url"]
code = instagram.shortcode(url)
if code:
return _import_instagram(request, url, code, form)
if instagram.is_instagram(url):
form.add_error("url", "Bitte den Link zu einem einzelnen Beitrag oder Reel verwenden.")
return import_start(request, link_form=form, status=400)
return _import_website(request, url, form)
def _instagram_fallback(request, url, form, reason):
messages.warning(request, f"{reason} Füge die Beschreibung des Beitrags unten ein – oder lade einen Screenshot hoch.")
text_form = TextImportForm(initial={"source_url": url})
return import_start(request, link_form=form, text_form=text_form, instagram_failed=True)
def _import_instagram(request, url, code, form):
try:
caption, image = instagram.fetch_caption(code)
except instagram.InstagramUnavailable:
return _instagram_fallback(request, url, form, "Instagram lässt den automatischen Abruf gerade nicht zu.")
extra = {"import_image_url": image} if image else {}
if not ai.is_configured():
initial = {"description": caption, "source_url": url, **extra}
return render_draft(request, initial, "Beschreibung von Instagram übernommen (KI nicht eingerichtet – "
"bitte Zutaten und Schritte selbst aufteilen)")
try:
draft = ai.structure_text(request.user, caption, hint="Beschreibung (Caption) eines Instagram-Beitrags:")
except ai.AIError as error:
messages.error(request, error.user_message)
return import_start(request, link_form=form, text_form=TextImportForm(initial={"text": caption, "source_url": url}))
return render_draft(request, {**draft.to_form_initial(), "source_url": url, **extra}, "Aus Instagram erkannt",
ai_draft=draft)
def _import_website(request, url, form):
try:
result = fetch(url)
except FetchError as error:
form.add_error("url", error.user_message)
return import_start(request, link_form=form, status=400)
if result.status >= 400:
form.add_error("url", f"Die Seite antwortete mit Fehler {result.status}.")
return import_start(request, link_form=form, status=400)
if "html" not in result.content_type.lower():
form.add_error("url", "Unter dem Link liegt keine Webseite.")
return import_start(request, link_form=form, status=400)
initial, image, page = schema_org.extract(result.text)
extra = {"source_url": url, **({"import_image_url": image} if image else {})}
if initial:
return render_draft(request, {**initial, **extra}, "Von der Webseite übernommen")
if not ai.is_configured():
form.add_error("url", "Auf der Seite wurde kein strukturiertes Rezept gefunden, und die KI ist nicht "
"eingerichtet. Kopiere den Rezepttext und trag ihn von Hand ein.")
return import_start(request, link_form=form, status=400)
text = page.title.strip() + "\n\n" + page.visible_text()
try:
draft = ai.structure_text(request.user, text, hint=f"Text der Webseite {url}:")
except ai.AIError as error:
form.add_error("url", error.user_message)
return import_start(request, link_form=form, status=400)
return render_draft(request, {**draft.to_form_initial(), **extra}, "Von der Webseite (per KI) erkannt",
ai_draft=draft)
@require_GET
def share_target(request):
"""Web Share Target (Android PWA): 'Teilen → Kochbuch' lands here with title/text/url."""
for value in (request.GET.get("url", ""), request.GET.get("text", ""), request.GET.get("title", "")):
match = _URL_RE.search(value)
if match:
return redirect(f"{reverse('recipes:import')}?{urlencode({'url': match.group(0)})}")
text = request.GET.get("text", "").strip()
if text:
return import_start(request, text_form=TextImportForm(initial={"text": text}))
return redirect("recipes:import")
+114
View File
@@ -0,0 +1,114 @@
"""Best-effort retrieval of an Instagram post caption without login.
Instagram has no login-free API for captions any more (the official oEmbed endpoint needs a
Meta app and no longer returns post content). We make a single, honest request to the
public embed page of the post and read the caption from it. Instagram frequently serves an
empty app shell to servers instead – then the caller falls back to "paste the caption" or
"import a screenshot". No login, no cookies, no evasion of bot detection.
"""
import re
from html.parser import HTMLParser
from urllib.parse import urlsplit
from .fetch import FetchError, fetch
from .schema_org import parse_page
HOSTS = {"instagram.com", "www.instagram.com", "m.instagram.com", "instagr.am", "www.instagr.am"}
_PATH_RE = re.compile(r"^/(?:[\w.]+/)?(?:p|reel|reels|tv)/([A-Za-z0-9_-]{5,40})(?:/|$)")
_OG_CAPTION_RE = re.compile(r':\s*["“](.+)["”]\s*\.?\s*$', re.DOTALL)
class InstagramUnavailable(Exception):
pass
def shortcode(url):
"""Shortcode of a post/reel URL, or None if the URL is not an Instagram post."""
try:
parts = urlsplit(url.strip())
except ValueError:
return None
if (parts.hostname or "").lower() not in HOSTS:
return None
match = _PATH_RE.match(parts.path)
return match.group(1) if match else None
def is_instagram(url):
return (urlsplit(url.strip()).hostname or "").lower() in HOSTS
class _EmbedCaptionParser(HTMLParser):
"""Reads <div class="Caption"> of the embed page, without username and comment links."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.depth = 0 # >0 while inside the caption div
self.skip_depth = 0
self.parts = []
self.image = ""
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
classes = (attrs.get("class") or "").split()
if tag == "img" and "EmbeddedMediaImage" in classes and not self.image:
self.image = attrs.get("src", "")
if self.depth:
if tag == "br":
self.parts.append("\n")
return
if self.skip_depth or "CaptionUsername" in classes or "CaptionComments" in classes:
self.skip_depth += 1
if tag == "div":
self.depth += 1
elif tag == "div" and "Caption" in classes:
self.depth = 1
def handle_endtag(self, tag):
if not self.depth:
return
if self.skip_depth:
self.skip_depth -= 1
if tag == "div":
self.depth -= 1
def handle_data(self, data):
if self.depth and not self.skip_depth:
self.parts.append(data)
def caption(self):
text = "".join(self.parts)
text = re.sub(r"[ \t]+", " ", text)
return re.sub(r"\n{3,}", "\n\n", text).strip()
def parse_embed(page_html):
"""Return (caption, image_url) from an embed page; caption may be empty."""
parser = _EmbedCaptionParser()
try:
parser.feed(page_html)
except Exception:
return "", ""
caption = parser.caption()
image = parser.image
if not caption:
meta = parse_page(page_html).meta
match = _OG_CAPTION_RE.search(meta.get("og:description", "") or meta.get("og:title", ""))
caption = match.group(1).strip() if match else ""
image = image or meta.get("og:image", "")
return caption, image
def fetch_caption(code):
"""Try to read the caption of a post. Raises InstagramUnavailable if Instagram doesn't deliver it."""
try:
result = fetch(f"https://www.instagram.com/p/{code}/embed/captioned/")
except FetchError as error:
raise InstagramUnavailable(error.user_message) from None
if result.status != 200:
raise InstagramUnavailable(f"Instagram antwortete mit Status {result.status}.")
caption, image = parse_embed(result.text)
if len(caption) < 20:
raise InstagramUnavailable("Instagram hat die Beschreibung nicht herausgegeben.")
return caption, image
+216
View File
@@ -0,0 +1,216 @@
"""Extract schema.org/Recipe (JSON-LD) and page metadata from HTML without extra dependencies."""
import html
import json
import re
from html.parser import HTMLParser
from . import parsing
_DURATION_RE = re.compile(r"^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:[\d.]+S)?)?$", re.IGNORECASE)
_TAG_RE = re.compile(r"<[^>]+>")
MAX_TEXT_CHARS = 15_000
class PageParser(HTMLParser):
"""Collects JSON-LD blocks, <meta> tags, the <title> and the visible text of a page."""
SKIP_TEXT = {"script", "style", "noscript", "svg", "nav", "footer", "header", "form", "button", "iframe"}
BLOCK = {"p", "div", "li", "br", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "section", "article", "ol", "ul"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.json_ld, self.meta, self.title = [], {}, ""
self._stack, self._buffer, self._text = [], None, []
self._in_title = False
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == "script" and (attrs.get("type") or "").lower().startswith("application/ld+json"):
self._buffer = []
if tag == "meta":
key = (attrs.get("property") or attrs.get("name") or "").lower()
if key and attrs.get("content") and key not in self.meta:
self.meta[key] = attrs["content"]
if tag == "title":
self._in_title = True
if tag in self.BLOCK:
self._text.append("\n")
if tag not in ("br", "meta", "img", "link", "input", "hr"):
self._stack.append(tag)
def handle_endtag(self, tag):
if tag == "script" and self._buffer is not None:
self.json_ld.append("".join(self._buffer))
self._buffer = None
if tag == "title":
self._in_title = False
if tag in self._stack:
while self._stack and self._stack.pop() != tag:
pass
def handle_data(self, data):
if self._buffer is not None:
self._buffer.append(data)
return
if self._in_title:
self.title += data
if not any(t in self.SKIP_TEXT for t in self._stack):
self._text.append(data)
def visible_text(self):
text = "".join(self._text)
text = re.sub(r"[ \t\r\f\v]+", " ", text)
text = re.sub(r"\n\s*\n+", "\n", text)
return text.strip()[:MAX_TEXT_CHARS]
def parse_page(page_html):
parser = PageParser()
try:
parser.feed(page_html)
parser.close()
except Exception: # malformed HTML must never break the import
pass
return parser
def _types(node):
value = node.get("@type", [])
return {t.lower() for t in (value if isinstance(value, list) else [value]) if isinstance(t, str)}
def find_recipe(data):
"""Depth-first search for the first object whose @type includes Recipe."""
if isinstance(data, list):
for item in data:
found = find_recipe(item)
if found:
return found
elif isinstance(data, dict):
if "recipe" in _types(data):
return data
for key in ("@graph", "mainEntity", "mainEntityOfPage", "itemListElement", "item"):
if key in data:
found = find_recipe(data[key])
if found:
return found
return None
def _clean(text):
if not isinstance(text, str):
return ""
text = html.unescape(_TAG_RE.sub(" ", text))
text = re.sub(r"\s+", " ", text).strip()
return re.sub(r"\s+([.,;:!?])", r"\1", text)
def _duration_minutes(value):
if not isinstance(value, str):
return None
match = _DURATION_RE.match(value.strip())
if not match or not any(match.groups()):
return None
days, hours, minutes = (int(g) if g else 0 for g in match.groups())
total = days * 1440 + hours * 60 + minutes
return total if 0 < total <= 7 * 1440 else None
def _yield(value):
if isinstance(value, list):
value = next((v for v in value if isinstance(v, (str, int))), None)
if isinstance(value, (int, float)):
return int(value), ""
if isinstance(value, str):
match = re.search(r"(\d+)", value)
if match:
label = _clean(value[match.end():]).strip(" ()")
return int(match.group(1)), label[:30]
return None, ""
def _steps(value):
steps = []
if isinstance(value, str):
return parsing.parse_steps(_clean_multiline(value))
if isinstance(value, dict):
value = [value]
for item in value or []:
if isinstance(item, str):
steps.append(_clean(item))
elif isinstance(item, dict):
types = _types(item)
if "howtosection" in types:
steps.extend(_steps(item.get("itemListElement")))
else:
steps.append(_clean(item.get("text") or item.get("name") or ""))
return [s for s in steps if s]
def _clean_multiline(text):
text = re.sub(r"<br\s*/?>|</p>|</li>", "\n", text, flags=re.IGNORECASE)
return html.unescape(_TAG_RE.sub("", text))
def _keywords(recipe):
words = []
for key in ("recipeCategory", "recipeCuisine", "keywords"):
value = recipe.get(key)
if isinstance(value, str):
words.extend(value.split(","))
elif isinstance(value, list):
words.extend(v for v in value if isinstance(v, str))
return parsing.parse_tags(",".join(_clean(w) for w in words if len(w.strip()) <= 30))[:6]
def _image(value):
if isinstance(value, str):
return value
if isinstance(value, list) and value:
return _image(value[0])
if isinstance(value, dict):
return value.get("url") or value.get("contentUrl") or ""
return ""
def recipe_to_initial(recipe):
"""Map a schema.org Recipe dict to RecipeForm initial values (+ image URL)."""
servings, label = _yield(recipe.get("recipeYield"))
ingredients = recipe.get("recipeIngredient") or recipe.get("ingredients") or []
if isinstance(ingredients, str):
ingredients = [ingredients]
prep = _duration_minutes(recipe.get("prepTime"))
cook = _duration_minutes(recipe.get("cookTime"))
if prep is None and cook is None:
prep = _duration_minutes(recipe.get("totalTime"))
initial = {
"title": _clean(recipe.get("name"))[:200],
"description": _clean(recipe.get("description")),
"prep_minutes": prep,
"cook_minutes": cook,
"ingredients_text": "\n".join(line for line in (_clean(i) for i in ingredients if isinstance(i, str)) if line),
"steps_text": parsing.steps_to_text(_steps(recipe.get("recipeInstructions"))),
"tags_text": ", ".join(_keywords(recipe)),
}
if servings and 1 <= servings <= 100:
initial["servings"] = servings
initial["servings_label"] = label if label and len(label) < 30 else "Portionen"
return initial, _image(recipe.get("image"))
def extract(page_html):
"""Return (initial, image_url, page) – initial is None when the page has no usable Recipe."""
page = parse_page(page_html)
for block in page.json_ld:
try:
data = json.loads(block.strip())
except json.JSONDecodeError:
# Some sites put several objects or trailing commas in one block; skip those.
continue
recipe = find_recipe(data)
if recipe:
initial, image = recipe_to_initial(recipe)
if initial["ingredients_text"] or initial["steps_text"]:
return initial, image or page.meta.get("og:image", ""), page
return None, page.meta.get("og:image", ""), page
+2
View File
@@ -10,6 +10,8 @@ urlpatterns = [
path("rezepte/import/", import_views.import_start, name="import"),
path("rezepte/import/foto/", import_views.import_photo, name="import_photo"),
path("rezepte/import/text/", import_views.import_text, name="import_text"),
path("rezepte/import/link/", import_views.import_link, name="import_link"),
path("teilen/", import_views.share_target, name="share"),
path("rezepte/<int:pk>/", views.recipe_detail, name="detail"),
path("rezepte/<int:pk>/bearbeiten/", views.recipe_edit, name="edit"),
path("rezepte/<int:pk>/loeschen/", views.recipe_delete, name="delete"),
+21 -2
View File
@@ -3,6 +3,7 @@ from datetime import timedelta
from django.conf import settings
from django.contrib import messages
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Exists, OuterRef, Prefetch
@@ -14,7 +15,7 @@ from django.views.decorators.http import require_GET, require_POST
from core import ratelimit
from . import ai, images
from . import ai, fetch, images
from .forms import CommentForm, PhotoUploadForm, RecipeForm, SearchForm
from .models import Comment, Favorite, Recipe, RecipeImage, Tag
@@ -27,6 +28,19 @@ def _visible(request):
return Recipe.objects.visible_to(request.user)
def download_image(request, url):
"""Fetch a suggested source photo (SSRF-safe). Returns an uploaded-file object or None."""
try:
result = fetch.fetch(url, accept="image/*", max_bytes=images.max_upload_bytes())
except fetch.FetchError as error:
messages.warning(request, f"Das Bild der Quelle konnte nicht geladen werden: {error.user_message}")
return None
if result.status != 200 or not result.content_type.lower().startswith("image/"):
messages.warning(request, "Das Bild der Quelle konnte nicht geladen werden.")
return None
return SimpleUploadedFile("quelle.jpg", result.body, content_type=result.content_type)
def attach_photos(request, recipe, files):
"""Process uploaded files and attach them. Problems are reported as messages, not exceptions."""
has_cover = recipe.images.filter(is_cover=True).exists()
@@ -124,7 +138,12 @@ def recipe_create(request):
form = RecipeForm(request.POST or None, request.FILES or None)
if request.method == "POST" and form.is_valid():
recipe = form.save(owner=request.user)
attach_photos(request, recipe, form.cleaned_data.get("photos") or [])
photos = list(form.cleaned_data.get("photos") or [])
if form.cleaned_data.get("use_import_image") and form.cleaned_data.get("import_image_url"):
downloaded = download_image(request, form.cleaned_data["import_image_url"])
if downloaded:
photos.insert(0, downloaded)
attach_photos(request, recipe, photos[: settings.IMAGE_MAX_PER_RECIPE])
messages.success(request, "Rezept gespeichert.")
return redirect("recipes:detail", pk=recipe.pk)
return _render_form(request, form)
+41
View File
@@ -0,0 +1,41 @@
"""Generates the PWA icons in static/icons (run once; output is committed)."""
from pathlib import Path
from PIL import Image, ImageDraw
OUT = Path(__file__).resolve().parent.parent / "static" / "icons"
ACCENT = (180, 83, 42)
CREAM = (250, 247, 242)
def draw_pot(size, padding):
img = Image.new("RGB", (size, size), ACCENT)
d = ImageDraw.Draw(img)
s = size - 2 * padding
x0, y0 = padding, padding
# pot body
d.rounded_rectangle([x0 + s * 0.18, y0 + s * 0.42, x0 + s * 0.82, y0 + s * 0.82], radius=s * 0.08, fill=CREAM)
# rim
d.rounded_rectangle([x0 + s * 0.12, y0 + s * 0.36, x0 + s * 0.88, y0 + s * 0.45], radius=s * 0.04, fill=CREAM)
# handles
d.rounded_rectangle([x0 + s * 0.05, y0 + s * 0.5, x0 + s * 0.2, y0 + s * 0.58], radius=s * 0.03, fill=CREAM)
d.rounded_rectangle([x0 + s * 0.8, y0 + s * 0.5, x0 + s * 0.95, y0 + s * 0.58], radius=s * 0.03, fill=CREAM)
# steam
for i, dx in enumerate((0.35, 0.5, 0.65)):
top = y0 + s * (0.12 if i == 1 else 0.17)
d.line([x0 + s * dx, top, x0 + s * dx, y0 + s * 0.3], fill=CREAM, width=max(2, int(s * 0.045)))
return img
def main():
OUT.mkdir(parents=True, exist_ok=True)
draw_pot(192, 16).save(OUT / "icon-192.png")
draw_pot(512, 40).save(OUT / "icon-512.png")
draw_pot(512, 110).save(OUT / "icon-maskable-512.png") # content inside the safe zone
draw_pot(180, 14).save(OUT / "apple-touch-icon.png")
draw_pot(64, 4).save(OUT / "favicon.png")
if __name__ == "__main__":
main()
+3
View File
@@ -280,3 +280,6 @@ h3 { font-size: 1rem; margin: 1rem 0 0.25rem; color: var(--muted); }
.preview .ingredients .amount { flex-basis: 5.5rem; }
button[disabled] { opacity: 0.6; cursor: progress; }
code { font-size: 0.9em; }
.help-box { border-left: 4px solid var(--accent); }
.help-box ol { padding-left: 1.2rem; margin: 0; }
.help-box li { margin-bottom: 0.4rem; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+5
View File
@@ -61,3 +61,8 @@ window.addEventListener("pageshow", () => {
b.textContent = b.dataset.label;
});
});
// Register the (no-cache) service worker so the app can be installed and used as share target.
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => navigator.serviceWorker.register("/sw.js").catch(() => {}));
}
+7
View File
@@ -6,6 +6,11 @@
<meta name="theme-color" content="#b4532a">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}Kochbuch{% endblock %} · Kochbuch</title>
<link rel="manifest" href="{% url 'core:manifest' %}">
<link rel="icon" type="image/png" href="{% static 'icons/favicon.png' %}">
<link rel="apple-touch-icon" href="{% static 'icons/apple-touch-icon.png' %}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Kochbuch">
<link rel="stylesheet" href="{% static 'css/app.css' %}">
<script src="{% static 'js/app.js' %}" defer></script>
</head>
@@ -17,6 +22,8 @@
<summary aria-label="Menü">☰</summary>
<nav>
<a href="{% url 'recipes:list' %}">Rezepte</a>
<a href="{% url 'recipes:list' %}?ansicht=favoriten">Favoriten</a>
<a href="{% url 'recipes:import' %}">Importieren</a>
<a href="{% url 'accounts:profile' %}">Mein Konto</a>
{% if user.is_admin %}
<a href="{% url 'accounts:admin_users' %}">Nutzer verwalten</a>
+25 -5
View File
@@ -4,7 +4,26 @@
<h1>Rezept importieren</h1>
<p class="muted">Das Ergebnis ist immer ein Entwurf, den du prüfst und erst dann speicherst.</p>
{% block link_import %}{% endblock %}
<section class="card">
<h2>🔗 Link</h2>
<form method="post" action="{% url 'recipes:import_link' %}" novalidate>
{% csrf_token %}
{% with form=link_form %}{% include "form_fields.html" %}{% endwith %}
<p class="muted small">Rezeptseiten werden direkt ausgelesen. Bei Instagram wird die Beschreibung des Beitrags übernommen{% if ai_enabled %} und per KI aufbereitet{% endif %}.</p>
<button type="submit" class="button" data-busy-text="Wird geladen …">Importieren</button>
</form>
</section>
{% if instagram_failed %}
<section class="card help-box">
<h2>So klappt es mit Instagram</h2>
<ol>
<li>Öffne den Beitrag in der Instagram-App und tippe auf „… mehr“, damit die ganze Beschreibung sichtbar ist.</li>
<li>Mach einen <strong>Screenshot</strong> und lade ihn unten bei „Foto oder Screenshot“ hoch{% if not ai_enabled %} (braucht die KI){% endif %}, <em>oder</em></li>
<li>öffne den Beitrag im Browser, markiere den Text, kopiere ihn und füge ihn unten bei „Text einfügen“ ein.</li>
</ol>
</section>
{% endif %}
<section class="card">
<h2>📷 Foto oder Screenshot</h2>
@@ -22,15 +41,16 @@
<section class="card">
<h2>📝 Text einfügen</h2>
{% if ai_enabled %}
<form method="post" action="{% url 'recipes:import_text' %}" novalidate>
{% csrf_token %}
{% with form=text_form %}{% include "form_fields.html" %}{% endwith %}
{% if ai_enabled %}
<button type="submit" class="button" data-busy-text="KI bereitet auf …">Mit KI aufbereiten</button>
{% else %}
<p class="muted small">Ohne KI wird der Text als Beschreibung übernommen und du teilst ihn selbst auf.</p>
<button type="submit" class="button">Als Entwurf übernehmen</button>
{% endif %}
</form>
{% else %}
{% include "recipes/_ai_disabled.html" %}
{% endif %}
</section>
<p><a href="{% url 'recipes:create' %}">Oder ganz von Hand eintragen →</a></p>
+12 -2
View File
@@ -164,9 +164,19 @@ def test_without_key_ai_is_disabled_but_app_works(settings, user_client):
@pytest.mark.django_db
def test_without_key_imports_give_clear_message(settings, user_client):
def test_without_key_text_import_becomes_description(settings, user_client):
settings.OPENAI_API_KEY = ""
response = user_client.post(reverse("recipes:import_text"), {"text": "200 g Mehl, 2 Eier, alles verrühren und backen"})
text = "200 g Mehl, 2 Eier, alles verrühren und backen"
response = user_client.post(reverse("recipes:import_text"), {"text": text})
assert "nicht eingerichtet" in response.content.decode()
assert response.context["form"].initial["description"] == text
assert not Recipe.objects.exists()
@pytest.mark.django_db
def test_without_key_photo_import_gives_clear_message(settings, user_client):
settings.OPENAI_API_KEY = ""
response = user_client.post(reverse("recipes:import_photo"), {"photos": [image_upload()]}, follow=True)
assert "nicht eingerichtet" in response.content.decode()
+330
View File
@@ -0,0 +1,330 @@
import io
import json
import socket
import pytest
from django.urls import reverse
from PIL import Image
from recipes import ai, fetch, import_views, instagram, schema_org
from recipes import views as recipe_views
from recipes.fetch import FetchError, FetchResult
from recipes.models import Recipe
from .recipe_helpers import form_data
from .test_ai import ai_answer
# --- SSRF protection ------------------------------------------------------------------------
def fake_dns(monkeypatch, ip):
monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 443))])
@pytest.mark.parametrize("ip", ["127.0.0.1", "10.1.2.3", "192.168.178.1", "172.17.0.1", "169.254.169.254", "100.64.0.1", "0.0.0.0"])
def test_internal_addresses_blocked(monkeypatch, ip):
fake_dns(monkeypatch, ip)
with pytest.raises(FetchError, match="nicht erlaubt"):
fetch.fetch("https://harmless-looking.example/recipe")
def test_ipv6_loopback_and_mapped_blocked(monkeypatch):
for ip in ["::1", "::ffff:127.0.0.1", "fd00::1"]:
monkeypatch.setattr(socket, "getaddrinfo", lambda *a, ip=ip, **k: [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", (ip, 443, 0, 0))])
with pytest.raises(FetchError):
fetch.fetch("https://x.example/")
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://x.example/", "https://user:[email protected]/", "https://x.example:22/", "gopher://x"])
def test_bad_urls_rejected(url):
with pytest.raises(FetchError):
fetch.fetch(url)
def test_redirect_target_is_checked_again(monkeypatch):
class Response:
status = 302
headers = {"Location": "http://internal.example/admin"}
def release_conn(self):
pass
def fake_request(url, accept, timeout):
if "internal" in url:
raise FetchError("Diese Adresse ist nicht erlaubt (internes Netz).")
return Response()
monkeypatch.setattr(fetch, "_request_once", fake_request)
with pytest.raises(FetchError, match="nicht erlaubt"):
fetch.fetch("https://public.example/")
# --- schema.org parsing -----------------------------------------------------------------------
RECIPE_LD = {
"@context": "https://schema.org",
"@graph": [
{"@type": "WebPage", "name": "Seite"},
{
"@type": ["Recipe", "NewsArticle"],
"name": "Kürbissuppe &amp; Ingwer",
"description": "<p>Wärmt von innen.</p>",
"recipeYield": ["4", "4 Portionen"],
"prepTime": "PT15M",
"cookTime": "PT1H5M",
"image": [{"@type": "ImageObject", "url": "https://cdn.example/suppe.jpg"}],
"keywords": "Suppe, Herbst, vegan",
"recipeCategory": "Hauptgericht",
"recipeIngredient": ["1 kg Hokkaido", "2 cm Ingwer", "400 ml Kokosmilch"],
"recipeInstructions": [
{"@type": "HowToSection", "name": "Vorbereitung", "itemListElement": [
{"@type": "HowToStep", "text": "Kürbis würfeln."},
]},
{"@type": "HowToStep", "text": "Alles <b>pürieren</b>."},
],
},
],
}
def page_with(ld, extra=""):
return f"""<html><head><title>Suppe</title><meta property="og:image" content="https://cdn.example/og.jpg">
<script type="application/ld+json">{{ broken json,</script>
<script type="application/ld+json">{json.dumps(ld)}</script>{extra}</head><body><p>Text</p></body></html>"""
def test_schema_org_recipe_extracted():
initial, image, _ = schema_org.extract(page_with(RECIPE_LD))
assert initial["title"] == "Kürbissuppe & Ingwer"
assert initial["description"] == "Wärmt von innen."
assert initial["servings"] == 4 and initial["prep_minutes"] == 15 and initial["cook_minutes"] == 65
assert initial["ingredients_text"].splitlines() == ["1 kg Hokkaido", "2 cm Ingwer", "400 ml Kokosmilch"]
assert initial["steps_text"] == "Kürbis würfeln.\n\nAlles pürieren."
assert initial["tags_text"] == "hauptgericht, suppe, herbst, vegan"
assert image == "https://cdn.example/suppe.jpg"
def test_schema_org_string_instructions_and_yield():
ld = {"@type": "Recipe", "name": "X", "recipeYield": "12 Stück", "recipeIngredient": ["Mehl"],
"recipeInstructions": "Erst das.<br>Dann das."}
initial, image, _ = schema_org.extract(page_with(ld))
assert initial["servings"] == 12 and initial["servings_label"] == "Stück"
assert initial["steps_text"] == "Erst das.\n\nDann das."
assert image == "https://cdn.example/og.jpg" # falls back to og:image
def test_page_without_recipe():
initial, image, page = schema_org.extract("<html><head><title>Blog</title></head><body><nav>Menü</nav><p>Hallo Welt</p><script>x()</script></body></html>")
assert initial is None
assert "Hallo Welt" in page.visible_text() and "Menü" not in page.visible_text() and "x()" not in page.visible_text()
# --- Instagram ---------------------------------------------------------------------------------
@pytest.mark.parametrize(
"url, code",
[
("https://www.instagram.com/p/C1a2b3c4D5e/", "C1a2b3c4D5e"),
("https://instagram.com/reel/C1a2b3c4D5e?igsh=abc", "C1a2b3c4D5e"),
("https://www.instagram.com/kochprofi/p/C1a2b3c4D5e/", "C1a2b3c4D5e"),
("https://www.instagram.com/reels/DaU7f-CjE1c/", "DaU7f-CjE1c"),
("https://www.instagram.com/kochprofi/", None),
("https://evil.example/p/C1a2b3c4D5e/", None),
("https://instagram.com.evil.example/p/C1a2b3c4D5e/", None),
],
)
def test_shortcode(url, code):
assert instagram.shortcode(url) == code
EMBED_HTML = """<html><body><div class="Embed">
<img class="EmbeddedMediaImage" src="https://scontent.cdninstagram.com/x.jpg">
<div class="Caption"><a class="CaptionUsername" href="#">kochprofi</a><br><br>Einfaches Dal 🍛<br><br>
Zutaten:<br>200 g rote Linsen<br>1 Dose Kokosmilch<br><br>Linsen kochen, fertig!
<div class="CaptionComments"><a href="#">Alle 23 Kommentare ansehen</a></div></div></div></body></html>"""
def test_parse_embed_caption():
caption, image = instagram.parse_embed(EMBED_HTML)
assert caption.startswith("Einfaches Dal")
assert "200 g rote Linsen" in caption and "kochprofi" not in caption and "Kommentare" not in caption
assert image == "https://scontent.cdninstagram.com/x.jpg"
def test_parse_og_description_fallback():
html = '<meta property="og:description" content="1.234 likes, 12 comments - kochprofi am 1. März 2026: &quot;Dal mit 200 g Linsen und Kokosmilch&quot;">'
caption, _ = instagram.parse_embed(html)
assert caption == "Dal mit 200 g Linsen und Kokosmilch"
def test_app_shell_means_unavailable(monkeypatch):
monkeypatch.setattr(instagram, "fetch", lambda url: FetchResult(url, 200, "text/html", b"<html><title>Instagram</title></html>"))
with pytest.raises(instagram.InstagramUnavailable):
instagram.fetch_caption("C1a2b3c4D5e")
# --- Views ---------------------------------------------------------------------------------------
def stub_fetch(monkeypatch, html, status=200, content_type="text/html; charset=utf-8"):
calls = []
def fake(url, **kwargs):
calls.append(url)
return FetchResult(url, status, content_type, html.encode())
monkeypatch.setattr(import_views, "fetch", fake)
return calls
@pytest.mark.django_db
def test_link_import_uses_schema_org_without_ai(monkeypatch, settings, user_client):
settings.OPENAI_API_KEY = ""
stub_fetch(monkeypatch, page_with(RECIPE_LD))
response = user_client.post(reverse("recipes:import_link"), {"url": "https://rezepte.example/suppe"})
assert response.status_code == 200
initial = response.context["form"].initial
assert initial["title"] == "Kürbissuppe & Ingwer"
assert initial["source_url"] == "https://rezepte.example/suppe"
html = response.content.decode()
assert 'name="import_image_url"' in html and "Bild der Quelle" in html
assert not Recipe.objects.exists()
@pytest.mark.django_db
def test_link_import_falls_back_to_ai(monkeypatch, settings, user_client):
settings.OPENAI_API_KEY = "sk-test"
stub_fetch(monkeypatch, "<html><title>Omas Dal</title><body><p>200 g Linsen kochen ...</p></body></html>")
seen = []
monkeypatch.setattr(ai, "_create_completion", lambda messages: seen.append(messages) or ai_answer())
response = user_client.post(reverse("recipes:import_link"), {"url": "https://blog.example/dal"})
assert response.context["form"].initial["title"] == "Linsen-Dal"
assert "200 g Linsen" in seen[0][1]["content"]
@pytest.mark.django_db
def test_link_import_without_recipe_and_without_ai(monkeypatch, settings, user_client):
settings.OPENAI_API_KEY = ""
stub_fetch(monkeypatch, "<html><body>nichts</body></html>")
response = user_client.post(reverse("recipes:import_link"), {"url": "https://blog.example/x"})
assert response.status_code == 400
assert "kein strukturiertes Rezept" in response.content.decode()
@pytest.mark.django_db
def test_link_import_reports_fetch_errors(monkeypatch, user_client):
def fail(url, **kwargs):
raise FetchError("Die Seite ist nicht erreichbar.")
monkeypatch.setattr(import_views, "fetch", fail)
response = user_client.post(reverse("recipes:import_link"), {"url": "https://down.example/"})
assert "nicht erreichbar" in response.content.decode()
@pytest.mark.django_db
def test_instagram_failure_offers_paste_fallback(monkeypatch, user_client):
def unavailable(code):
raise instagram.InstagramUnavailable("nope")
monkeypatch.setattr(instagram, "fetch_caption", unavailable)
url = "https://www.instagram.com/p/C1a2b3c4D5e/"
response = user_client.post(reverse("recipes:import_link"), {"url": url}, follow=True)
html = response.content.decode()
assert "So klappt es mit Instagram" in html
assert response.context["text_form"].initial["source_url"] == url
@pytest.mark.django_db
def test_instagram_caption_structured_by_ai(monkeypatch, settings, user_client):
settings.OPENAI_API_KEY = "sk-test"
monkeypatch.setattr(instagram, "fetch_caption", lambda code: ("Dal: 200 g Linsen, Kokosmilch. Kochen!", "https://cdn.example/i.jpg"))
seen = []
monkeypatch.setattr(ai, "_create_completion", lambda messages: seen.append(messages) or ai_answer())
response = user_client.post(reverse("recipes:import_link"), {"url": "https://www.instagram.com/reel/C1a2b3c4D5e/"})
initial = response.context["form"].initial
assert initial["title"] == "Linsen-Dal" and initial["import_image_url"] == "https://cdn.example/i.jpg"
assert "Instagram" in seen[0][1]["content"]
@pytest.mark.django_db
def test_instagram_profile_link_rejected(user_client):
response = user_client.post(reverse("recipes:import_link"), {"url": "https://www.instagram.com/kochprofi/"})
assert "einzelnen Beitrag" in response.content.decode()
def jpeg_bytes():
buffer = io.BytesIO()
Image.new("RGB", (800, 600), (10, 200, 10)).save(buffer, "JPEG")
return buffer.getvalue()
@pytest.mark.django_db
def test_saving_draft_downloads_source_image(monkeypatch, user_client):
monkeypatch.setattr(recipe_views.fetch, "fetch",
lambda url, **kw: FetchResult(url, 200, "image/jpeg", jpeg_bytes()))
user_client.post(reverse("recipes:create"), form_data(import_image_url="https://cdn.example/i.jpg", use_import_image="on"))
recipe = Recipe.objects.get()
assert recipe.images.count() == 1 and recipe.images.get().is_cover
@pytest.mark.django_db
def test_source_image_skipped_when_unticked(monkeypatch, user_client):
called = []
monkeypatch.setattr(recipe_views.fetch, "fetch", lambda url, **kw: called.append(url))
user_client.post(reverse("recipes:create"), form_data(import_image_url="https://cdn.example/i.jpg"))
assert not called and Recipe.objects.get().images.count() == 0
@pytest.mark.django_db
def test_source_image_to_internal_address_is_refused(monkeypatch, user_client):
fake_dns(monkeypatch, "10.0.0.5")
response = user_client.post(reverse("recipes:create"),
form_data(import_image_url="https://nas.example/secret.jpg", use_import_image="on"), follow=True)
assert "nicht erlaubt" in response.content.decode()
assert Recipe.objects.get().images.count() == 0
@pytest.mark.django_db
def test_source_image_must_be_an_image(monkeypatch, user_client):
monkeypatch.setattr(recipe_views.fetch, "fetch", lambda url, **kw: FetchResult(url, 200, "text/html", b"<html>"))
user_client.post(reverse("recipes:create"), form_data(import_image_url="https://x.example/", use_import_image="on"))
assert Recipe.objects.get().images.count() == 0
# --- PWA / share target ----------------------------------------------------------------------------
@pytest.mark.django_db
@pytest.mark.parametrize(
"params, expected",
[
({"url": "https://www.instagram.com/p/C1a2b3c4D5e/"}, "https%3A%2F%2Fwww.instagram.com%2Fp%2FC1a2b3c4D5e%2F"),
({"text": "Schau mal https://www.instagram.com/reel/XYZ12345/ lecker!"}, "reel%2FXYZ12345%2F"),
],
)
def test_share_target_redirects_to_import(user_client, params, expected):
response = user_client.get(reverse("recipes:share"), params)
assert response.status_code == 302 and expected in response["Location"]
@pytest.mark.django_db
def test_share_target_with_plain_text(user_client):
response = user_client.get(reverse("recipes:share"), {"text": "200 g Mehl und 2 Eier verrühren"})
assert response.context["text_form"].initial["text"].startswith("200 g Mehl")
@pytest.mark.django_db
def test_import_page_prefills_url(user_client):
response = user_client.get(reverse("recipes:import"), {"url": "https://x.example/r"})
assert response.context["link_form"].initial["url"] == "https://x.example/r"
@pytest.mark.django_db
def test_manifest_and_service_worker(client):
manifest = client.get("/manifest.webmanifest")
assert manifest.status_code == 200 and manifest["Content-Type"] == "application/manifest+json"
data = manifest.json()
assert data["share_target"]["action"] == reverse("recipes:share")
sw = client.get("/sw.js")
assert sw.status_code == 200 and "javascript" in sw["Content-Type"]
assert "fetch" not in sw.content.decode() # no caching of private pages