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