Files
ink-note-app/electron/linkpreview.cjs
T
Your NameandClaude Sonnet 5.5 313c41f867 feat(electron): main process, vault, search index, links, import, capture
Vault on plain .md files with FTS5 index, link rewriting, canvas files,
history/trash/backup, link previews, image fetch, tasks and unlinked
mentions, Notion/Obsidian importer, quick-capture window and shortcut.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:57:09 +02:00

167 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// What a web link card shows: the page's title and description, read from the start of its HTML.
// Runs in the main process (no CORS there). Only public http(s) addresses are fetched: a canvas file that
// someone else made must not be able to make this app read an address on your own network.
const dns = require('node:dns/promises');
const net = require('node:net');
const MAX_BYTES = 512 * 1024; // of HTML read; the tags we want are at the top
const TIMEOUT = 8000;
const MAX_REDIRECTS = 5;
// Replaceable for tests: { fetch, lookup }
const hooks = { fetch: null, lookup: null };
// Addresses that are not on the public internet (this machine, the local network, link-local, multicast, …).
const PRIVATE = new net.BlockList();
for (const [ip, bits] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3]]) PRIVATE.addSubnet(ip, bits, 'ipv4');
for (const [ip, bits] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8]]) PRIVATE.addSubnet(ip, bits, 'ipv6');
function privateAddress(ip) {
const v = net.isIP(ip);
if (!v) return true;
const x = ip.toLowerCase();
if (v === 6) {
// IPv4 inside IPv6 (::ffff:a.b.c.d, which URL parsing turns into ::ffff:aabb:ccdd, or ::a.b.c.d): judge the IPv4 part
const dotted = x.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/);
const hex = x.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
let v4 = dotted ? dotted[1] : null;
if (hex) {
const hi = parseInt(hex[1], 16), lo = parseInt(hex[2], 16);
v4 = `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
}
if (v4 && PRIVATE.check(v4, 'ipv4')) return true;
}
return PRIVATE.check(x, v === 4 ? 'ipv4' : 'ipv6');
}
// The URL if it may be fetched, else null.
async function allowed(raw, lookup) {
let u;
try { u = new URL(raw); } catch { return null; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (u.username || u.password) return null;
const host = u.hostname.replace(/^\[|\]$/g, '');
if (!host) return null;
if (net.isIP(host)) return privateAddress(host) ? null : u;
if (/(^|\.)localhost$/i.test(host)) return null;
try {
const addrs = await (lookup || ((h) => dns.lookup(h, { all: true })))(host);
if (!addrs.length || addrs.some((a) => privateAddress(a.address))) return null;
} catch { return null; }
return u;
}
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', hellip: '…', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', copy: '©' };
const decode = (s) =>
s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
if (e[0] === '#') {
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
try { return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m; } catch { return m; }
}
return ENTITIES[e.toLowerCase()] ?? m;
});
const tidy = (s, max) => {
const t = decode(String(s || '')).replace(/\s+/g, ' ').trim();
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
};
// { title, description, site } from a page's HTML (empty strings where it says nothing)
function parsePreview(html) {
const head = String(html).slice(0, MAX_BYTES);
const meta = {};
for (const tag of head.match(/<meta\b[^>]*>/gi) || []) {
const at = {};
for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) at[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
const key = (at.property || at.name || '').toLowerCase();
if (key && at.content !== undefined && !(key in meta)) meta[key] = at.content;
}
const t = head.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
return {
title: tidy(meta['og:title'] || meta['twitter:title'] || (t ? t[1] : ''), 200),
description: tidy(meta['og:description'] || meta['twitter:description'] || meta.description, 400),
site: tidy(meta['og:site_name'], 80)
};
}
function charsetOf(contentType, bytes) {
const h = /charset\s*=\s*["']?([\w-]+)/i.exec(contentType || '');
if (h) return h[1];
const m = /<meta[^>]+charset\s*=\s*["']?([\w-]+)/i.exec(Buffer.from(bytes.subarray(0, 4096)).toString('latin1'));
return m ? m[1] : 'utf-8';
}
async function readLimited(res, limit = MAX_BYTES) {
const chunks = [];
let size = 0;
const reader = res.body.getReader();
try {
while (size < limit) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
size += value.length;
}
} finally {
reader.cancel().catch(() => {});
}
return Buffer.concat(chunks.map((c) => Buffer.from(c))).subarray(0, limit);
}
// { title, description, site } for a web address, or null when there is nothing to show.
async function fetchPreview(url, { fetch: doFetch, lookup } = {}) {
const get = doFetch || hooks.fetch || globalThis.fetch;
const look = lookup || hooks.lookup;
const signal = AbortSignal.timeout(TIMEOUT);
let u = await allowed(url, look);
for (let hop = 0; u && hop <= MAX_REDIRECTS; hop++) {
let res;
try {
res = await get(u.href, {
redirect: 'manual',
signal,
headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1', 'accept-language': 'en;q=0.8' }
});
} catch { return null; }
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
u = loc ? await allowed(new URL(loc, u).href, look) : null; // every hop is checked again
continue;
}
if (!res.ok || !/html/i.test(res.headers.get('content-type') || '') || !res.body) return null;
const bytes = await readLimited(res);
let text;
try { text = new TextDecoder(charsetOf(res.headers.get('content-type'), bytes)).decode(bytes); } catch { text = bytes.toString('utf8'); }
const p = parsePreview(text);
return p.title || p.description ? p : null;
}
return null;
}
// { html, url } for a whole web page (up to 3 MB), under the same rules as previews; null if it can't be had.
async function fetchHtml(url, { fetch: doFetch, lookup } = {}) {
const get = doFetch || hooks.fetch || globalThis.fetch;
const look = lookup || hooks.lookup;
const signal = AbortSignal.timeout(15000);
let u = await allowed(url, look);
for (let hop = 0; u && hop <= MAX_REDIRECTS; hop++) {
let res;
try {
res = await get(u.href, { redirect: 'manual', signal, headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1' } });
} catch { return null; }
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
u = loc ? await allowed(new URL(loc, u).href, look) : null;
continue;
}
if (!res.ok || !/html/i.test(res.headers.get('content-type') || '') || !res.body) return null;
const bytes = await readLimited(res, 3 * 1024 * 1024);
let text;
try { text = new TextDecoder(charsetOf(res.headers.get('content-type'), bytes)).decode(bytes); } catch { text = bytes.toString('utf8'); }
return { html: text, url: u.href };
}
return null;
}
module.exports = { fetchHtml, fetchPreview, parsePreview, allowed, hooks };