M6 1901: URL capture with link-preview unfurl (SSRF-hardened)
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
This commit is contained in:
@@ -2,10 +2,12 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useTitlesStore, type TitleEntry } from "../stores/titles";
|
||||
import ColorPicker from "./ColorPicker.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import LabelPicker from "./LabelPicker.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import NoteChecklist from "./NoteChecklist.vue";
|
||||
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
||||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||||
@@ -23,6 +25,7 @@ const props = withDefaults(defineProps<{ note?: Note | null; inline?: boolean; a
|
||||
});
|
||||
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
||||
const notes = useNotesStore();
|
||||
const config = useConfigStore();
|
||||
const titles = useTitlesStore();
|
||||
|
||||
const noteId = ref<string | null>(props.note?.id ?? null);
|
||||
@@ -68,6 +71,7 @@ const draftNote = computed<Note>(() => ({
|
||||
labels: labelList.value,
|
||||
items: [],
|
||||
attachments: [],
|
||||
previews: [],
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
}));
|
||||
@@ -447,6 +451,42 @@ async function uploadFile(file: File) {
|
||||
uploadError.value = (e as { error?: string }).error ?? "Could not upload file.";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- link previews (URL unfurl) ----
|
||||
const unfurling = ref<string | null>(null); // the URL currently being fetched
|
||||
const unfurlError = ref("");
|
||||
// Bare http(s) URLs in the body; trailing sentence punctuation trimmed.
|
||||
const URL_RE = /(https?:\/\/[^\s<>"'\])]+)/g;
|
||||
const detectedUrls = computed(() => {
|
||||
const out: string[] = [];
|
||||
for (const m of body.value.matchAll(URL_RE)) {
|
||||
const u = m[1].replace(/[.,;:!?]+$/, "");
|
||||
if (!out.includes(u)) out.push(u);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const previewedUrls = computed(() => new Set(liveNote.value.previews.map((p) => p.url)));
|
||||
const unpreviewedUrls = computed(() => detectedUrls.value.filter((u) => !previewedUrls.value.has(u)));
|
||||
async function addPreview(url: string) {
|
||||
const id = await ensureDraft();
|
||||
if (!id) return;
|
||||
unfurling.value = url;
|
||||
unfurlError.value = "";
|
||||
try {
|
||||
await notes.unfurl(id, url);
|
||||
} catch (e) {
|
||||
unfurlError.value = (e as { error?: string }).error ?? "Couldn't fetch a preview for that link.";
|
||||
} finally {
|
||||
unfurling.value = null;
|
||||
}
|
||||
}
|
||||
function shortUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
async function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -604,6 +644,34 @@ defineExpose({ open });
|
||||
</div>
|
||||
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
|
||||
|
||||
<!-- Link previews: stored preview cards + one "Preview <domain>" per detected URL -->
|
||||
<div v-if="liveNote.previews.length" class="flex flex-col gap-2">
|
||||
<LinkPreview
|
||||
v-for="p in liveNote.previews"
|
||||
:key="p.id"
|
||||
:preview="p"
|
||||
:removable="!liveNote.trashed"
|
||||
@remove="notes.deletePreview(liveNote.id, p.id)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="config.enableUrlUnfurl && !liveNote.trashed && unpreviewedUrls.length"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<button
|
||||
v-for="u in unpreviewedUrls"
|
||||
:key="u"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-neutral-200 px-2 py-0.5 text-xs text-neutral-500 hover:bg-neutral-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
:disabled="unfurling === u"
|
||||
@click="addPreview(u)"
|
||||
>
|
||||
<Icon name="link" />
|
||||
{{ unfurling === u ? "Fetching…" : `Preview ${shortUrl(u)}` }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="unfurlError" class="text-xs text-red-600 dark:text-red-400">{{ unfurlError }}</p>
|
||||
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
|
||||
Reference in New Issue
Block a user