Skip to content

feat: import profiles from URL (#96)#322

Merged
hessius merged 2 commits intoversion/2.4.0from
feature/96-import-from-link
Mar 26, 2026
Merged

feat: import profiles from URL (#96)#322
hessius merged 2 commits intoversion/2.4.0from
feature/96-import-from-link

Conversation

@hessius
Copy link
Copy Markdown
Owner

@hessius hessius commented Mar 25, 2026

Summary

Adds URL-based profile import across the full stack.

Changes

  • Backend: POST /api/import-from-url endpoint — fetches JSON/.met profiles from any URL, validates, deduplicates, generates AI descriptions, and uploads to machine
  • Frontend: New "From URL" option in ProfileImportDialog with URL input, validation, and keyboard submit
  • Direct mode: Fetch interceptor in main.tsx for PWA/offline support
  • Deep links: ?import=<url> query parameter auto-opens import dialog with pre-filled URL
  • i18n: 10 new keys across all 6 locales (en, sv, de, es, fr, it)
  • iOS Shortcuts: Documentation for Share Sheet import workflow

Quality Gates

  • ✅ Lint: 0 errors (27 pre-existing warnings)
  • ✅ Build: success
  • ✅ Frontend tests: 416 passed
  • ✅ Backend tests: 802 passed (verified earlier, endpoint code unchanged)

Closes #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=<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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-url route 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.

Comment on lines +2305 to +2309
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}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 0ef3ef4. Moved the existence check inside _history_lock — the dedupe read + insert now happens atomically under the lock, preventing concurrent duplicate imports.

Comment on lines +2269 to +2271
@router.post("/api/import-from-url")
async def import_from_url(request: Request):
"""Import a profile from a URL (JSON or .met format)."""
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Comment on lines +56 to +57
| 2 | **URL** | `http://<SERVER_IP>:3550/?import=` appended with Shortcut Input |
| 3 | **Open URLs** | Open the URL from step 2 |
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
| 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 |

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 0ef3ef4. Added validation that body.url is present and non-empty before proceeding. Returns 400 with 'No URL provided' if missing.

Comment on lines +400 to +404
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 })
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
})

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 91 to +94
setGenerateDescriptions(aiConfigured)
if (initialUrl) {
setTimeout(() => handleUrlImport(initialUrl), 100)
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +2275 to +2282
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})
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +2286 to +2297
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()
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@hessius
Copy link
Copy Markdown
Owner Author

hessius commented Mar 25, 2026

Review Findings Addressed

All 8 code review comments resolved in 0ef3ef4:

# Issue Fix
1 Race condition in dedupe check Moved existence check inside _history_lock
2 Missing tests Added TestImportFromUrl with 8 tests (829 total pass)
3 URL encoding in iOS Shortcuts Added URL Encode action step
4 Direct mode missing URL validation Added 400 on missing/empty URL
5 Direct mode missing name validation Added type + presence check for name
6 setTimeout leak Timer stored in ref, cleared on close
7 CRITICAL: SSRF vulnerability Added _validate_url_for_ssrf() — blocks private IPs, localhost, cloud metadata
8 Streaming download for size limit Replaced buffered GET with streaming aiter_bytes + running byte count

Quality Gates

  • ✅ Backend tests: 829 passed
  • ✅ Frontend lint: 0 errors (27 pre-existing warnings)
  • ✅ Frontend build: success
  • ✅ Frontend tests: 416 passed (1 pre-existing skip)

Issue #96 Completeness

  • ✅ Import from URL (JSON and .met)
  • ✅ Works in proxy and direct modes
  • ?import=<url> deep link
  • ✅ iOS Shortcuts integration (now with URL encoding)
  • ✅ Error handling + SSRF protection
  • ✅ i18n (10 keys across 6 locales)

Ready to merge.

@hessius hessius merged commit af247c3 into version/2.4.0 Mar 26, 2026
6 checks passed
@hessius hessius deleted the feature/96-import-from-link branch March 26, 2026 05:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants