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
39 lines
1.4 KiB
Python
39 lines
1.4 KiB
Python
"""note_link_previews (URL unfurl — M6 1901)
|
|
|
|
Revision ID: 0020
|
|
Revises: 0019
|
|
Create Date: 2026-07-23
|
|
|
|
Cached OG/meta previews for URLs in a note, fetched server-side on request
|
|
(unfurl.py) and stored so they never re-fetch. One per (note, url).
|
|
"""
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
|
|
revision = "0020"
|
|
down_revision = "0019"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
"note_link_previews",
|
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
|
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("url", sa.Text(), nullable=False),
|
|
sa.Column("title", sa.Text(), nullable=True),
|
|
sa.Column("description", sa.Text(), nullable=True),
|
|
sa.Column("image_url", sa.Text(), nullable=True),
|
|
sa.Column("site_name", sa.Text(), nullable=True),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
|
sa.UniqueConstraint("note_id", "url", name="uq_note_link_previews_note_url"),
|
|
)
|
|
op.create_index("ix_note_link_previews_note", "note_link_previews", ["note_id"])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index("ix_note_link_previews_note", table_name="note_link_previews")
|
|
op.drop_table("note_link_previews")
|