feat: import profiles from URL (#96)#322
Conversation
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=<url> 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>
There was a problem hiding this comment.
Pull request overview
Adds “import profile from URL” support end-to-end (backend endpoint, UI flow, direct-mode interceptor, and deep-linking), plus iOS Shortcuts documentation and i18n updates.
Changes:
- Backend: new
POST /api/import-from-urlroute to fetch a remote profile and import it into history (and attempt upload to the machine). - Frontend: “From URL” option in
ProfileImportDialog, with deep link support via?import=<url>and History view auto-opening. - Docs/i18n: new translation keys across 6 locales and an iOS Share Sheet shortcut workflow.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/web/src/main.tsx | Adds direct-mode fetch interception for /api/import-from-url to fetch remote JSON and save to machine. |
| apps/web/src/components/ProfileImportDialog.tsx | Adds a new “url” import step and supports initialUrl for deep-link initiated imports. |
| apps/web/src/components/HistoryView.tsx | Opens import dialog when importUrl is present and consumes it on close/import actions. |
| apps/web/src/App.tsx | Parses ?import= query param on mount to trigger URL import flow and removes it from the URL. |
| apps/web/public/locales/en/translation.json | Adds new profileImport.* strings for URL import UI. |
| apps/web/public/locales/sv/translation.json | Same as above (Swedish). |
| apps/web/public/locales/de/translation.json | Same as above (German). |
| apps/web/public/locales/es/translation.json | Same as above (Spanish). |
| apps/web/public/locales/fr/translation.json | Same as above (French). |
| apps/web/public/locales/it/translation.json | Same as above (Italian). |
| apps/server/api/routes/profiles.py | Implements /api/import-from-url server-side import workflow. |
| IOS_SHORTCUTS.md | Documents Share Sheet-driven URL import and a direct API alternative. |
apps/server/api/routes/profiles.py
Outdated
| 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} |
There was a problem hiding this comment.
The “already exists” check reads history outside _history_lock, but insertion happens under the lock. Two concurrent imports of the same profile name can both pass the pre-check and create duplicate entries. Consider moving the existence check into the locked section (or using a hash/key-based dedupe) so the check+insert is atomic.
There was a problem hiding this comment.
✅ Resolved in 0ef3ef4. Moved the existence check inside _history_lock — the dedupe read + insert now happens atomically under the lock, preventing concurrent duplicate imports.
| @router.post("/api/import-from-url") | ||
| async def import_from_url(request: Request): | ||
| """Import a profile from a URL (JSON or .met format).""" |
There was a problem hiding this comment.
There are extensive tests for /api/profile/import (see TestProfileImportEndpoint), but no tests for the new /api/import-from-url route. Add unit tests covering: invalid URL/scheme, remote non-200, non-JSON body, missing name, dedupe/exists behavior, and successful import (mocking httpx + history save).
There was a problem hiding this comment.
✅ Resolved in 0ef3ef4. Added TestImportFromUrl class with 8 tests: missing URL, invalid scheme, SSRF blocked, remote non-200, non-JSON body, missing name field, dedupe/exists, and full success path. All 829 backend tests pass.
IOS_SHORTCUTS.md
Outdated
| | 2 | **URL** | `http://<SERVER_IP>:3550/?import=` appended with Shortcut Input | | ||
| | 3 | **Open URLs** | Open the URL from step 2 | |
There was a problem hiding this comment.
The shortcut step that builds http://<SERVER_IP>:3550/?import= by appending “Shortcut Input” will break for many URLs unless the input is URL-encoded (e.g., &, ?, spaces). Update the instructions to URL-encode the shared URL before appending, or include a dedicated “URL Encode” action in the workflow.
| | 2 | **URL** | `http://<SERVER_IP>:3550/?import=` appended with Shortcut Input | | |
| | 3 | **Open URLs** | Open the URL from step 2 | | |
| | 2 | **URL Encode** (or **Encode Text for URL**) | Text: Shortcut Input, Encoding: Query | | |
| | 3 | **URL** | `http://<SERVER_IP>:3550/?import=` appended with Encoded Text from step 2 | | |
| | 4 | **Open URLs** | Open the URL from step 3 | |
There was a problem hiding this comment.
✅ Resolved in 0ef3ef4. Added a URL Encode action step before building the import URL, so special characters (&, ?, spaces) are properly escaped.
| return (async () => { | ||
| try { | ||
| const body = await new Response(init?.body || '{}').json() as { url?: string } | ||
| const profileUrl = body.url?.trim() |
There was a problem hiding this comment.
In direct mode, body.url can be missing/empty, which makes profileUrl undefined and still passed into _fetch(...). This ends up returning a 502 (“Failed to fetch URL”) for a client error and relies on an exception for control flow. Validate that url is a non-empty string (and ideally http/https) and return a 400 before attempting the fetch.
| const profileUrl = body.url?.trim() | |
| const profileUrl = body.url?.trim() | |
| if (!profileUrl) { | |
| return jsonResponse({ status: 'error', detail: 'URL is required' }, 400) | |
| } | |
| let parsedUrl: URL | |
| try { | |
| parsedUrl = new URL(profileUrl) | |
| } catch { | |
| return jsonResponse({ status: 'error', detail: 'URL is invalid' }, 400) | |
| } | |
| if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { | |
| return jsonResponse({ status: 'error', detail: 'URL must start with http:// or https://' }, 400) | |
| } |
There was a problem hiding this comment.
✅ Resolved in 0ef3ef4. Added validation that body.url is present and non-empty before proceeding. Returns 400 with 'No URL provided' if missing.
| let profileJson: Record<string, unknown> | ||
| 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 }) |
There was a problem hiding this comment.
This path returns profile_name: profileJson.name as string without checking that name exists and is a string. If the fetched JSON is not a profile object (or has a non-string name), this will return an invalid success payload and may also send a bad profile to /api/v1/profile/save. Validate required fields (at least name) and return a 400 if they’re missing/invalid.
| let profileJson: Record<string, unknown> | |
| 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 }) | |
| let profileJson: any | |
| try { profileJson = await profileResp.json() } catch { return jsonResponse({ status: 'error', detail: 'URL did not return valid JSON' }, 400) } | |
| if ( | |
| !profileJson || | |
| typeof profileJson.name !== 'string' || | |
| profileJson.name.trim().length === 0 | |
| ) { | |
| return jsonResponse({ status: 'error', detail: 'Profile JSON missing required name' }, 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.trim(), | |
| has_description: false, | |
| uploaded_to_machine: true, | |
| }) |
There was a problem hiding this comment.
✅ Resolved in 0ef3ef4. Added validation: checks typeof profileJson.name === 'string' && profileJson.name before using the name. Returns 400 with "Profile is missing a 'name' field" if invalid.
| setGenerateDescriptions(aiConfigured) | ||
| if (initialUrl) { | ||
| setTimeout(() => handleUrlImport(initialUrl), 100) | ||
| } |
There was a problem hiding this comment.
setTimeout(() => handleUrlImport(initialUrl), 100) is never cleared if the dialog closes quickly or initialUrl changes. This can trigger an import request after the dialog is closed (and update state while closed). Store the timeout id in a ref and clear it in the effect cleanup / close branch, or guard inside the callback with the latest isOpen state.
There was a problem hiding this comment.
✅ Resolved in 0ef3ef4. Added autoImportTimerRef to store the timeout ID. The cleanup branch of the useEffect now calls clearTimeout() before the abort controller cleanup, preventing the leak if the dialog closes within 100ms.
| 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}) |
There was a problem hiding this comment.
This endpoint fetches an arbitrary user-provided URL with no SSRF protections. As written it can be used to probe internal services (localhost, 127.0.0.0/8, RFC1918, link-local, cloud metadata IPs, etc.) via the server. Add host/IP allow/deny validation (including DNS resolution and post-redirect validation) before performing the request, and reject private/loopback/link-local ranges.
There was a problem hiding this comment.
✅ Resolved in 0ef3ef4. Added _validate_url_for_ssrf() that blocks: non-http(s) schemes, localhost/127.0.0.1/::1/0.0.0.0/metadata.google.internal hostnames, and any URL that resolves to a private, loopback, link-local, or reserved IP address. Called before the fetch, returns 400 on violation.
apps/server/api/routes/profiles.py
Outdated
| 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() |
There was a problem hiding this comment.
max_size is checked after client.get(url) has already downloaded the full body into memory (resp.content). A large response can still consume bandwidth/memory before you reject it. Use a streaming request (client.stream(...)) and enforce a byte limit while reading, or otherwise configure httpx to cap download size before buffering.
| 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() | |
| async with client.stream("GET", url) as resp: | |
| resp.raise_for_status() | |
| chunks: list[bytes] = [] | |
| total_bytes = 0 | |
| async for chunk in resp.aiter_bytes(): | |
| if not chunk: | |
| continue | |
| total_bytes += len(chunk) | |
| if total_bytes > max_size: | |
| raise HTTPException(status_code=413, detail="Response too large (max 5 MB)") | |
| chunks.append(chunk) | |
| 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}") | |
| body_bytes = b"".join(chunks) | |
| try: | |
| text = body_bytes.decode(resp.encoding or "utf-8", errors="strict") | |
| profile_json = json.loads(text) |
There was a problem hiding this comment.
✅ Resolved in 0ef3ef4. Replaced client.get(url) + post-hoc size check with client.stream('GET', url) + aiter_bytes(8192), accumulating chunks with a running byte count. Raises 413 immediately when the limit is exceeded, before buffering the full response.
- 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>
Review Findings AddressedAll 8 code review comments resolved in
Quality Gates
Issue #96 Completeness
Ready to merge. |
Summary
Adds URL-based profile import across the full stack.
Changes
POST /api/import-from-urlendpoint — fetches JSON/.metprofiles from any URL, validates, deduplicates, generates AI descriptions, and uploads to machinemain.tsxfor PWA/offline support?import=<url>query parameter auto-opens import dialog with pre-filled URLQuality Gates
Closes #96