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>
This commit is contained in:
1 parent
0b5f8bfa8b
commit
313c41f867
16 files changed
+2706
-15
No files matched your search
@@ -0,0 +1,166 @@
|
||||
'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 };
|
||||
Reference in new issue
Block a user