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
+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)