Files
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

80 lines
3.4 KiB
JavaScript

'use strict';
// Download a picture from a web address (for a page's cover). Runs in the main process, and has the same rule as
// link previews: only public http(s) addresses, every redirect checked again (see linkpreview.cjs).
const path = require('node:path');
const { allowed, hooks } = require('./linkpreview.cjs');
const MAX_BYTES = 50 * 1024 * 1024;
const TIMEOUT = 25000;
const MAX_REDIRECTS = 5;
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif', 'image/svg+xml': 'svg', 'image/bmp': 'bmp' };
const KNOWN = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
// A file name for it: the address's own (when it looks like one), else "cover" with the right extension.
function nameFor(u, type) {
let base = '';
try { base = decodeURIComponent(path.posix.basename(u.pathname)); } catch { base = path.posix.basename(u.pathname); }
base = base.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-').trim().slice(0, 80);
if (KNOWN.test(base)) return base;
const ext = EXT[type] || 'jpg';
return `${base && !base.startsWith('.') ? base.replace(/\.[a-z0-9]{1,5}$/i, '') : 'cover'}.${ext}`;
}
async function readLimited(res, max) {
const chunks = [];
let size = 0;
const reader = res.body.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > max) throw new Error('That image is too big (50 MB at most).');
chunks.push(Buffer.from(value));
}
} finally {
reader.cancel().catch(() => {});
}
return Buffer.concat(chunks);
}
// -> { bytes: Buffer, name, type }. Throws an Error whose message is meant for the person.
async function fetchImage(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(String(url || '').trim(), look);
if (!u) throw new Error('That is not a public web address (http or https).');
for (let hop = 0; 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: 'image/*,*/*;q=0.1' }
});
} catch {
throw new Error('Could not reach that address.');
}
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
const next = loc ? await allowed(new URL(loc, u).href, look) : null;
if (!next) throw new Error('That address redirects somewhere that is not allowed.');
u = next;
continue;
}
if (!res.ok) throw new Error(`The server answered ${res.status}.`);
const type = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!type.startsWith('image/')) throw new Error('That address is not a picture.');
const len = Number(res.headers.get('content-length'));
if (len > MAX_BYTES) throw new Error('That image is too big (50 MB at most).');
if (!res.body) throw new Error('That address sent nothing.');
const bytes = await readLimited(res, MAX_BYTES);
if (!bytes.length) throw new Error('That address sent nothing.');
return { bytes, name: nameFor(u, type), type };
}
throw new Error('Too many redirects.');
}
module.exports = { fetchImage, nameFor };