Paste a link → fetch its OpenGraph/meta preview (title, description, image,
site) and show a rich card. User-triggered + persisted (never auto-fetches;
cached so it never re-fetches). Opt-in via a new admin setting
enable_url_unfurl (default on, rule 26).
Security (the whole point of this task): a new dependency-free unfurl.py
does the fetch with layered SSRF defenses — http/https only; resolve the
host and reject EVERY non-public address (private/loopback/link-local/
reserved/multicast/unspecified — blocks 169.254.169.254 etc.); connect to
the vetted IP with SNI so DNS-rebinding can't slip through; ≤3 redirects
each re-validated; 5s timeout; 512 KB cap; text/html only; blocking IO in a
worker thread. No server-side image fetch — the og:image URL is loaded by
the browser.
- note_link_previews table (migration 0020), one per (note, url); serialized
inline on notes (+ rides the sync pull feed read-only).
- POST /api/notes/<id>/unfurl {url} (owner-scoped, setting-gated, 502 on
fetch failure); DELETE /api/notes/<id>/previews/<id>.
- enable_url_unfurl exposed in public config so the UI hides the affordance
when disabled.
Frontend: LinkPreview.vue card; editor detects URLs in the body and offers a
"Preview <domain>" chip per un-previewed link (ensureDraft first), renders
preview cards with remove; card shows previews read-only. New link icon;
notes-store unfurl()/deletePreview().
Tests (DB-free): is_public_ip range blocking, validate_url scheme/parts,
extract_preview (OG + <title> fallback + relative-image resolve), endpoint
auth-guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
75 lines
2.9 KiB
Python
75 lines
2.9 KiB
Python
import ipaddress
|
|
|
|
import pytest
|
|
|
|
from thoughtsync.app import create_app
|
|
from thoughtsync.unfurl import UnfurlError, extract_preview, is_public_ip, validate_url
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return create_app()
|
|
|
|
|
|
def test_is_public_ip_blocks_internal_ranges():
|
|
assert is_public_ip(ipaddress.ip_address("8.8.8.8"))
|
|
assert is_public_ip(ipaddress.ip_address("2606:4700:4700::1111"))
|
|
# everything internal / special is rejected (the SSRF core)
|
|
assert not is_public_ip(ipaddress.ip_address("10.0.0.1")) # private
|
|
assert not is_public_ip(ipaddress.ip_address("192.168.1.1")) # private
|
|
assert not is_public_ip(ipaddress.ip_address("127.0.0.1")) # loopback
|
|
assert not is_public_ip(ipaddress.ip_address("169.254.169.254")) # link-local (cloud metadata)
|
|
assert not is_public_ip(ipaddress.ip_address("0.0.0.0")) # unspecified
|
|
assert not is_public_ip(ipaddress.ip_address("::1")) # loopback v6
|
|
assert not is_public_ip(ipaddress.ip_address("fc00::1")) # unique-local v6
|
|
|
|
|
|
def test_validate_url_scheme_and_parts():
|
|
assert validate_url("https://example.com/a?b=c") == ("https", "example.com", 443, "/a?b=c")
|
|
assert validate_url("http://x.io")[3] == "/" # default path
|
|
assert validate_url("http://x.io:8080/p")[2] == 8080 # explicit port
|
|
for bad in ("file:///etc/passwd", "ftp://x", "gopher://x", "not a url", ""):
|
|
with pytest.raises(UnfurlError):
|
|
validate_url(bad)
|
|
|
|
|
|
def test_extract_preview_opengraph():
|
|
html = (
|
|
b"<html><head>"
|
|
b'<meta property="og:title" content="Hello & World">'
|
|
b'<meta property="og:description" content="A page">'
|
|
b'<meta property="og:image" content="/img.png">'
|
|
b'<meta property="og:site_name" content="Example">'
|
|
b"</head></html>"
|
|
)
|
|
p = extract_preview("https://example.com/page", html)
|
|
assert p["title"] == "Hello & World" # entities decoded
|
|
assert p["description"] == "A page"
|
|
assert p["image_url"] == "https://example.com/img.png" # relative resolved to absolute
|
|
assert p["site_name"] == "Example"
|
|
|
|
|
|
def test_extract_preview_title_fallback_and_host_defaults():
|
|
html = b"<html><head><title> Just a Title </title></head></html>"
|
|
p = extract_preview("https://example.com", html)
|
|
assert p["title"] == "Just a Title" # whitespace collapsed
|
|
assert p["description"] is None
|
|
assert p["image_url"] is None
|
|
assert p["site_name"] == "example.com" # falls back to host
|
|
|
|
|
|
async def test_unfurl_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.post(
|
|
"/api/notes/00000000-0000-0000-0000-000000000000/unfurl", json={"url": "https://x.com"}
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_delete_preview_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.delete(
|
|
"/api/notes/00000000-0000-0000-0000-000000000000/previews/00000000-0000-0000-0000-000000000001"
|
|
)
|
|
assert resp.status_code == 401
|