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>
80 lines
3.4 KiB
JavaScript
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 };
|