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 0000000..1eace3a Binary files /dev/null and b/static/icons/apple-touch-icon.png differ diff --git a/static/icons/favicon.png b/static/icons/favicon.png new file mode 100644 index 0000000..c20bf86 Binary files /dev/null and b/static/icons/favicon.png differ diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000..d5f4ad6 Binary files /dev/null and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000..89bbb5c Binary files /dev/null and b/static/icons/icon-512.png differ diff --git a/static/icons/icon-maskable-512.png b/static/icons/icon-maskable-512.png new file mode 100644 index 0000000..058cd29 Binary files /dev/null and b/static/icons/icon-maskable-512.png differ 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 @@ ☰