From 60785e24377f7198dba0b36df742463511d6bf1e Mon Sep 17 00:00:00 2001 From: Kyle Dammann Date: Fri, 30 Oct 2026 21:53:28 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 9 +- core/pwa.py | 48 +++++ core/urls.py | 4 +- recipes/fetch.py | 126 +++++++++++ recipes/forms.py | 9 +- recipes/import_views.py | 116 +++++++++- recipes/instagram.py | 114 ++++++++++ recipes/schema_org.py | 216 +++++++++++++++++++ recipes/urls.py | 2 + recipes/views.py | 23 +- scripts/make_icons.py | 41 ++++ static/css/app.css | 3 + static/icons/apple-touch-icon.png | Bin 0 -> 716 bytes static/icons/favicon.png | Bin 0 -> 316 bytes static/icons/icon-192.png | Bin 0 -> 787 bytes static/icons/icon-512.png | Bin 0 -> 2429 bytes static/icons/icon-maskable-512.png | Bin 0 -> 2224 bytes static/js/app.js | 5 + templates/base.html | 7 + templates/recipes/import.html | 30 ++- tests/test_ai.py | 14 +- tests/test_link_import.py | 330 +++++++++++++++++++++++++++++ 22 files changed, 1081 insertions(+), 16 deletions(-) create mode 100644 core/pwa.py create mode 100644 recipes/fetch.py create mode 100644 recipes/instagram.py create mode 100644 recipes/schema_org.py create mode 100644 scripts/make_icons.py create mode 100644 static/icons/apple-touch-icon.png create mode 100644 static/icons/favicon.png create mode 100644 static/icons/icon-192.png create mode 100644 static/icons/icon-512.png create mode 100644 static/icons/icon-maskable-512.png create mode 100644 tests/test_link_import.py diff --git a/CLAUDE.md b/CLAUDE.md index 973ff6e..32d4f9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/core/pwa.py b/core/pwa.py new file mode 100644 index 0000000..c86af5b --- /dev/null +++ b/core/pwa.py @@ -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") diff --git a/core/urls.py b/core/urls.py index 1809821..15dec97 100644 --- a/core/urls.py +++ b/core/urls.py @@ -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"), ] diff --git a/recipes/fetch.py b/recipes/fetch.py new file mode 100644 index 0000000..eeb2a28 --- /dev/null +++ b/recipes/fetch.py @@ -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.") diff --git a/recipes/forms.py b/recipes/forms.py index cdb1719..b794a9c 100644 --- a/recipes/forms.py +++ b/recipes/forms.py @@ -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" diff --git a/recipes/import_views.py b/recipes/import_views.py index ffbbc12..7432fb1 100644 --- a/recipes/import_views.py +++ b/recipes/import_views.py @@ -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") diff --git a/recipes/instagram.py b/recipes/instagram.py new file mode 100644 index 0000000..89ad166 --- /dev/null +++ b/recipes/instagram.py @@ -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
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 diff --git a/recipes/schema_org.py b/recipes/schema_org.py new file mode 100644 index 0000000..29f4f74 --- /dev/null +++ b/recipes/schema_org.py @@ -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, tags, the 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 diff --git a/recipes/urls.py b/recipes/urls.py index ae44a91..ee0f670 100644 --- a/recipes/urls.py +++ b/recipes/urls.py @@ -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"), diff --git a/recipes/views.py b/recipes/views.py index 3362bd2..a52f9df 100644 --- a/recipes/views.py +++ b/recipes/views.py @@ -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) diff --git a/scripts/make_icons.py b/scripts/make_icons.py new file mode 100644 index 0000000..fb6896e --- /dev/null +++ b/scripts/make_icons.py @@ -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() diff --git a/static/css/app.css b/static/css/app.css index 8f33ef2..f1ee96d 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; } diff --git a/static/icons/apple-touch-icon.png b/static/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1eace3a0fa3ea72ee4f4567ef42f8fb40c75aaa2 GIT binary patch literal 716 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbofoZa*i(^Q|oVRy3=H3nvaR{{h zU@}udxT0~3oWAmsODvMwt_>oc>lvNz|LMPEx%3Xl{OSEmbh`ZQ_t)<|5WR2DPpyvT z?=xgn3MU+K6k_)zld<Iazst#$KR>>nAHHt=eZBs(PZT<vRrn@Zcsv3!cJG@aTO|6+ z=y9%2N)ZY6EKxtdX*s6blHYCm|39q6?#T)3@BP19Ld2GPKUr|N%jn#WdG1FR_Do*4 zN;v4-(ZrlN<+;9KCnUtnr2pS}^rT%iP+;4!#ELm3q5QLCepx)OlKcC%Sibts3%max z!=4zGJ+e|v&5}8G?8#R{O9{#LjfOQ-eSk(-JPEp%_~@Ko!*pN1)H55iqStgfOJ3Hw zsl@##;F!<$d~x+9NCEg^X5kO62SDNJrjwoJLCQ|^Y5$s$e0AH6pRz&nYx(YfoHJn( z5y7p%XLk-IAR!?-<K3?JE2XR-7rg&kpvE5j*uduAb5CEMyT#w+!?x)&7C*jl{6=ls z|I3R1KRMeMZ;}u{F3&&jM5&LY@Z*XzF6$=Gnjyz7++(4bszzC)BY7DqQV%Zu!^S-~ WeS1B_c}ZX@W$<+Mb6Mw<&;$UVbt20E literal 0 HcmV?d00001 diff --git a/static/icons/favicon.png b/static/icons/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c20bf8630cd08e064ca3161023872a2421bfedab GIT binary patch literal 316 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFfx0(IEGZrd3)nv;UNP7w}<o1 zSE`8yG)kW^eHzHLd~L&~3<m!t2Pb`Mc>m_(yQtlp-dz6r+&ayr?}VVGlKYbmnTg6p zE`1jnx1G-2edpNS`9R^YuO|c7y27MR2=2W6d~uzj()X(scP|J(o%hT3`2^93Nwv2g zeahdQ-?MH$BVXo`!dL5G_-b!h)t1$9`@7O?Gybc8PFSTSJTu8ZyRj@h;q9}_`*b2U zo%ajN-_WbYy<%zg|9S0^r8$cyvoF3S_o#c@1P-t}AdWimTO>f3-^27#cTN=VRpF(( tTivb)%>DlV&)m3P6la4RtNvk)b)UC2d)^tJK47RYc)I$ztaD0e0syM^kY@k@ literal 0 HcmV?d00001 diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f4ad63a88108369a5969f12e7b4699c07c3a23 GIT binary patch literal 787 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf$5f~i(^Q|oVRxl`br0iI0SYC z@ro{Cl1iBUDTrzHN`=%^Y0j`a>p%XUUp&oY+Zyxh=f5SK|NQ&*Y2liy{rdNXSX@$> zCvtSSIH)iw2?}&Jc(AxADF`u=DKu&R+h@nC{(k=cSbqKe{c;~8+%`9&nP~ae=E9cG z=Iam3oi8#Y*+QHB`}c0Ceh`gete}8&+`XrlyXRfFTmNtddyc}_IVQJvGY6ed6y9eF z<d{GI%@lNgZr<!|{)tFlU0@trK6lbS{RNZt&Yw#*o6vS{_UF6$iBeWMhx&g;_HVv) z+{QBfubxlDZQW0f^ABCw{CEu;>ybW|BZ7=)Z=T#;G1qXTCL<6TzDfQw;p!>d<qTr^ z3SZA`|0c1P$HR7aM8%S6tV`zYU1Jq@&t)=7Fdyk@cvj=yXWf7O6z`ISu{t(E-SLM^ zOzz)GHhJp(^`G#jmzS^3&LBG`z#5gAW_>Vzzc<EM%}iARi2OJ+PWatBWSSP%%p_$x z{p32&;8~&q)mM!T7i2O^J)LDfM|1j)-zQw(Kf7*R$jm$c`r?@2<%Z?Vf8XEETb!L` sKAZJOL~;C~8%-1@6C!+dB={qLVp`hX9OL!pfQg;K)78&qol`;+0L82uNdN!< literal 0 HcmV?d00001 diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..89bbb5c7b6e798d01f8b21a51bfdd584c8ab87dc GIT binary patch literal 2429 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_n+SPZ!6KiaBrZo-LG$6ln;Y z5oFTUz~SK#eJGeCs+5H*+blS)=WFwZh4-uPTV@BpYufkywfd7!AKyOx$>4C_zB-<P z;kN)og9al*htMcRQV^_IzE_{&-Mc@(>TYvyKm9U4o}1?gCxe0)1H&Q~im6wb@3|97 zo?ridJ8nm;U;Xc>MKvsUI)xb=rZ6zLa1Lck_I}*F@B7#o${w&V2&ghJ1Ts;X`nu&i ztIwN1zxI7sWoUm}^hxi|vmf6+)jak-yrxcKg3zBS41apA@;6Uze9d`a>zcY59Xv6I zrymI4_%~56?VA+S2itO?Gbf(Zc{A*LDA!au^Zt6y1L1dU4XlIzu!3?)gMVc~gXxT? z=Q%*JP;*9^dCd`*dJj;nKVX&<OR#<RtDIOb$=}dhe!O1jK<v!d_hFHG;P@P|L{+t4 z&R|34e>%aCZE$+NAVWgxw*TTR547`S6B3y|@bWf5oc8^Wvw`yD-)kS=KK=Q7eb?cK ze}28oZP1MP8!pr^e<GvAoVWG1j5}I+4m7O!H(#jX_DP1G1NZjNWO(Cj^Pv9C$K#)) z9jrI}Js8vDz#w<_8~bHL4zMTNKmFiOOOXM)q5ttmd7o1O;6yEV<gxvX=@-F%2PVV= z%0GVc&zQvsb37<W?#$I^IPj*&6C)qY<Lj1sQl9_E{`!aZGoSv-022Z~x8##J#;~oi ze(j+;UzHVZ%Wp_8QfF8#U$G(g-{J#Y>I^`U%0-L}3nUo&|2O;%=4p7#|K_#L=X|+y zvJ4D)YQXd(%%1RSviOFc&s%n9JYrz@piXk`Q{y{OX8E?3lcBw`{NfQth6nyY|6RGm zIOF>h)-{Q<E4k0gHr%{ln~-(;$?x=E3=D6QX4m}I7iwU&V@uGxEtLHnl)fU4{`^|| z-kagtcHNDC#Vr}vd|%0M_?Fl$WAC{PY&N_((=RK8GZ{;zH*IVE_D|ox-BtHzrsIUj zee;*rv9KKYO0uJejfQu5yXU<7Km9<{nR9!Q(-9<j>2dp<Z2<eV;h}|zeH(*KhqRPL zFQ^ne@YsT-!Bese%yzb9X^@m`XRw*Y*6>oI1uVuV!*l?Yu)i&2Oi(IQXE<=qhX1WA zL)qhH?9ChW8E;Hx6G&(IrpjP#%#gD4XSq7VZ(}x=op0R>YN{*m{g(Q1{^!@b)sBsC z?K#vN8fS2E-Fg1FfJvgSm|^v%U+&5b&660#l7I7iFtn*L>l}C`KaIi1fo;abz4msD zGdhI~SXbAJF&StZCdiomTLSdKsr}5OmF(baXE<NpFZ<$iet+V&8Ng;0gQu&X%Q~lo FCICAZm6`wm literal 0 HcmV?d00001 diff --git a/static/icons/icon-maskable-512.png b/static/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..058cd29bb5f646818c8996800c87a2ceaa825293 GIT binary patch literal 2224 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_q9DPZ!6KiaBrZp3c4PAmR|H z8DP4~L39UWZs<Jk3tpl-*67Gc^a(#nR;iM?TYSyT;X?kiNsAocu7CcRuVJ44Wp;)G zJd6w-LJSQWqtplr0xKVRhCAmke?C3`&yBZ5zy2R*`h16x!G)8-VG09LRHd{=`6J&s zeoWDi`4|+u7#J3@3@G*O`sc!55)2X#$aM|hW%dL6zkK=gsO8iCyL#&CKMjAlZE#Px z|NLLd14fPmpWgiAnbf$nl5K<8ZhN%}{=yBdH~-#bD&C>T7%?^Uc-f3&uAB!Ztufzr z%x9ekEHth?j+>opH1o6|zU<ZTwSw9Dc8bw-Wd?>orVT37qjGv@#!Li7!+}*X1si3T z6#|6~R`=-|w7(W=*cw?q!@(}M-d5iJ|LvUS?;DEi_3Y#t7;HpTAMoY;<#%RSw4{kY z=e6TJMv<F;4MjN|Cf=3bd{Da_Wcr0ipX7xPNrSR_gTVCvGlcI6BROrw2L^`AhuE1I z1XMwa83ama*fG5OD7jbM_S1Hrl*a3h=Ra3W)0wWexrW8$%sc;?Kj*75O!l#lU~Ohe z=-J7hoj48bYM$x;6I^?cO~zHiG)TrZ^MxI;0GB-pGo&ABG8r7Zz`U6yVG>(IsKJTp zht7Y#%V2Wz*Ktk<CFbp?J=1RRMgMn`S;pUxufu6})IqF*4enZ~qX#(^P`G=}GXBQY z8PhmmB`!x{e81>NpRvqo#mbi#gMT#f&p}<G@)`WGCl=5Cw)<sfNk}vJTVP5`-SH zDEKlPFsCvFr2n1%^Yw8~hO$PD-S)p<|9qjd;r82k^Wx{Ub41&(XK8qU;=tj}zdyS% z{NR+Vu=!S7r^4{3O+x4FzVrGV!0i5?iD|U>$CW;jGRLSASo&nRVOq=T-?aWYqtc0d QU`vF-)78&qol`;+04=5xI{*Lx literal 0 HcmV?d00001 diff --git a/static/js/app.js b/static/js/app.js index 353358b..8ce65fb 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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(() => {})); +} diff --git a/templates/base.html b/templates/base.html index c4062eb..62ad72f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,6 +6,11 @@ <meta name="theme-color" content="#b4532a"> <meta name="robots" content="noindex, nofollow"> <title>{% block title %}Kochbuch{% endblock %} · Kochbuch + + + + + @@ -17,6 +22,8 @@ ☰