- 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]>
49 lines
1.9 KiB
Python
49 lines
1.9 KiB
Python
"""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")
|