From 9962826759df0988bb0a73d91d30ea4e1a31d374 Mon Sep 17 00:00:00 2001 From: Jesper Hessius Date: Wed, 25 Mar 2026 19:46:17 +0100 Subject: [PATCH 1/2] feat: import profiles from URL (#96) Add URL-based profile import supporting .json and .met formats. Backend: - POST /api/import-from-url endpoint with URL validation, 10s timeout, 5MB size limit, duplicate detection, and machine upload Frontend: - 'From URL' button in ProfileImportDialog with URL input step - Direct mode interceptor for /api/import-from-url - ?import= query parameter for deep-link auto-import i18n: All 10 new keys added to all 6 locales (en, sv, de, es, fr, it) Docs: iOS Shortcuts share sheet import instructions Closes #96 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- IOS_SHORTCUTS.md | 25 +++++ apps/server/api/routes/profiles.py | 77 +++++++++++++++ apps/web/public/locales/de/translation.json | 12 ++- apps/web/public/locales/en/translation.json | 12 ++- apps/web/public/locales/es/translation.json | 12 ++- apps/web/public/locales/fr/translation.json | 12 ++- apps/web/public/locales/it/translation.json | 12 ++- apps/web/public/locales/sv/translation.json | 12 ++- apps/web/src/App.tsx | 15 +++ apps/web/src/components/HistoryView.tsx | 19 ++-- .../src/components/ProfileImportDialog.tsx | 98 +++++++++++++++++-- apps/web/src/main.tsx | 18 ++++ 12 files changed, 301 insertions(+), 23 deletions(-) diff --git a/IOS_SHORTCUTS.md b/IOS_SHORTCUTS.md index 28de0c89..decd37c1 100644 --- a/IOS_SHORTCUTS.md +++ b/IOS_SHORTCUTS.md @@ -43,3 +43,28 @@ Same as Photo workflow but use endpoint: `http://:3550/api/analyze_co - **Connection fails** — Ensure your phone is on the same network. Test `http://:3550/docs` in Safari. - **Invalid response** — Check field names are exactly `file` and/or `user_prefs` (case-sensitive). - **Photo won't upload** — Ensure the form field key is `file` and value comes from the Take Photo action. + +## Import Profile from Share Sheet + +Share a profile URL (`.json` or `.met`) from any app to import it directly into MeticAI. + +### Setup + +| # | Action | Configuration | +|---|--------|---------------| +| 1 | **Receive** | URLs from Share Sheet | +| 2 | **URL** | `http://:3550/?import=` appended with Shortcut Input | +| 3 | **Open URLs** | Open the URL from step 2 | + +### How It Works + +MeticAI supports a `?import=` query parameter. When the app loads with this parameter, it automatically opens the import dialog and begins importing the profile from the given URL. + +### Alternative: Direct API Import + +| # | Action | Configuration | +|---|--------|---------------| +| 1 | **Receive** | URLs from Share Sheet | +| 2 | **Get Contents of URL** | URL: `http://:3550/api/import-from-url`, Method: POST, Body: JSON, `url` = Shortcut Input | +| 3 | **Get Dictionary Value** | Key: `profile_name` | +| 4 | **Show Notification** | Text: "Imported: " + Dictionary Value | diff --git a/apps/server/api/routes/profiles.py b/apps/server/api/routes/profiles.py index 4a203586..79fb846b 100644 --- a/apps/server/api/routes/profiles.py +++ b/apps/server/api/routes/profiles.py @@ -2265,6 +2265,83 @@ async def import_profile(request: Request): ) + +@router.post("/api/import-from-url") +async def import_from_url(request: Request): + """Import a profile from a URL (JSON or .met format).""" + request_id = request.state.request_id + try: + body = await request.json() + url = body.get("url", "").strip() + generate_description = body.get("generate_description", True) + if not url: + raise HTTPException(status_code=400, detail="No URL provided") + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise HTTPException(status_code=400, detail="Only http and https URLs are supported") + logger.info("Importing profile from URL: %s", url, extra={"request_id": request_id}) + max_size = 5 * 1024 * 1024 + try: + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: + resp = await client.get(url) + resp.raise_for_status() + except httpx.TimeoutException: + raise HTTPException(status_code=408, detail="Request timed out fetching URL") + except httpx.HTTPStatusError as exc: + raise HTTPException(status_code=502, detail=f"Remote server returned {exc.response.status_code}") + except httpx.RequestError as exc: + raise HTTPException(status_code=502, detail=f"Failed to fetch URL: {exc}") + if len(resp.content) > max_size: + raise HTTPException(status_code=413, detail="Response too large (max 5 MB)") + try: + profile_json = resp.json() + except Exception: + raise HTTPException(status_code=400, detail="URL did not return valid JSON") + if not isinstance(profile_json, dict): + raise HTTPException(status_code=400, detail="URL did not return a valid profile object") + if not profile_json.get("name"): + raise HTTPException(status_code=400, detail="Profile is missing a 'name' field") + profile_name = profile_json["name"] + history = load_history() + entries = history if isinstance(history, list) else history.get("entries", []) + for entry in entries: + if entry.get("profile_name") == profile_name: + return {"status": "exists", "message": f"Profile '{profile_name}' already exists", "entry_id": entry.get("id"), "profile_name": profile_name} + reply = None + if generate_description: + try: + reply = await _generate_profile_description(profile_json, request_id) + except Exception as e: + logger.warning("Failed to generate description for URL import: %s", e, extra={"request_id": request_id}) + from services.analysis_service import _build_static_profile_description + reply = _build_static_profile_description(profile_json) + else: + from services.analysis_service import _build_static_profile_description + reply = _build_static_profile_description(profile_json) + entry_id = str(uuid.uuid4()) + created_at = datetime.now(timezone.utc).isoformat() + new_entry = {"id": entry_id, "created_at": created_at, "profile_name": profile_name, "user_preferences": f"Imported from URL: {url}", "reply": reply, "profile_json": deep_convert_to_dict(profile_json), "imported": True, "import_source": "url"} + with _history_lock: + history = load_history() + if not isinstance(history, list): + history = history.get("entries", []) + history.insert(0, new_entry) + save_history(history) + machine_profile_id = None + try: + result = await async_create_profile(profile_json) + machine_profile_id = result.get("id") if isinstance(result, dict) else None + logger.info("URL-imported profile uploaded to machine: %s", profile_name, extra={"request_id": request_id, "machine_profile_id": machine_profile_id}) + except Exception as exc: + logger.warning("Profile saved to history but failed to upload to machine: %s", exc, extra={"request_id": request_id, "error_type": type(exc).__name__}) + logger.info("Profile imported from URL successfully: %s", profile_name, extra={"request_id": request_id, "entry_id": entry_id, "source_url": url}) + return {"status": "success", "entry_id": entry_id, "profile_name": profile_name, "has_description": reply is not None and "Description generation failed" not in reply, "uploaded_to_machine": machine_profile_id is not None} + except HTTPException: + raise + except Exception as e: + logger.error("Failed to import profile from URL: %s", str(e), exc_info=True, extra={"request_id": request_id, "error_type": type(e).__name__}) + raise HTTPException(status_code=500, detail={"status": "error", "error": str(e)}) + @router.post("/api/profile/import-all") async def import_all_profiles(request: Request): """Import all profiles from the Meticulous machine that aren't already in history. diff --git a/apps/web/public/locales/de/translation.json b/apps/web/public/locales/de/translation.json index c657605e..df11ed1e 100644 --- a/apps/web/public/locales/de/translation.json +++ b/apps/web/public/locales/de/translation.json @@ -652,7 +652,17 @@ "bulkImportFailed": "Massen-Import fehlgeschlagen", "fetchDetailsFailed": "Fehler beim Abrufen der Profildetails", "noResponseStream": "Kein Antwortstream verfügbar", - "bulkImportStartFailed": "Fehler beim Starten des Massenimports" + "bulkImportStartFailed": "Fehler beim Starten des Massenimports", + "fromUrl": "Von URL", + "jsonOrMet": ".json / .met", + "importFromUrl": "Von URL importieren", + "urlPlaceholder": "Profil-URL einfügen...", + "urlHint": "Unterstützt .json- und .met-Profil-URLs", + "back": "Zurück", + "importButton": "Importieren", + "fetchingUrl": "Profil von URL abrufen...", + "importUrlFailed": "Import von URL fehlgeschlagen", + "invalidUrl": "Bitte geben Sie eine gültige URL ein" }, "imageCrop": { "title": "Profilbild zuschneiden", diff --git a/apps/web/public/locales/en/translation.json b/apps/web/public/locales/en/translation.json index 0d720abf..8ba12b16 100644 --- a/apps/web/public/locales/en/translation.json +++ b/apps/web/public/locales/en/translation.json @@ -657,7 +657,17 @@ "bulkImportFailed": "Bulk import failed", "bulkImportStartFailed": "Failed to start bulk import", "noResponseStream": "No response stream available", - "fetchDetailsFailed": "Failed to fetch profile details" + "fetchDetailsFailed": "Failed to fetch profile details", + "fromUrl": "From URL", + "jsonOrMet": ".json / .met", + "importFromUrl": "Import from URL", + "urlPlaceholder": "Paste profile URL...", + "urlHint": "Supports .json and .met profile URLs", + "back": "Back", + "importButton": "Import", + "fetchingUrl": "Fetching profile from URL...", + "importUrlFailed": "Failed to import from URL", + "invalidUrl": "Please enter a valid URL" }, "imageCrop": { "title": "Crop Profile Image", diff --git a/apps/web/public/locales/es/translation.json b/apps/web/public/locales/es/translation.json index cbaf03d9..fe8b5d11 100644 --- a/apps/web/public/locales/es/translation.json +++ b/apps/web/public/locales/es/translation.json @@ -652,7 +652,17 @@ "bulkImportFailed": "Importación masiva fallida", "fetchDetailsFailed": "Error al obtener detalles del perfil", "noResponseStream": "No hay flujo de respuesta disponible", - "bulkImportStartFailed": "Error al iniciar la importación masiva" + "bulkImportStartFailed": "Error al iniciar la importación masiva", + "fromUrl": "Desde URL", + "jsonOrMet": ".json / .met", + "importFromUrl": "Importar desde URL", + "urlPlaceholder": "Pegar URL del perfil...", + "urlHint": "Admite URLs de perfiles .json y .met", + "back": "Atrás", + "importButton": "Importar", + "fetchingUrl": "Obteniendo perfil desde URL...", + "importUrlFailed": "Error al importar desde URL", + "invalidUrl": "Introduce una URL válida" }, "imageCrop": { "title": "Recortar imagen de perfil", diff --git a/apps/web/public/locales/fr/translation.json b/apps/web/public/locales/fr/translation.json index 87d90de1..29d05efa 100644 --- a/apps/web/public/locales/fr/translation.json +++ b/apps/web/public/locales/fr/translation.json @@ -652,7 +652,17 @@ "bulkImportFailed": "Échec de l'importation en masse", "fetchDetailsFailed": "Échec de la récupération des détails du profil", "noResponseStream": "Aucun flux de réponse disponible", - "bulkImportStartFailed": "Échec du démarrage de l'importation en masse" + "bulkImportStartFailed": "Échec du démarrage de l'importation en masse", + "fromUrl": "Depuis URL", + "jsonOrMet": ".json / .met", + "importFromUrl": "Importer depuis URL", + "urlPlaceholder": "Coller l'URL du profil...", + "urlHint": "Prend en charge les URL de profils .json et .met", + "back": "Retour", + "importButton": "Importer", + "fetchingUrl": "Récupération du profil depuis l'URL...", + "importUrlFailed": "Échec de l'importation depuis l'URL", + "invalidUrl": "Veuillez entrer une URL valide" }, "imageCrop": { "title": "Recadrer l'image du profil", diff --git a/apps/web/public/locales/it/translation.json b/apps/web/public/locales/it/translation.json index 535acc44..e2c1dfa2 100644 --- a/apps/web/public/locales/it/translation.json +++ b/apps/web/public/locales/it/translation.json @@ -652,7 +652,17 @@ "bulkImportFailed": "Importazione di massa fallita", "fetchDetailsFailed": "Impossibile recuperare i dettagli del profilo", "noResponseStream": "Nessun flusso di risposta disponibile", - "bulkImportStartFailed": "Impossibile avviare l'importazione di massa" + "bulkImportStartFailed": "Impossibile avviare l'importazione di massa", + "fromUrl": "Da URL", + "jsonOrMet": ".json / .met", + "importFromUrl": "Importa da URL", + "urlPlaceholder": "Incolla URL del profilo...", + "urlHint": "Supporta URL di profili .json e .met", + "back": "Indietro", + "importButton": "Importa", + "fetchingUrl": "Recupero del profilo dall'URL...", + "importUrlFailed": "Importazione da URL non riuscita", + "invalidUrl": "Inserisci un URL valido" }, "imageCrop": { "title": "Ritaglia immagine profilo", diff --git a/apps/web/public/locales/sv/translation.json b/apps/web/public/locales/sv/translation.json index 6f76e2a5..b3539967 100644 --- a/apps/web/public/locales/sv/translation.json +++ b/apps/web/public/locales/sv/translation.json @@ -657,7 +657,17 @@ "bulkImportFailed": "Bulkimport misslyckades", "fetchDetailsFailed": "Kunde inte hämta profildetaljer", "noResponseStream": "Ingen svarsström tillgänglig", - "bulkImportStartFailed": "Kunde inte starta bulkimport" + "bulkImportStartFailed": "Kunde inte starta bulkimport", + "fromUrl": "Från URL", + "jsonOrMet": ".json / .met", + "importFromUrl": "Importera från URL", + "urlPlaceholder": "Klistra in profil-URL...", + "urlHint": "Stöder .json- och .met-profil-URL:er", + "back": "Tillbaka", + "importButton": "Importera", + "fetchingUrl": "Hämtar profil från URL...", + "importUrlFailed": "Kunde inte importera från URL", + "invalidUrl": "Ange en giltig URL" }, "imageCrop": { "title": "Beskär profilbild", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index edf8dc32..4f55b441 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -90,6 +90,7 @@ function App() { const [shotHistoryProfileName, setShotHistoryProfileName] = useState(undefined) const [shotHistoryInitialDate, setShotHistoryInitialDate] = useState(undefined) const [shotHistoryInitialFilename, setShotHistoryInitialFilename] = useState(undefined) + const [pendingImportUrl, setPendingImportUrl] = useState(null) const fileInputRef = useRef(null) const resultsCardRef = useRef(null) const clickTimerRef = useRef(null) @@ -282,6 +283,18 @@ function App() { checkProfiles() }, []) + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const importParam = params.get('import') + if (importParam) { + setPendingImportUrl(importParam) + setViewState('history') + const url = new URL(window.location.href) + url.searchParams.delete('import') + window.history.replaceState({}, '', url.toString()) + } + }, []) + // Update profile count when returning from history view const refreshProfileCount = useCallback(async () => { if (isDirectMode()) return @@ -984,6 +997,8 @@ function App() { onManageMachine={() => setViewState('profile-catalogue')} aiConfigured={aiAvailable} hideAiWhenUnavailable={hideAiWhenUnavailable} + importUrl={pendingImportUrl} + onImportUrlConsumed={() => setPendingImportUrl(null)} /> )} diff --git a/apps/web/src/components/HistoryView.tsx b/apps/web/src/components/HistoryView.tsx index c5342789..0bfdd69a 100644 --- a/apps/web/src/components/HistoryView.tsx +++ b/apps/web/src/components/HistoryView.tsx @@ -114,9 +114,11 @@ interface HistoryViewProps { onManageMachine?: () => void aiConfigured?: boolean hideAiWhenUnavailable?: boolean + importUrl?: string | null + onImportUrlConsumed?: () => void } -export function HistoryView({ onBack, onViewProfile, onGenerateNew, onManageMachine, aiConfigured = true, hideAiWhenUnavailable = false }: HistoryViewProps) { +export function HistoryView({ onBack, onViewProfile, onGenerateNew, onManageMachine, aiConfigured = true, hideAiWhenUnavailable = false, importUrl, onImportUrlConsumed }: HistoryViewProps) { const { t } = useTranslation() const { entries, @@ -143,6 +145,8 @@ export function HistoryView({ onBack, onViewProfile, onGenerateNew, onManageMach fetchHistory() }, [fetchHistory]) + useEffect(() => { if (importUrl) setShowImportDialog(true) }, [importUrl]) + // Fetch sync badge count useEffect(() => { if (!onManageMachine) return @@ -545,15 +549,10 @@ export function HistoryView({ onBack, onViewProfile, onGenerateNew, onManageMach isOpen={showImportDialog} aiConfigured={aiConfigured} hideAiWhenUnavailable={hideAiWhenUnavailable} - onClose={() => setShowImportDialog(false)} - onImported={() => { - setShowImportDialog(false) - fetchHistory() - }} - onGenerateNew={() => { - setShowImportDialog(false) - onGenerateNew() - }} + initialUrl={importUrl || undefined} + onClose={() => { setShowImportDialog(false); onImportUrlConsumed?.() }} + onImported={() => { setShowImportDialog(false); onImportUrlConsumed?.(); fetchHistory() }} + onGenerateNew={() => { setShowImportDialog(false); onImportUrlConsumed?.(); onGenerateNew() }} /> ) diff --git a/apps/web/src/components/ProfileImportDialog.tsx b/apps/web/src/components/ProfileImportDialog.tsx index 7a26970d..55971326 100644 --- a/apps/web/src/components/ProfileImportDialog.tsx +++ b/apps/web/src/components/ProfileImportDialog.tsx @@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Label } from '@/components/ui/label' +import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' @@ -19,7 +20,8 @@ import { Plus, MagicWand, DownloadSimple, - Info + Info, + LinkSimple } from '@phosphor-icons/react' import { getServerUrl } from '@/lib/config' @@ -51,14 +53,15 @@ interface ProfileImportDialogProps { isOpen: boolean aiConfigured?: boolean hideAiWhenUnavailable?: boolean + initialUrl?: string onClose: () => void onImported: () => void onGenerateNew: () => void } -type ImportStep = 'choose' | 'file' | 'machine' | 'importing' | 'bulk-importing' | 'success' | 'bulk-success' | 'error' +type ImportStep = 'choose' | 'file' | 'machine' | 'url' | 'importing' | 'bulk-importing' | 'success' | 'bulk-success' | 'error' -export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUnavailable = false, onClose, onImported, onGenerateNew }: ProfileImportDialogProps) { +export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUnavailable = false, initialUrl, onClose, onImported, onGenerateNew }: ProfileImportDialogProps) { const { t } = useTranslation() const [step, setStep] = useState('choose') const [machineProfiles, setMachineProfiles] = useState([]) @@ -70,13 +73,15 @@ export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUna const [bulkProgress, setBulkProgress] = useState(null) const [bulkLogs, setBulkLogs] = useState([]) const [generateDescriptions, setGenerateDescriptions] = useState(aiConfigured) + const [importUrl, setImportUrl] = useState('') const fileInputRef = useRef(null) const abortControllerRef = useRef(null) // Reset state when dialog opens useEffect(() => { if (isOpen) { - setStep('choose') + setStep(initialUrl ? 'url' : 'choose') + setImportUrl(initialUrl || '') setMachineProfiles([]) setSelectedProfile(null) setError(null) @@ -84,14 +89,18 @@ export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUna setBulkProgress(null) setBulkLogs([]) setGenerateDescriptions(aiConfigured) + if (initialUrl) { + setTimeout(() => handleUrlImport(initialUrl), 100) + } } else { - // Cleanup abort controller when dialog closes + setImportUrl('') if (abortControllerRef.current) { abortControllerRef.current.abort() abortControllerRef.current = null } } - }, [isOpen, aiConfigured]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, aiConfigured, initialUrl]) const fetchMachineProfiles = async () => { setLoadingMachine(true) @@ -118,6 +127,54 @@ export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUna } } + const handleUrlImport = async (urlOverride?: string) => { + const urlToImport = urlOverride || importUrl.trim() + if (!urlToImport) return + + try { + new URL(urlToImport) + } catch { + setError(t('profileImport.invalidUrl')) + setStep('error') + return + } + + setStep('importing') + setImportProgress(t('profileImport.fetchingUrl')) + setError(null) + + try { + const serverUrl = await getServerUrl() + const response = await fetch(`${serverUrl}/api/import-from-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: urlToImport, generate_description: generateDescriptions }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: t('profileImport.importUrlFailed') })) + const errorMessage = typeof errorData.detail === 'string' + ? errorData.detail + : errorData.detail?.error || errorData.detail?.message || t('profileImport.importUrlFailed') + throw new Error(errorMessage) + } + + const result = await response.json() + + if (result.status === 'exists') { + setError(t('profileImport.profileExists', { name: result.profile_name })) + setStep('error') + return + } + + setImportedProfileName(result.profile_name) + setStep('success') + } catch (err) { + setError(err instanceof Error ? err.message : t('profileImport.importUrlFailed')) + setStep('error') + } + } + const handleBulkImport = async () => { setStep('bulk-importing') setBulkProgress(null) @@ -356,7 +413,7 @@ export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUna -
+
+ + + +
+ + )} + {/* Step: Bulk Importing */} {step === 'bulk-importing' && ( fetch URL, parse profile JSON, save to machine + if (url.match(/\/api\/import-from-url/) && method === 'POST') { + return (async () => { + try { + const body = await new Response(init?.body || '{}').json() as { url?: string } + const profileUrl = body.url?.trim() + let profileResp: Response + try { profileResp = await _fetch(profileUrl) } catch { return jsonResponse({ status: 'error', detail: 'Failed to fetch URL' }, 502) } + let profileJson: Record + try { profileJson = await profileResp.json() } catch { return jsonResponse({ status: 'error', detail: 'URL did not return valid JSON' }, 400) } + const saveResp = await _fetch('/api/v1/profile/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profileJson) }) + if (!saveResp.ok) return jsonResponse({ status: 'error', detail: 'Failed to save profile to machine' }, 502) + return jsonResponse({ status: 'success', entry_id: 'direct-' + Date.now(), profile_name: profileJson.name as string, has_description: false, uploaded_to_machine: true }) + } catch { return jsonResponse({ status: 'error', detail: 'Import from URL failed' }, 500) } + })() + } + // DELETE /api/machine/profile/:id → DELETE /api/v1/profile/delete/:id const deleteMatch = url.match(/\/api\/machine\/profile\/([^/?]+)$/) if (deleteMatch && method === 'DELETE') { From 0ef3ef45a5c64ebabcc138218c851ee1229c9091 Mon Sep 17 00:00:00 2001 From: Jesper Hessius Date: Wed, 25 Mar 2026 23:08:59 +0100 Subject: [PATCH 2/2] fix: address code review findings for #322 (security + validation) - SSRF protection: add _validate_url_for_ssrf() blocking private/reserved IPs, localhost, link-local, and cloud metadata endpoints - Streaming download: use httpx streaming with byte-level 5MB size limit instead of buffering entire response in memory - Race condition: move dedupe existence check inside _history_lock - Direct mode: validate url is present/non-empty (400 if missing) - Direct mode: validate profileJson.name is a non-empty string (400 if not) - setTimeout leak: store timer ref and clear on dialog close - iOS Shortcuts: add URL Encode step for proper query parameter handling - Tests: add TestImportFromUrl with 8 tests covering all error paths and success flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- IOS_SHORTCUTS.md | 5 +- apps/server/api/routes/profiles.py | 54 +++++-- apps/server/test_main.py | 140 ++++++++++++++++++ .../src/components/ProfileImportDialog.tsx | 7 +- apps/web/src/main.tsx | 2 + 5 files changed, 191 insertions(+), 17 deletions(-) diff --git a/IOS_SHORTCUTS.md b/IOS_SHORTCUTS.md index decd37c1..1025d117 100644 --- a/IOS_SHORTCUTS.md +++ b/IOS_SHORTCUTS.md @@ -53,8 +53,9 @@ Share a profile URL (`.json` or `.met`) from any app to import it directly into | # | Action | Configuration | |---|--------|---------------| | 1 | **Receive** | URLs from Share Sheet | -| 2 | **URL** | `http://:3550/?import=` appended with Shortcut Input | -| 3 | **Open URLs** | Open the URL from step 2 | +| 2 | **URL Encode** | Encode Shortcut Input | +| 3 | **URL** | `http://:3550/?import=` appended with URL Encoded Text | +| 4 | **Open URLs** | Open the URL from step 3 | ### How It Works diff --git a/apps/server/api/routes/profiles.py b/apps/server/api/routes/profiles.py index 79fb846b..75387371 100644 --- a/apps/server/api/routes/profiles.py +++ b/apps/server/api/routes/profiles.py @@ -2266,6 +2266,25 @@ async def import_profile(request: Request): +def _validate_url_for_ssrf(url: str) -> None: + """Reject URLs that could hit internal/private network resources.""" + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"Unsupported scheme: {parsed.scheme}") + hostname = parsed.hostname + if not hostname: + raise ValueError("No hostname in URL") + blocked = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "metadata.google.internal"} + if hostname.lower() in blocked: + raise ValueError(f"Blocked hostname: {hostname}") + try: + ip = ipaddress.ip_address(socket.gethostbyname(hostname)) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise ValueError(f"URL resolves to private/reserved IP: {ip}") + except socket.gaierror: + pass # Let httpx handle DNS errors + + @router.post("/api/import-from-url") async def import_from_url(request: Request): """Import a profile from a URL (JSON or .met format).""" @@ -2279,22 +2298,32 @@ async def import_from_url(request: Request): parsed = urlparse(url) if parsed.scheme not in ("http", "https"): raise HTTPException(status_code=400, detail="Only http and https URLs are supported") + try: + _validate_url_for_ssrf(url) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Blocked URL: {exc}") logger.info("Importing profile from URL: %s", url, extra={"request_id": request_id}) max_size = 5 * 1024 * 1024 try: async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: - resp = await client.get(url) - resp.raise_for_status() + async with client.stream("GET", url) as resp: + resp.raise_for_status() + chunks: list[bytes] = [] + total = 0 + async for chunk in resp.aiter_bytes(8192): + total += len(chunk) + if total > max_size: + raise HTTPException(status_code=413, detail="Response too large (max 5 MB)") + chunks.append(chunk) + content = b"".join(chunks) except httpx.TimeoutException: raise HTTPException(status_code=408, detail="Request timed out fetching URL") except httpx.HTTPStatusError as exc: raise HTTPException(status_code=502, detail=f"Remote server returned {exc.response.status_code}") except httpx.RequestError as exc: raise HTTPException(status_code=502, detail=f"Failed to fetch URL: {exc}") - if len(resp.content) > max_size: - raise HTTPException(status_code=413, detail="Response too large (max 5 MB)") try: - profile_json = resp.json() + profile_json = json.loads(content) except Exception: raise HTTPException(status_code=400, detail="URL did not return valid JSON") if not isinstance(profile_json, dict): @@ -2302,11 +2331,6 @@ async def import_from_url(request: Request): if not profile_json.get("name"): raise HTTPException(status_code=400, detail="Profile is missing a 'name' field") profile_name = profile_json["name"] - history = load_history() - entries = history if isinstance(history, list) else history.get("entries", []) - for entry in entries: - if entry.get("profile_name") == profile_name: - return {"status": "exists", "message": f"Profile '{profile_name}' already exists", "entry_id": entry.get("id"), "profile_name": profile_name} reply = None if generate_description: try: @@ -2323,10 +2347,12 @@ async def import_from_url(request: Request): new_entry = {"id": entry_id, "created_at": created_at, "profile_name": profile_name, "user_preferences": f"Imported from URL: {url}", "reply": reply, "profile_json": deep_convert_to_dict(profile_json), "imported": True, "import_source": "url"} with _history_lock: history = load_history() - if not isinstance(history, list): - history = history.get("entries", []) - history.insert(0, new_entry) - save_history(history) + entries = history if isinstance(history, list) else history.get("entries", []) + for entry in entries: + if entry.get("profile_name") == profile_name: + return {"status": "exists", "message": f"Profile '{profile_name}' already exists", "entry_id": entry.get("id"), "profile_name": profile_name} + entries.insert(0, new_entry) + save_history(entries) machine_profile_id = None try: result = await async_create_profile(profile_json) diff --git a/apps/server/test_main.py b/apps/server/test_main.py index eb0879eb..b5b8d2d0 100644 --- a/apps/server/test_main.py +++ b/apps/server/test_main.py @@ -22,6 +22,7 @@ import time import asyncio import requests +import httpx # Import the app and services @@ -13874,3 +13875,142 @@ def test_prompt_empty_iterations(self): prompt = build_dialin_recommendation_prompt(roast_level="dark", iterations=[]) assert "dark" in prompt assert "Iteration" not in prompt + + +class TestImportFromUrl: + """Tests for the /api/import-from-url endpoint.""" + + VALID_PROFILE = {"name": "URL Espresso", "temperature": 93.0, "stages": [{"name": "extraction"}]} + + @staticmethod + def _mock_httpx_stream(response_bytes=b'{}', raise_for_status_error=None): + """Build a mock httpx.AsyncClient that streams response_bytes.""" + class FakeStream: + def __init__(self): + self.status_code = 200 + def raise_for_status(self): + if raise_for_status_error: + raise raise_for_status_error + async def aiter_bytes(self, chunk_size=8192): + yield response_bytes + async def __aenter__(self): + return self + async def __aexit__(self, *args): + return False + + class FakeClient: + def __init__(self, **kwargs): + pass + def stream(self, method, url): + return FakeStream() + async def __aenter__(self): + return self + async def __aexit__(self, *args): + return False + + return FakeClient + + def test_missing_url(self, client): + """Returns 400 when URL is missing or empty.""" + resp = client.post("/api/import-from-url", json={"url": ""}) + assert resp.status_code == 400 + assert "No URL" in resp.json()["detail"] + + def test_invalid_scheme(self, client): + """Returns 400 for non-http(s) schemes.""" + resp = client.post("/api/import-from-url", json={"url": "ftp://example.com/profile.json"}) + assert resp.status_code == 400 + assert "http" in resp.json()["detail"].lower() or "scheme" in resp.json()["detail"].lower() + + @patch('api.routes.profiles._validate_url_for_ssrf') + def test_ssrf_blocked(self, mock_validate, client): + """Returns 400 when SSRF validation blocks the URL.""" + mock_validate.side_effect = ValueError("URL resolves to private/reserved IP: 127.0.0.1") + resp = client.post("/api/import-from-url", json={"url": "http://localhost:8080/profile.json"}) + assert resp.status_code == 400 + assert "Blocked URL" in resp.json()["detail"] + + @patch('api.routes.profiles._validate_url_for_ssrf') + @patch('httpx.AsyncClient') + def test_remote_non_200(self, mock_client_class, mock_ssrf, client): + """Returns 502 when the remote server returns an error.""" + mock_ssrf.return_value = None + mock_client_class.side_effect = [TestImportFromUrl._mock_httpx_stream( + raise_for_status_error=httpx.HTTPStatusError( + "Not Found", request=MagicMock(), response=MagicMock(status_code=404) + ) + )] + # Replace with direct class substitution + fake_cls = TestImportFromUrl._mock_httpx_stream( + raise_for_status_error=httpx.HTTPStatusError( + "Not Found", request=MagicMock(), response=MagicMock(status_code=404) + ) + ) + mock_client_class.side_effect = None + mock_client_class.return_value = fake_cls() + resp = client.post("/api/import-from-url", json={"url": "https://example.com/missing.json"}) + assert resp.status_code == 502 + assert "404" in resp.json()["detail"] + + @patch('api.routes.profiles._validate_url_for_ssrf') + @patch('httpx.AsyncClient') + def test_non_json_body(self, mock_client_class, mock_ssrf, client): + """Returns 400 when URL returns non-JSON content.""" + mock_ssrf.return_value = None + fake_cls = TestImportFromUrl._mock_httpx_stream(response_bytes=b"Not JSON") + mock_client_class.return_value = fake_cls() + resp = client.post("/api/import-from-url", json={"url": "https://example.com/page.html"}) + assert resp.status_code == 400 + assert "valid JSON" in resp.json()["detail"] + + @patch('api.routes.profiles._validate_url_for_ssrf') + @patch('httpx.AsyncClient') + def test_missing_name_field(self, mock_client_class, mock_ssrf, client): + """Returns 400 when profile JSON is missing 'name'.""" + mock_ssrf.return_value = None + fake_cls = TestImportFromUrl._mock_httpx_stream(response_bytes=json.dumps({"temperature": 93.0}).encode()) + mock_client_class.return_value = fake_cls() + resp = client.post("/api/import-from-url", json={"url": "https://example.com/profile.json"}) + assert resp.status_code == 400 + assert "name" in resp.json()["detail"].lower() + + @patch('api.routes.profiles.async_create_profile', new_callable=AsyncMock, return_value={"id": "m-1"}) + @patch('api.routes.profiles.save_history') + @patch('api.routes.profiles.load_history', return_value=[ + {"id": "old-1", "profile_name": "URL Espresso", "reply": "desc"} + ]) + @patch('api.routes.profiles._validate_url_for_ssrf') + @patch('httpx.AsyncClient') + def test_dedupe_exists(self, mock_client_class, mock_ssrf, mock_load, mock_save, mock_create, client): + """Returns 'exists' when a profile with the same name is already in history.""" + mock_ssrf.return_value = None + fake_cls = TestImportFromUrl._mock_httpx_stream(response_bytes=json.dumps(self.VALID_PROFILE).encode()) + mock_client_class.return_value = fake_cls() + resp = client.post("/api/import-from-url", json={"url": "https://example.com/profile.json"}) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "exists" + assert data["profile_name"] == "URL Espresso" + mock_save.assert_not_called() + + @patch('api.routes.profiles.async_create_profile', new_callable=AsyncMock, return_value={"id": "m-2"}) + @patch('api.routes.profiles.save_history') + @patch('api.routes.profiles.load_history', return_value=[]) + @patch('api.routes.profiles._generate_profile_description', new_callable=AsyncMock, return_value="Rich espresso") + @patch('api.routes.profiles._validate_url_for_ssrf') + @patch('httpx.AsyncClient') + def test_success_path(self, mock_client_class, mock_ssrf, mock_gen_desc, mock_load, mock_save, mock_create, client): + """Full success path: fetch, parse, save, upload.""" + mock_ssrf.return_value = None + fake_cls = TestImportFromUrl._mock_httpx_stream(response_bytes=json.dumps(self.VALID_PROFILE).encode()) + mock_client_class.return_value = fake_cls() + resp = client.post("/api/import-from-url", json={"url": "https://example.com/profile.json"}) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "success" + assert data["profile_name"] == "URL Espresso" + assert data["has_description"] is True + assert data["uploaded_to_machine"] is True + assert "entry_id" in data + mock_save.assert_called_once() + mock_create.assert_called_once() diff --git a/apps/web/src/components/ProfileImportDialog.tsx b/apps/web/src/components/ProfileImportDialog.tsx index 55971326..4f75612c 100644 --- a/apps/web/src/components/ProfileImportDialog.tsx +++ b/apps/web/src/components/ProfileImportDialog.tsx @@ -76,6 +76,7 @@ export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUna const [importUrl, setImportUrl] = useState('') const fileInputRef = useRef(null) const abortControllerRef = useRef(null) + const autoImportTimerRef = useRef | null>(null) // Reset state when dialog opens useEffect(() => { @@ -90,10 +91,14 @@ export function ProfileImportDialog({ isOpen, aiConfigured = true, hideAiWhenUna setBulkLogs([]) setGenerateDescriptions(aiConfigured) if (initialUrl) { - setTimeout(() => handleUrlImport(initialUrl), 100) + autoImportTimerRef.current = setTimeout(() => handleUrlImport(initialUrl), 100) } } else { setImportUrl('') + if (autoImportTimerRef.current) { + clearTimeout(autoImportTimerRef.current) + autoImportTimerRef.current = null + } if (abortControllerRef.current) { abortControllerRef.current.abort() abortControllerRef.current = null diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7ea9dcb1..92930eac 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -395,10 +395,12 @@ if (isDirectMode()) { try { const body = await new Response(init?.body || '{}').json() as { url?: string } const profileUrl = body.url?.trim() + if (!profileUrl) return jsonResponse({ status: 'error', detail: 'No URL provided' }, 400) let profileResp: Response try { profileResp = await _fetch(profileUrl) } catch { return jsonResponse({ status: 'error', detail: 'Failed to fetch URL' }, 502) } let profileJson: Record try { profileJson = await profileResp.json() } catch { return jsonResponse({ status: 'error', detail: 'URL did not return valid JSON' }, 400) } + if (typeof profileJson.name !== 'string' || !profileJson.name) return jsonResponse({ status: 'error', detail: "Profile is missing a 'name' field" }, 400) const saveResp = await _fetch('/api/v1/profile/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profileJson) }) if (!saveResp.ok) return jsonResponse({ status: 'error', detail: 'Failed to save profile to machine' }, 502) return jsonResponse({ status: 'success', entry_id: 'direct-' + Date.now(), profile_name: profileJson.name as string, has_description: false, uploaded_to_machine: true })