diff --git a/server/config/scrape.js b/server/config/scrape.js new file mode 100644 index 0000000..5492b4a --- /dev/null +++ b/server/config/scrape.js @@ -0,0 +1,70 @@ +// +// All scraper tunables in one place, so nothing downstream is a magic number. +// +// Env overrides exist for the two kill switches only. Everything else is a +// constant on purpose: the politeness numbers are the difference between +// looking like a browser and looking like a bot, and they should not drift +// per-environment without a code change. +// + +const isTest = process.env.NODE_ENV === 'test' + +const config = { + // Kill switches + enabled: process.env.SCRAPE_ENABLED !== '0', + // The background worker is off under Jest so tests drive the queue + // deterministically via ScrapeQueue.drain() instead of racing it. + workerEnabled: process.env.SCRAPE_WORKER ? process.env.SCRAPE_WORKER === '1' : !isTest, + + // Timeouts + pageTimeoutMs: 8000, // single HTTP request + totalBudgetMs: 25000, // all hops + retries for one URL + imageTimeoutMs: 10000, + + // Size caps. Content-Length is optional and can lie, so these are also + // enforced while streaming the body. + maxHtmlBytes: 2 * 1024 * 1024, + maxImageBytes: 5 * 1024 * 1024, + maxRobotsBytes: 100 * 1024, + + // Redirects + maxRedirects: 5, + + // Retries. Only for transient classes - a 404 is an answer, not a failure. + retries: 2, + backoffBaseMs: 500, + backoffMaxMs: 8000, + maxRetryAfterMs: 60 * 1000, + + // Politeness. One request at a time per host, spaced out, is what actually + // stops us looking like a crawler. + maxConcurrentFetches: 4, + perHostConcurrency: 1, + perHostMinSpacingMs: 1000, + perHostMaxSpacingMs: 3000, + maxCrawlDelayMs: 30 * 1000, + + // Backpressure + maxQueueLength: 500, + maxPendingPerUser: 50, + + // Identity. Stable salt, so a host always sees the same browser across + // restarts. Changing this re-rolls every host's assigned profile. + profileSalt: 'ss-scrape-v1', + robotsUaToken: 'SolidScribeBot', + robotsTtlMs: 24 * 60 * 60 * 1000, + robotsNegativeTtlMs: 60 * 60 * 1000, + robotsErrorTtlMs: 10 * 60 * 1000, + robotsMaxOrigins: 2000, + + // Parsing / storage + maxSearchTextBytes: 8000, + maxKeywordSourceChars: 6000, + maxUrlLength: 1000, // matches attachment.url varchar(1000) + + // staticFiles is served from our own origin via /api/static, so an SVG + // with an inline script would be same-origin. Off by default. + allowSvgThumbnails: false, +} + +module.exports = config diff --git a/server/helpers/BrowserProfiles.js b/server/helpers/BrowserProfiles.js new file mode 100644 index 0000000..c6d9004 --- /dev/null +++ b/server/helpers/BrowserProfiles.js @@ -0,0 +1,195 @@ +// +// Browser identities for outbound fetches. +// +// The old code sent a single hardcoded Googlebot User-Agent. That was removed +// deliberately: Googlebot is verifiable by reverse DNS, so the claim is trivially +// disproven, and sites that check it block the source IP outright. Some also +// serve crawlers a stripped-down page. +// +// What replaces it is an honest, *coherent* browser identity. Coherence is the +// point - a Chrome User-Agent shipping Firefox's Accept header, or sec-ch-ua on +// a Safari request, is a stronger bot signal than any UA string. So each profile +// carries the complete header set its family really sends, and nothing mixes. +// +// Profiles are assigned stably per host, not randomly per request: a site should +// see one returning browser, not a rotating cast. +// + +const config = require('@config/scrape') + +let BrowserProfiles = module.exports = {} + +const CHROME_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' +const FIREFOX_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' +const SAFARI_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' + +const PROFILES = BrowserProfiles.PROFILES = [ + { + id: 'chrome-win', + family: 'chrome', + ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', + accept: CHROME_ACCEPT, + acceptLanguage: 'en-US,en;q=0.9', + secChUa: '"Chromium";v="141", "Not;A=Brand";v="24", "Google Chrome";v="141"', + secChUaMobile: '?0', + secChUaPlatform: '"Windows"', + }, + { + id: 'chrome-mac', + family: 'chrome', + ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', + accept: CHROME_ACCEPT, + acceptLanguage: 'en-US,en;q=0.9', + secChUa: '"Chromium";v="141", "Not;A=Brand";v="24", "Google Chrome";v="141"', + secChUaMobile: '?0', + secChUaPlatform: '"macOS"', + }, + { + id: 'firefox-win', + family: 'firefox', + ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0', + accept: FIREFOX_ACCEPT, + acceptLanguage: 'en-US,en;q=0.5', + }, + { + id: 'firefox-linux', + family: 'firefox', + ua: 'Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0', + accept: FIREFOX_ACCEPT, + acceptLanguage: 'en-US,en;q=0.5', + }, + { + id: 'safari-mac', + family: 'safari', + ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15', + accept: SAFARI_ACCEPT, + acceptLanguage: 'en-US,en;q=0.9', + }, +] + +// FNV-1a. Not cryptographic - we only need a stable, well-spread bucket. +const fnv1a = (input) => { + let hash = 0x811c9dc5 + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return hash >>> 0 +} +BrowserProfiles.__fnv1a = fnv1a + +// +// Same host always gets the same profile, across requests and across restarts +// (the salt is a constant in config, not a per-boot random). +// +BrowserProfiles.profileForHost = (hostname) => { + const key = require('@helpers/UrlSafety').registrableHost(hostname) + config.profileSalt + return PROFILES[fnv1a(key) % PROFILES.length] +} + +BrowserProfiles.profileById = (id) => PROFILES.find(profile => profile.id === id) || PROFILES[0] + +// +// Sec-Fetch-Site is relative: "none" for a URL typed into the bar, "same-origin" +// when the referrer shares the origin, "cross-site" otherwise. Getting this +// wrong while sending a Referer is exactly the kind of incoherence that flags. +// +const fetchSite = (targetUrl, referer) => { + if (!referer) { return 'none' } + try { + const target = new URL(targetUrl) + const from = new URL(referer) + if (target.origin === from.origin) { return 'same-origin' } + if (UrlSafetyHost(target) === UrlSafetyHost(from)) { return 'same-site' } + return 'cross-site' + } catch (error) { + return 'none' + } +} + +const UrlSafetyHost = (url) => require('@helpers/UrlSafety').registrableHost(url.hostname) + +const applyChromiumHints = (headers, profile) => { + if (profile.family !== 'chrome') { return headers } + headers['sec-ch-ua'] = profile.secChUa + headers['sec-ch-ua-mobile'] = profile.secChUaMobile + headers['sec-ch-ua-platform'] = profile.secChUaPlatform + return headers +} + +// +// Headers for a top-level document navigation. +// +BrowserProfiles.documentHeaders = (profile, { url, referer } = {}) => { + const headers = { + 'User-Agent': profile.ua, + 'Accept': profile.accept, + 'Accept-Language': profile.acceptLanguage, + 'Accept-Encoding': 'gzip, deflate, br', + } + + // Safari sends neither sec-ch-ua nor Sec-Fetch-*; Firefox sends Sec-Fetch-* + // but no client hints; Chromium sends both. + if (profile.family !== 'safari') { + headers['Sec-Fetch-Dest'] = 'document' + headers['Sec-Fetch-Mode'] = 'navigate' + headers['Sec-Fetch-Site'] = fetchSite(url, referer) + headers['Sec-Fetch-User'] = '?1' + headers['Upgrade-Insecure-Requests'] = '1' + } + + applyChromiumHints(headers, profile) + + if (referer) { headers['Referer'] = referer } + + return headers +} + +// +// Headers for a subresource image load, as issued from the page it was found on. +// +BrowserProfiles.imageHeaders = (profile, { url, pageUrl } = {}) => { + const headers = { + 'User-Agent': profile.ua, + 'Accept': profile.family === 'chrome' + ? 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8' + : 'image/avif,image/webp,*/*', + 'Accept-Language': profile.acceptLanguage, + 'Accept-Encoding': 'gzip, deflate, br', + } + + if (profile.family !== 'safari') { + headers['Sec-Fetch-Dest'] = 'image' + headers['Sec-Fetch-Mode'] = 'no-cors' + headers['Sec-Fetch-Site'] = fetchSite(url, pageUrl) + } + + applyChromiumHints(headers, profile) + + if (pageUrl) { headers['Referer'] = pageUrl } + + return headers +} + +// +// Headers for robots.txt. Same identity as the page fetch, but it is not a +// navigation - a browser would never send Sec-Fetch-Dest: document for this. +// +BrowserProfiles.robotsHeaders = (profile) => { + const headers = { + 'User-Agent': profile.ua, + 'Accept': 'text/plain,*/*;q=0.8', + 'Accept-Language': profile.acceptLanguage, + 'Accept-Encoding': 'gzip, deflate, br', + } + + if (profile.family !== 'safari') { + headers['Sec-Fetch-Dest'] = 'empty' + headers['Sec-Fetch-Mode'] = 'no-cors' + headers['Sec-Fetch-Site'] = 'none' + } + + applyChromiumHints(headers, profile) + + return headers +} diff --git a/server/helpers/HostQueue.js b/server/helpers/HostQueue.js new file mode 100644 index 0000000..da2988d --- /dev/null +++ b/server/helpers/HostQueue.js @@ -0,0 +1,221 @@ +// +// Per-host politeness scheduler. Knows nothing about scraping. +// +// This is the piece that stops the scraper looking like a crawler. The old code +// did foundUrls.forEach(fetch) - a note with 40 links opened 40 simultaneous +// connections, which is the most bot-like traffic shape available. Here, each +// host gets one request at a time with jittered spacing between them, and a +// global cap bounds total concurrency. +// + +const config = require('@config/scrape') + +let HostQueue = module.exports = {} + +const jitter = (min, max) => Math.round(min + Math.random() * Math.max(0, max - min)) + +class Queue { + constructor(options = {}) { + this.globalConcurrency = options.globalConcurrency != null + ? options.globalConcurrency : config.maxConcurrentFetches + this.perHostConcurrency = options.perHostConcurrency != null + ? options.perHostConcurrency : config.perHostConcurrency + this.minSpacingMs = options.minSpacingMs != null + ? options.minSpacingMs : config.perHostMinSpacingMs + this.maxSpacingMs = options.maxSpacingMs != null + ? options.maxSpacingMs : config.perHostMaxSpacingMs + this.now = options.now || (() => Date.now()) + // Injectable so tests with a controlled clock don't spend real + // wall-time waiting out politeness spacing. + this.setTimeoutFn = options.setTimeoutFn || ((fn, ms) => setTimeout(fn, ms)) + this.clearTimeoutFn = options.clearTimeoutFn || ((handle) => clearTimeout(handle)) + + this.pending = [] // FIFO + this.hosts = new Map() // hostKey -> { running, nextAllowedAt } + this.running = 0 + this.seq = 0 + this.timer = null + this.stopped = false + this.drainWaiters = [] + } + + hostState(hostKey) { + if (!this.hosts.has(hostKey)) { + this.hosts.set(hostKey, { running: 0, nextAllowedAt: 0 }) + } + return this.hosts.get(hostKey) + } + + // + // Queue a task for a host. Resolves/rejects with the task's own outcome. + // spacingMs overrides the jitter floor (robots.txt Crawl-delay). + // + schedule(hostKey, taskFn, options = {}) { + if (this.stopped) { + return Promise.reject(new Error('HostQueue stopped')) + } + + return new Promise((resolve, reject) => { + this.pending.push({ + hostKey: hostKey || '(none)', + taskFn, + resolve, + reject, + spacingMs: options.spacingMs != null ? options.spacingMs : null, + seq: this.seq++, + }) + this.pump() + }) + } + + // + // Run whatever is eligible. FIFO scan rather than per-host round-robin, so + // no host can starve another and order stays predictable. + // + pump() { + if (this.stopped) { return } + + this.clearTimer() + + while (this.running < this.globalConcurrency) { + const at = this.now() + let index = -1 + let earliest = Infinity + + for (let i = 0; i < this.pending.length; i++) { + const job = this.pending[i] + const host = this.hostState(job.hostKey) + + if (host.running >= this.perHostConcurrency) { continue } + + if (host.nextAllowedAt > at) { + earliest = Math.min(earliest, host.nextAllowedAt) + continue + } + + index = i + break + } + + if (index === -1) { + // Nothing runnable now. Wake up when the earliest host is due. + if (this.pending.length && earliest !== Infinity) { + this.armTimer(earliest - at) + } + break + } + + this.run(this.pending.splice(index, 1)[0]) + } + + this.checkDrained() + } + + run(job) { + const host = this.hostState(job.hostKey) + host.running++ + this.running++ + + const settle = () => { + host.running-- + this.running-- + + // Space the NEXT request to this host from the end of this one. + const spacing = job.spacingMs != null + ? job.spacingMs + : jitter(this.minSpacingMs, this.maxSpacingMs) + host.nextAllowedAt = this.now() + spacing + + this.pump() + } + + // A throwing taskFn must not wedge the pump, so the call itself is + // wrapped rather than only its promise. + let result = null + try { + result = Promise.resolve(job.taskFn()) + } catch (error) { + result = Promise.reject(error) + } + + result.then( + value => { settle(); job.resolve(value) }, + error => { settle(); job.reject(error) } + ) + } + + armTimer(delay) { + this.clearTimer() + this.timer = this.setTimeoutFn(() => { + this.timer = null + this.pump() + }, Math.max(1, delay)) + + // + // Deliberately NOT unref'd. + // + // This timer is the only thing that will resume a host which is inside + // its politeness gap. unref'ing it means that if nothing else holds the + // event loop open, Node exits and every queued job is silently + // abandoned with its promise never settling. Inside the server that is + // masked by the listening socket, but it makes the queue unusable from + // a standalone script and is a footgun either way. + // + // Shutdown is handled explicitly instead: stop() clears this timer and + // rejects whatever is still queued, and it is wired to SIGINT/SIGTERM + // in server/index.js. + // + } + + clearTimer() { + if (this.timer) { + this.clearTimeoutFn(this.timer) + this.timer = null + } + } + + size() { return this.pending.length } + inflight() { return this.running } + + stats() { + return { + pending: this.pending.length, + running: this.running, + hosts: this.hosts.size, + } + } + + checkDrained() { + if (this.pending.length || this.running) { return } + + const waiters = this.drainWaiters + this.drainWaiters = [] + waiters.forEach(resolve => resolve()) + } + + // Resolves once the queue is empty and nothing is in flight. + drain() { + if (!this.pending.length && !this.running) { return Promise.resolve() } + return new Promise(resolve => this.drainWaiters.push(resolve)) + } + + // + // Reject everything still queued. In-flight tasks are not cancelled here - + // their own AbortControllers handle that. + // + stop() { + this.stopped = true + this.clearTimer() + + const pending = this.pending + this.pending = [] + pending.forEach(job => job.reject(new Error('HostQueue stopped'))) + + this.checkDrained() + } +} + +HostQueue.Queue = Queue + +// Shared instance used by the scrape worker. Tests build their own. +HostQueue.shared = new Queue() diff --git a/server/helpers/HttpFetch.js b/server/helpers/HttpFetch.js new file mode 100644 index 0000000..69aef02 --- /dev/null +++ b/server/helpers/HttpFetch.js @@ -0,0 +1,429 @@ +// +// The only module in the server that opens an outbound connection. +// +// Everything that made the old scraper unreliable is handled here, in one place: +// a single AbortController-based timeout (the old code raced request's socket +// timeout against a manual setTimeout that could fire against a null reference +// and crash the process), manual redirect following so the SSRF gate runs on +// every hop, hard size caps enforced while streaming, a Content-Type gate before +// any body is read, real charset decoding, and retries that only fire for +// transient failures. +// + +const config = require('@config/scrape') +const UrlSafety = require('@helpers/UrlSafety') +const BrowserProfiles = require('@helpers/BrowserProfiles') + +let HttpFetch = module.exports = {} + +const HTML_TYPES = ['text/html', 'application/xhtml+xml', 'text/plain', 'application/xml', 'text/xml'] + +class FetchError extends Error { + constructor(kind, message, extra = {}) { + super(message) + this.name = 'FetchError' + this.kind = kind // timeout|http|blocked|too_large|bad_content_type|redirect_loop|too_many_redirects|network|aborted + this.status = extra.status || null + this.retryAfterMs = extra.retryAfterMs || null + this.url = extra.url || null + this.reason = extra.reason || null + } +} +HttpFetch.FetchError = FetchError + +// Injectable so tests don't spend real wall-time in backoff. Far simpler than +// interleaving fake timers with promises. +let sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) +HttpFetch.__setSleep = (fn) => { sleep = fn } +HttpFetch.__resetSleep = () => { sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) } + +const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 507, 509, 521, 522, 523, 524]) + +const isRetryable = (error) => { + if (!(error instanceof FetchError)) { return false } + if (error.kind === 'network' || error.kind === 'timeout') { return true } + if (error.kind === 'http') { return RETRYABLE_STATUS.has(error.status) } + return false +} + +// Retry-After is either delta-seconds or an HTTP-date. +const parseRetryAfter = (value) => { + if (!value) { return null } + + const seconds = Number(value) + if (Number.isFinite(seconds)) { return Math.max(0, seconds * 1000) } + + const when = Date.parse(value) + if (Number.isFinite(when)) { return Math.max(0, when - Date.now()) } + + return null +} + +const backoffFor = (attempt) => { + const base = Math.min(config.backoffMaxMs, config.backoffBaseMs * Math.pow(2, attempt)) + return Math.round(base * (0.5 + Math.random())) // +/-50% jitter +} + +const contentTypeOf = (response) => String(response.headers.get('content-type') || '').toLowerCase() +const mimeOf = (contentType) => contentType.split(';')[0].trim() + +const charsetFromContentType = (contentType) => { + const match = contentType.match(/charset=\s*"?([^";\s]+)"?/i) + return match ? match[1].toLowerCase() : null +} + +// Last resort: sniff a out of the head, read as latin1 so every +// byte maps to a character and the regex can't miss. +const charsetFromMeta = (buffer) => { + const head = buffer.subarray(0, 2048).toString('latin1') + + const metaCharset = head.match(/]+charset\s*=\s*["']?\s*([a-z0-9_\-:]+)/i) + if (metaCharset) { return metaCharset[1].toLowerCase() } + + const httpEquiv = head.match(/]+content\s*=\s*["'][^"']*charset=\s*([a-z0-9_\-:]+)/i) + if (httpEquiv) { return httpEquiv[1].toLowerCase() } + + return null +} + +const charsetFromBom = (buffer) => { + if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) { return 'utf-8' } + if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { return 'utf-16le' } + if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) { return 'utf-16be' } + return null +} + +const decodeBody = (buffer, declaredCharset) => { + const label = declaredCharset || charsetFromBom(buffer) || charsetFromMeta(buffer) || 'utf-8' + + try { + return { text: new TextDecoder(label, { fatal: false }).decode(buffer), charset: label } + } catch (error) { + // Unknown/bogus label - utf-8 with replacement chars beats throwing. + return { text: new TextDecoder('utf-8', { fatal: false }).decode(buffer), charset: 'utf-8' } + } +} + +// +// Read the body with a running byte count, aborting mid-stream once the cap is +// passed. Content-Length is checked by the caller too, but it is optional and +// can lie, so this is the cap that actually holds. +// +const readCapped = async (response, maxBytes, controller, url) => { + if (!response.body) { return Buffer.alloc(0) } + + const chunks = [] + let total = 0 + + for await (const chunk of response.body) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + total += buffer.length + + if (total > maxBytes) { + try { controller.abort() } catch (error) { /* already done */ } + throw new FetchError('too_large', `Body exceeded ${maxBytes} bytes`, { url }) + } + + chunks.push(buffer) + } + + return Buffer.concat(chunks, total) +} + +// +// One HTTP request, following redirects by hand. +// +// Manual redirects are the whole point: fetch's own 'follow' would hop to +// 127.0.0.1 without ever re-consulting the safety gate. +// +const requestOnce = async (startUrl, options) => { + const { + maxBytes, + acceptMimes, + headersFor, + conditional, + timeoutMs, + deadline, + referer, + } = options + + let currentUrl = typeof startUrl === 'string' ? startUrl : startUrl.href + const visited = new Set() + + for (let hop = 0; hop <= config.maxRedirects; hop++) { + if (visited.has(currentUrl)) { + throw new FetchError('redirect_loop', `Redirect loop at ${currentUrl}`, { url: currentUrl }) + } + visited.add(currentUrl) + + // Re-validate on every hop. This is the check the old code never had. + try { + await UrlSafety.assertFetchable(currentUrl) + } catch (error) { + // A DNS failure is transient - the host may simply be down right + // now - so it must not be classified alongside "this address is + // private", which is a permanent refusal. Collapsing the two meant + // a briefly-unresolvable domain got parked for the full cache TTL + // and had its attachment row deleted. + const kind = error.reason === 'dns_failed' ? 'network' : 'blocked' + throw new FetchError(kind, error.message, { url: currentUrl, reason: error.reason }) + } + + const remaining = deadline - Date.now() + if (remaining <= 0) { + throw new FetchError('timeout', 'Total budget exhausted', { url: currentUrl }) + } + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, remaining)) + + let response = null + try { + const headers = headersFor(currentUrl, referer) + + if (conditional && conditional.etag) { headers['If-None-Match'] = conditional.etag } + if (conditional && conditional.lastModified) { headers['If-Modified-Since'] = conditional.lastModified } + + response = await fetch(currentUrl, { + method: 'GET', + redirect: 'manual', + signal: controller.signal, + headers, + }) + } catch (error) { + clearTimeout(timer) + + if (controller.signal.aborted) { + throw new FetchError('timeout', `Timed out after ${timeoutMs}ms`, { url: currentUrl }) + } + throw new FetchError('network', error.message || 'Network failure', { url: currentUrl }) + } + + // Redirect? + if (response.status >= 300 && response.status < 400 && response.headers.get('location')) { + clearTimeout(timer) + try { if (response.body) { await response.body.cancel() } } catch (error) { /* nothing to drain */ } + + const location = response.headers.get('location') + let next = null + try { + next = new URL(location, currentUrl) + } catch (error) { + throw new FetchError('network', `Unparseable redirect target ${location}`, { url: currentUrl }) + } + + // Never follow https -> http. A downgrade mid-chain is either an + // attack or a misconfiguration, and is not worth the preview. + if (new URL(currentUrl).protocol === 'https:' && next.protocol === 'http:') { + throw new FetchError('blocked', `Refusing https->http downgrade to ${next.href}`, { url: currentUrl }) + } + + currentUrl = next.href + continue + } + + clearTimeout(timer) + + if (response.status === 304) { + try { if (response.body) { await response.body.cancel() } } catch (error) { /* no body */ } + return { notModified: true, status: 304, finalUrl: currentUrl, response } + } + + if (!response.ok) { + try { if (response.body) { await response.body.cancel() } } catch (error) { /* no body */ } + throw new FetchError('http', `HTTP ${response.status} for ${currentUrl}`, { + status: response.status, + retryAfterMs: parseRetryAfter(response.headers.get('retry-after')), + url: currentUrl, + }) + } + + const contentType = contentTypeOf(response) + const mime = mimeOf(contentType) + + // Gate on type BEFORE reading a byte. This is what stops a 100MB binary + // being handed to cheerio. + if (acceptMimes && !acceptMimes(mime)) { + try { controller.abort() } catch (error) { /* ignore */ } + throw new FetchError('bad_content_type', `Refusing content-type ${mime || '(none)'}`, { url: currentUrl }) + } + + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + try { controller.abort() } catch (error) { /* ignore */ } + throw new FetchError('too_large', `Declared length ${declaredLength} over cap`, { url: currentUrl }) + } + + const buffer = await readCapped(response, maxBytes, controller, currentUrl) + + return { + notModified: false, + status: response.status, + finalUrl: currentUrl, + contentType, + mime, + buffer, + response, + } + } + + throw new FetchError('too_many_redirects', `More than ${config.maxRedirects} redirects`, { url: currentUrl }) +} + +// +// Retry wrapper. Only transient classes are retried - a 404 is an answer. +// +const withRetries = async (attemptFn, { retries, deadline }) => { + let lastError = null + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await attemptFn() + } catch (error) { + lastError = error + + if (!isRetryable(error) || attempt === retries) { throw error } + + // A server asking us to come back in an hour is not something to + // sit on - surface it so the cache can park the URL instead. + if (error.retryAfterMs && error.retryAfterMs > config.maxRetryAfterMs) { throw error } + + const wait = error.retryAfterMs != null + ? Math.min(error.retryAfterMs, config.maxRetryAfterMs) + : backoffFor(attempt) + + if (Date.now() + wait >= deadline) { throw error } + + await sleep(wait) + } + } + + throw lastError +} + +// +// Fetch an HTML document. +// +HttpFetch.fetchDocument = async (url, options = {}) => { + const timeoutMs = options.timeoutMs || config.pageTimeoutMs + const deadline = Date.now() + (options.budgetMs || config.totalBudgetMs) + const maxBytes = options.maxBytes || config.maxHtmlBytes + const profile = options.profile || BrowserProfiles.profileForHost(hostnameOf(url)) + + const result = await withRetries(() => requestOnce(url, { + maxBytes, + acceptMimes: (mime) => HTML_TYPES.includes(mime), + headersFor: (target, referer) => BrowserProfiles.documentHeaders(profile, { + url: target, + // A browser arriving at a page has *some* referer; its own origin is + // the least surprising one and pairs with Sec-Fetch-Site: same-origin. + referer: referer || originOf(target), + }), + conditional: options.conditional, + timeoutMs, + deadline, + referer: options.referer, + }), { retries: options.retries != null ? options.retries : config.retries, deadline }) + + if (result.notModified) { + return { + notModified: true, + status: 304, + finalUrl: result.finalUrl, + html: null, + profileId: profile.id, + } + } + + const declaredCharset = charsetFromContentType(result.contentType) + const { text, charset } = decodeBody(result.buffer, declaredCharset) + + return { + notModified: false, + status: result.status, + finalUrl: result.finalUrl, + html: text, + charset, + contentType: result.contentType, + bytes: result.buffer.length, + etag: result.response.headers.get('etag') || null, + lastModified: result.response.headers.get('last-modified') || null, + profileId: profile.id, + } +} + +// +// Fetch a binary (thumbnail image). +// +HttpFetch.fetchBinary = async (url, options = {}) => { + const timeoutMs = options.timeoutMs || config.imageTimeoutMs + const deadline = Date.now() + (options.budgetMs || config.totalBudgetMs) + const maxBytes = options.maxBytes || config.maxImageBytes + const profile = options.profile || BrowserProfiles.profileForHost(hostnameOf(url)) + + const acceptMimes = (mime) => { + if (!mime.startsWith('image/')) { return false } + if (mime === 'image/svg+xml' && !config.allowSvgThumbnails) { return false } + return true + } + + const result = await withRetries(() => requestOnce(url, { + maxBytes, + acceptMimes, + headersFor: (target) => BrowserProfiles.imageHeaders(profile, { + url: target, + pageUrl: options.pageUrl, + }), + timeoutMs, + deadline, + }), { retries: options.retries != null ? options.retries : config.retries, deadline }) + + return { + status: result.status, + finalUrl: result.finalUrl, + contentType: result.contentType, + mime: result.mime, + buffer: result.buffer, + profileId: profile.id, + } +} + +// +// Fetch robots.txt. Short leash, no retries - fail-open is handled by the caller. +// +HttpFetch.fetchText = async (url, options = {}) => { + const timeoutMs = options.timeoutMs || 5000 + const deadline = Date.now() + (options.budgetMs || timeoutMs + 1000) + const profile = options.profile || BrowserProfiles.profileForHost(hostnameOf(url)) + + const result = await requestOnce(url, { + maxBytes: options.maxBytes || config.maxRobotsBytes, + acceptMimes: null, // robots.txt is served as every type under the sun + headersFor: () => BrowserProfiles.robotsHeaders(profile), + timeoutMs, + deadline, + }) + + if (result.notModified) { return { status: 304, text: null, finalUrl: result.finalUrl } } + + const { text } = decodeBody(result.buffer, charsetFromContentType(result.contentType)) + return { status: result.status, text, finalUrl: result.finalUrl } +} + +const hostnameOf = (url) => { + try { return new URL(typeof url === 'string' ? url : url.href).hostname } + catch (error) { return '' } +} + +const originOf = (url) => { + try { return new URL(url).origin + '/' } + catch (error) { return undefined } +} + +// +// Deliberately NOT attempted here, so nobody wonders later: +// - Header ordering: fetch normalises it; controlling order needs raw sockets. +// - HTTP/2: undici's fetch is h1 unless an Agent with allowH2 is supplied. +// - TLS/JA3 fingerprinting and proxy rotation: ruled out, we are being polite +// rather than evasive. +// diff --git a/server/helpers/Realtime.js b/server/helpers/Realtime.js new file mode 100644 index 0000000..97bdc66 --- /dev/null +++ b/server/helpers/Realtime.js @@ -0,0 +1,43 @@ +// +// Socket.IO emit wrapper. +// +// Replaces the `if(typeof SocketIo != 'undefined')` guards that were scattered +// through the models. Those guards did not just avoid a crash - they silently +// disabled ALL scraping under Jest, which is why none of this code was ever +// testable. Here the no-socket case is a no-op rather than a skipped branch, so +// the surrounding logic runs identically in tests and in production. +// + +let Realtime = module.exports = {} + +let warned = false + +const server = () => (typeof global.SocketIo !== 'undefined' && global.SocketIo) ? global.SocketIo : null + +Realtime.isEnabled = () => server() !== null + +const emit = (room, event, payload) => { + const io = server() + + if (!io) { + if (!warned) { + warned = true + console.log('[realtime] no socket server bound; emits are no-ops') + } + return false + } + + try { + if (payload === undefined) { io.to(room).emit(event) } + else { io.to(room).emit(event, payload) } + return true + } catch (error) { + console.log('[realtime] emit failed', event, error.message) + return false + } +} + +Realtime.emitToUser = (userId, event, payload) => emit(`user:${userId}`, event, payload) +Realtime.emitToNote = (noteRawTextId, event, payload) => emit(`note:${noteRawTextId}`, event, payload) + +Realtime.__resetWarning = () => { warned = false } diff --git a/server/helpers/Robots.js b/server/helpers/Robots.js new file mode 100644 index 0000000..c6ec0a9 --- /dev/null +++ b/server/helpers/Robots.js @@ -0,0 +1,216 @@ +// +// robots.txt fetching, parsing and caching. +// +// Posture: polite, not evasive. An explicit Disallow is respected (fail-closed); +// a robots.txt we cannot retrieve is treated as permissive (fail-open), because +// a 404 or a flaky server is not a refusal and these are single-page fetches of +// URLs a user explicitly saved. +// + +const config = require('@config/scrape') +const HttpFetch = require('@helpers/HttpFetch') + +let Robots = module.exports = {} + +// origin -> { rules, expiresAt } +const cache = new Map() + +Robots.__resetCache = () => cache.clear() +Robots.__cacheSize = () => cache.size + +const ALLOW_ALL = { groups: [], allowAll: true } + +// +// Parse robots.txt into user-agent groups. +// +// Consecutive User-agent lines share one rule block, which is the part naive +// parsers get wrong. +// +Robots.parse = (text) => { + if (!text || typeof text !== 'string') { return ALLOW_ALL } + + const groups = [] + let current = null + let expectingAgents = false + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.replace(/#.*$/, '').trim() + if (!line) { continue } + + const separator = line.indexOf(':') + if (separator === -1) { continue } + + const field = line.slice(0, separator).trim().toLowerCase() + const value = line.slice(separator + 1).trim() + + if (field === 'user-agent') { + // A User-agent line directly after another starts a shared group. + if (!current || !expectingAgents) { + current = { agents: [], rules: [], crawlDelayMs: null } + groups.push(current) + } + current.agents.push(value.toLowerCase()) + expectingAgents = true + continue + } + + if (!current) { continue } // directive before any user-agent + expectingAgents = false + + if (field === 'disallow' || field === 'allow') { + // "Disallow:" with an empty value means allow everything. + if (field === 'disallow' && value === '') { continue } + current.rules.push({ allow: field === 'allow', path: value }) + continue + } + + if (field === 'crawl-delay') { + const seconds = Number(value) + if (Number.isFinite(seconds) && seconds >= 0) { + current.crawlDelayMs = Math.round(seconds * 1000) + } + } + } + + return { groups, allowAll: groups.length === 0 } +} + +// +// Pick the group whose agent token best matches us. A named match always beats +// the "*" wildcard, regardless of file order. +// +const groupFor = (rules, userAgentToken) => { + if (!rules || rules.allowAll) { return null } + + const token = userAgentToken.toLowerCase() + let best = null + let bestScore = -1 + + for (const group of rules.groups) { + for (const agent of group.agents) { + let score = -1 + if (agent === '*') { score = 0 } + else if (token.includes(agent)) { score = agent.length } + + if (score > bestScore) { + bestScore = score + best = group + } + } + } + + return best +} + +// +// Match a robots path pattern, supporting the * and $ wildcards. +// +const matchesPattern = (pattern, path) => { + if (pattern === '') { return false } + if (!pattern.includes('*') && !pattern.includes('$')) { + return path.startsWith(pattern) + } + + const anchored = pattern.endsWith('$') + const body = anchored ? pattern.slice(0, -1) : pattern + + const expression = body + .split('*') + .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*') + + return new RegExp('^' + expression + (anchored ? '$' : '')).test(path) +} + +// +// Longest matching rule wins; Allow beats Disallow at equal length. +// +Robots.isPathAllowed = (rules, path, userAgentToken) => { + const group = groupFor(rules, userAgentToken) + if (!group) { return true } + + let decision = true + let decisionLength = -1 + + for (const rule of group.rules) { + if (!matchesPattern(rule.path, path)) { continue } + + const length = rule.path.length + if (length > decisionLength || (length === decisionLength && rule.allow)) { + decision = rule.allow + decisionLength = length + } + } + + return decision +} + +const evict = () => { + if (cache.size <= config.robotsMaxOrigins) { return } + // Map preserves insertion order, so the oldest key is first. + const oldest = cache.keys().next().value + cache.delete(oldest) +} + +const getRules = async (origin) => { + const cached = cache.get(origin) + if (cached && cached.expiresAt > Date.now()) { return cached.rules } + + let rules = ALLOW_ALL + let ttl = config.robotsTtlMs + + try { + const result = await HttpFetch.fetchText(`${origin}/robots.txt`, { + maxBytes: config.maxRobotsBytes, + }) + rules = Robots.parse(result.text) + } catch (error) { + // No robots.txt, or we could not reach it. Permissive, but cached for a + // short while only so a transient outage does not grant a day's pass. + rules = ALLOW_ALL + ttl = (error && error.kind === 'http' && error.status === 404) + ? config.robotsNegativeTtlMs + : config.robotsErrorTtlMs + } + + cache.set(origin, { rules, expiresAt: Date.now() + ttl }) + evict() + + return rules +} +Robots.__getRules = getRules + +const originAndPath = (url) => { + const parsed = new URL(typeof url === 'string' ? url : url.href) + return { origin: parsed.origin, path: parsed.pathname + parsed.search } +} + +// +// Is this URL allowed by its host's robots.txt? +// +Robots.isAllowed = async (url, userAgentToken) => { + let parts = null + try { parts = originAndPath(url) } catch (error) { return false } + + const rules = await getRules(parts.origin) + return Robots.isPathAllowed(rules, parts.path, userAgentToken || config.robotsUaToken) +} + +// +// Crawl-delay for this host, or null. Capped: a site asking for an hour between +// requests gets its job failed rather than parked in the queue forever. +// +Robots.crawlDelayMs = async (url, userAgentToken) => { + let parts = null + try { parts = originAndPath(url) } catch (error) { return null } + + const rules = await getRules(parts.origin) + const group = groupFor(rules, (userAgentToken || config.robotsUaToken).toLowerCase()) + + if (!group || group.crawlDelayMs == null) { return null } + return group.crawlDelayMs +} + +Robots.exceedsCrawlDelayCap = (delayMs) => { + return delayMs != null && delayMs > config.maxCrawlDelayMs +} diff --git a/server/helpers/Schema.js b/server/helpers/Schema.js new file mode 100644 index 0000000..ee75744 --- /dev/null +++ b/server/helpers/Schema.js @@ -0,0 +1,104 @@ +// +// Idempotent schema convergence for the scraper tables. +// +// There is no migration framework in this project and no schema file in git, +// so this runs at boot, checks information_schema for each object, and applies +// only what is missing. Dev and prod self-heal, and deploy.sh needs no change. +// +// Portability matters here: dev is MariaDB 11.4 and prod is MySQL 8. +// ADD COLUMN IF NOT EXISTS is MariaDB-only and is deliberately not used - +// every ALTER is guarded by an explicit existence check instead. +// + +const db = require('@config/database') + +let Schema = module.exports = {} + +const SCRAPE_CACHE_TABLE = ` + CREATE TABLE IF NOT EXISTS scrape_cache ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + url_hash char(64) NOT NULL, + url varchar(1000) NOT NULL DEFAULT '', + canonical_url varchar(1000) DEFAULT NULL, + status smallint(6) NOT NULL DEFAULT 0, + title varchar(512) DEFAULT NULL, + site_name varchar(255) DEFAULT NULL, + description mediumtext DEFAULT NULL, + keywords varchar(512) DEFAULT NULL, + search_text mediumtext DEFAULT NULL, + image_url varchar(1000) DEFAULT NULL, + file_location varchar(500) DEFAULT NULL, + etag varchar(255) DEFAULT NULL, + last_modified varchar(128) DEFAULT NULL, + content_type varchar(128) DEFAULT NULL, + fetch_state varchar(20) NOT NULL DEFAULT 'pending', + failure_count int(11) NOT NULL DEFAULT 0, + last_error varchar(255) DEFAULT NULL, + fetched_at int(11) DEFAULT NULL, + expires_at int(11) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY scrape_cache_url_hash (url_hash), + KEY scrape_cache_expires_at (expires_at), + KEY scrape_cache_fetch_state (fetch_state) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +` + +// DEFAULT 2 ("done") keeps existing rows out of the boot recovery sweep. +const ATTACHMENT_COLUMNS = [ + { name: 'scrape_state', ddl: 'ALTER TABLE attachment ADD COLUMN scrape_state tinyint(4) NOT NULL DEFAULT 2' }, + { name: 'scrape_attempts', ddl: 'ALTER TABLE attachment ADD COLUMN scrape_attempts tinyint(4) NOT NULL DEFAULT 0' }, + { name: 'enqueued_at', ddl: 'ALTER TABLE attachment ADD COLUMN enqueued_at int(11) DEFAULT NULL' }, +] + +const ATTACHMENT_INDEXES = [ + { name: 'attachment_scrape_state', ddl: 'ALTER TABLE attachment ADD KEY attachment_scrape_state (scrape_state, enqueued_at)' }, +] + +const columnExists = async (table, column) => { + const [rows] = await db.promise().query( + `SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`, + [table, column] + ) + return rows.length > 0 +} + +const indexExists = async (table, index) => { + const [rows] = await db.promise().query( + `SELECT 1 FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1`, + [table, index] + ) + return rows.length > 0 +} +Schema.__columnExists = columnExists +Schema.__indexExists = indexExists + +// +// Safe to call repeatedly. Returns the list of statements it actually applied, +// so boot logging says something useful on the one run that matters. +// +Schema.ensureScrapeSchema = async () => { + const applied = [] + + await db.promise().query(SCRAPE_CACHE_TABLE) + + for (const column of ATTACHMENT_COLUMNS) { + if (await columnExists('attachment', column.name)) { continue } + await db.promise().query(column.ddl) + applied.push(`attachment.${column.name}`) + } + + // Index last: it references the columns above. + for (const index of ATTACHMENT_INDEXES) { + if (await indexExists('attachment', index.name)) { continue } + await db.promise().query(index.ddl) + applied.push(`attachment KEY ${index.name}`) + } + + if (applied.length) { + console.log('[schema] applied scrape schema changes:', applied.join(', ')) + } + + return applied +} diff --git a/server/helpers/UrlSafety.js b/server/helpers/UrlSafety.js new file mode 100644 index 0000000..f5f4f9f --- /dev/null +++ b/server/helpers/UrlSafety.js @@ -0,0 +1,246 @@ +// +// URL normalisation and the SSRF gate. +// +// Scraped URLs come from untrusted note text and from the /pushmebaby query +// param, so every URL is validated before a socket is opened - and again after +// every redirect hop, which is the hop that actually matters (a public URL that +// 302s to 127.0.0.1 is the classic bypass). +// +// Pure except for assertFetchable, which resolves DNS. +// + +const crypto = require('crypto') +const dns = require('dns').promises +const net = require('net') +const config = require('@config/scrape') + +let UrlSafety = module.exports = {} + +const FETCHABLE_SCHEMES = new Set(['http:', 'https:']) + +// Hostname suffixes that never belong to a public site. +const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa', '.lan'] + +// Query params that change nothing about the page but would fragment the cache. +const TRACKING_PARAMS = [ + /^utm_/i, /^fbclid$/i, /^gclid$/i, /^dclid$/i, /^msclkid$/i, + /^mc_(e|c)id$/i, /^igshid$/i, /^ref_src$/i, /^_hs(enc|mi)$/i, /^vero_/i, +] + +class SafetyError extends Error { + constructor(reason, message) { + super(message || reason) + this.name = 'SafetyError' + this.reason = reason + } +} +UrlSafety.SafetyError = SafetyError + +// +// Parse to a URL object, or null. Does NOT mutate anything that would change +// which resource is addressed - callers may store the result. +// +UrlSafety.parse = (raw, base) => { + if (!raw || typeof raw !== 'string') { return null } + + let url = null + try { + url = new URL(raw.trim(), base || undefined) + } catch (error) { + return null + } + + if (!FETCHABLE_SCHEMES.has(url.protocol)) { return null } + if (url.href.length > config.maxUrlLength) { return null } + + return url +} + +// +// Canonical form used ONLY for cache keys. Never store this - it deliberately +// discards tracking params and case, which would change what the user wrote. +// +UrlSafety.normalizeForKey = (raw) => { + const url = UrlSafety.parse(raw) + if (!url) { return null } + + url.hash = '' + url.username = '' + url.password = '' + + // Trailing dot on the host is legal DNS but splits the cache key. + if (url.hostname.endsWith('.')) { + url.hostname = url.hostname.slice(0, -1) + } + url.hostname = url.hostname.toLowerCase() + + // new URL already strips the default port, but be explicit. + if ((url.protocol === 'http:' && url.port === '80') || + (url.protocol === 'https:' && url.port === '443')) { + url.port = '' + } + + for (const key of [...url.searchParams.keys()]) { + if (TRACKING_PARAMS.some(pattern => pattern.test(key))) { + url.searchParams.delete(key) + } + } + url.searchParams.sort() + + if (url.pathname === '') { url.pathname = '/' } + + return url +} + +// sha256 of the normalised href. This is scrape_cache.url_hash - char(64), +// because a UNIQUE index on varchar(1000) utf8mb4 would be 4000 bytes and +// blow InnoDB's 3072-byte key limit. +UrlSafety.urlKey = (raw) => { + const url = UrlSafety.normalizeForKey(raw) + if (!url) { return null } + return crypto.createHash('sha256').update(url.href).digest('hex') +} + +// +// True when two URLs address the same resource modulo scheme, "www." and a +// trailing slash. +// +// Load-bearing: scanTextForWebsites diffs the URLs currently in a note against +// the URLs already stored as attachments. Raw string equality would treat +// http://foo.com and https://foo.com as different resources and delete-then- +// rescrape the row on every save. +// +UrlSafety.sameResource = (a, b) => { + const left = UrlSafety.normalizeForKey(a) + const right = UrlSafety.normalizeForKey(b) + if (!left || !right) { return false } + + const host = (url) => url.hostname.replace(/^www\./, '') + const path = (url) => url.pathname.replace(/\/+$/, '') + + return host(left) === host(right) && + path(left) === path(right) && + left.search === right.search +} + +UrlSafety.registrableHost = (hostname) => { + return String(hostname || '').toLowerCase().replace(/^www\./, '').replace(/\.$/, '') +} + +// +// Private / non-routable address detection. +// +// Covers the ranges that matter for SSRF against this app: loopback, RFC1918, +// link-local (including the 169.254.169.254 cloud metadata endpoint), CGNAT, +// and the IPv6 equivalents. +// +UrlSafety.isPrivateIp = (address) => { + if (!address) { return true } + + const version = net.isIP(address) + if (version === 0) { return true } // not an IP - caller resolves first + + if (version === 4) { return isPrivateIpv4(address) } + return isPrivateIpv6(address) +} + +const isPrivateIpv4 = (address) => { + const parts = address.split('.').map(Number) + if (parts.length !== 4 || parts.some(n => !Number.isInteger(n) || n < 0 || n > 255)) { + return true + } + const [a, b] = parts + + if (a === 0) { return true } // 0.0.0.0/8 "this host" + if (a === 10) { return true } // RFC1918 + if (a === 127) { return true } // loopback + if (a === 169 && b === 254) { return true } // link-local + metadata + if (a === 172 && b >= 16 && b <= 31) { return true } // RFC1918 + if (a === 192 && b === 168) { return true } // RFC1918 + if (a === 192 && b === 0) { return true } // 192.0.0/24 + 192.0.2/24 + if (a === 198 && (b === 18 || b === 19)) { return true } // benchmarking + if (a === 100 && b >= 64 && b <= 127) { return true } // CGNAT + if (a === 198 && b === 51) { return true } // TEST-NET-2 + if (a === 203 && b === 0) { return true } // TEST-NET-3 + if (a >= 224) { return true } // multicast + reserved + + return false +} + +const isPrivateIpv6 = (address) => { + const lower = address.toLowerCase().replace(/^\[|\]$/g, '') + + // Unwrap v4-mapped (::ffff:127.0.0.1) and NAT64 (64:ff9b::/96) before + // judging, otherwise ::ffff:127.0.0.1 reads as "some v6 address". + const mapped = lower.match(/^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/) + if (mapped) { return isPrivateIpv4(mapped[1]) } + + if (lower === '::' || lower === '::1') { return true } // unspecified, loopback + if (/^f[cd]/.test(lower)) { return true } // fc00::/7 unique-local + if (/^fe[89ab]/.test(lower)) { return true } // fe80::/10 link-local + if (/^ff/.test(lower)) { return true } // multicast + + return false +} + +// +// The gate. Throws SafetyError; resolves to the vetted addresses on success. +// +// Known limitation (documented, accepted): this resolves DNS and then hands the +// hostname to fetch, which resolves again. A hostile 0-TTL record can answer +// differently the second time. Closing that properly needs a custom undici +// Agent with connect.lookup - deliberately deferred, see the plan's Phase 5. +// Residual risk is blind SSRF only: no credentials are sent and no response +// body is returned to whoever planted the URL. +// +UrlSafety.assertFetchable = async (raw) => { + const url = raw instanceof URL ? raw : UrlSafety.parse(raw) + + if (!url) { + throw new SafetyError('bad_url', `Not a fetchable URL: ${raw}`) + } + if (!FETCHABLE_SCHEMES.has(url.protocol)) { + throw new SafetyError('bad_scheme', `Refusing scheme ${url.protocol}`) + } + if (url.username || url.password) { + throw new SafetyError('credentials_in_url', 'Refusing URL with embedded credentials') + } + + const hostname = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase() + + if (!hostname) { + throw new SafetyError('bad_url', 'URL has no host') + } + if (hostname === 'localhost' || BLOCKED_SUFFIXES.some(suffix => hostname.endsWith(suffix))) { + throw new SafetyError('blocked_host', `Refusing internal host ${hostname}`) + } + + // Literal IP in the URL - no DNS needed. + if (net.isIP(hostname)) { + if (UrlSafety.isPrivateIp(hostname)) { + throw new SafetyError('private_ip', `Refusing non-routable address ${hostname}`) + } + return [hostname] + } + + let resolved = [] + try { + resolved = await dns.lookup(hostname, { all: true, verbatim: true }) + } catch (error) { + throw new SafetyError('dns_failed', `Could not resolve ${hostname}: ${error.code || error.message}`) + } + + if (!resolved.length) { + throw new SafetyError('dns_failed', `No addresses for ${hostname}`) + } + + // Every answer must be public. One private address is enough to refuse - + // otherwise a round-robin record gets us some of the time. + for (const entry of resolved) { + if (UrlSafety.isPrivateIp(entry.address)) { + throw new SafetyError('private_ip', `${hostname} resolves to non-routable ${entry.address}`) + } + } + + return resolved.map(entry => entry.address) +} diff --git a/server/migrations/0001_scrape_cache_and_state.sql b/server/migrations/0001_scrape_cache_and_state.sql new file mode 100644 index 0000000..aea23ec --- /dev/null +++ b/server/migrations/0001_scrape_cache_and_state.sql @@ -0,0 +1,51 @@ +-- +-- Scrape cache + per-attachment scrape lifecycle. +-- +-- This file is the human-readable record. It is NOT applied by hand: the same +-- statements live in server/helpers/Schema.js and are applied idempotently at +-- boot after an information_schema existence check, so dev and prod converge +-- without a migration runner. +-- +-- Portability: dev is MariaDB 11.4, prod is MySQL 8. Nothing here may use +-- ADD COLUMN IF NOT EXISTS (MariaDB-only). +-- + +-- One row per URL, shared across users and notes. url_hash carries the UNIQUE +-- index because a UNIQUE on varchar(1000) utf8mb4 is 4000 bytes and exceeds +-- InnoDB's 3072-byte key limit. +CREATE TABLE IF NOT EXISTS `scrape_cache` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `url_hash` char(64) NOT NULL, + `url` varchar(1000) NOT NULL DEFAULT '', + `canonical_url` varchar(1000) DEFAULT NULL, + `status` smallint(6) NOT NULL DEFAULT 0, + `title` varchar(512) DEFAULT NULL, + `site_name` varchar(255) DEFAULT NULL, + `description` mediumtext DEFAULT NULL, + `keywords` varchar(512) DEFAULT NULL, + `search_text` mediumtext DEFAULT NULL, + `image_url` varchar(1000) DEFAULT NULL, + `file_location` varchar(500) DEFAULT NULL, + `etag` varchar(255) DEFAULT NULL, + `last_modified` varchar(128) DEFAULT NULL, + `content_type` varchar(128) DEFAULT NULL, + `fetch_state` varchar(20) NOT NULL DEFAULT 'pending', + `failure_count` int(11) NOT NULL DEFAULT 0, + `last_error` varchar(255) DEFAULT NULL, + `fetched_at` int(11) DEFAULT NULL, + `expires_at` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `scrape_cache_url_hash` (`url_hash`), + KEY `scrape_cache_expires_at` (`expires_at`), + KEY `scrape_cache_fetch_state` (`fetch_state`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Per-attachment lifecycle, so an in-memory queue survives a restart. +-- +-- scrape_state: 0 = pending, 1 = fetching, 2 = done, 3 = failed. +-- DEFAULT 2 is load-bearing: it keeps the ~2200 pre-existing rows out of the +-- recovery sweep on first boot. +ALTER TABLE `attachment` ADD COLUMN `scrape_state` tinyint(4) NOT NULL DEFAULT 2; +ALTER TABLE `attachment` ADD COLUMN `scrape_attempts` tinyint(4) NOT NULL DEFAULT 0; +ALTER TABLE `attachment` ADD COLUMN `enqueued_at` int(11) DEFAULT NULL; +ALTER TABLE `attachment` ADD KEY `attachment_scrape_state` (`scrape_state`, `enqueued_at`); diff --git a/server/models/ScrapeCache.js b/server/models/ScrapeCache.js new file mode 100644 index 0000000..2e3f1bc --- /dev/null +++ b/server/models/ScrapeCache.js @@ -0,0 +1,224 @@ +// +// Read-through cache for scraped URL metadata, keyed by a hash of the +// normalised URL and shared across users and notes. +// +// A fresh hit means zero network I/O. A stale hit means a conditional request +// (If-None-Match / If-Modified-Since), so an unchanged page costs one 304 +// rather than a full page load. Failures are backed off so a dead link is not +// refetched on every note save. +// +// Stores only public page metadata. Note content never reaches this table. +// + +const db = require('@config/database') +const UrlSafety = require('@helpers/UrlSafety') + +let ScrapeCache = module.exports = {} + +const SUCCESS_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days + +// Widening backoff on repeated failure, then give up and stay failed. +const FAILURE_BACKOFF_SECONDS = [60 * 60, 6 * 60 * 60, 24 * 60 * 60] +const MAX_FAILURES = FAILURE_BACKOFF_SECONDS.length + +ScrapeCache.SUCCESS_TTL_SECONDS = SUCCESS_TTL_SECONDS +ScrapeCache.MAX_FAILURES = MAX_FAILURES + +const now = () => Math.round(Date.now() / 1000) + +const truncate = (value, length) => { + if (value == null) { return null } + const text = String(value) + return text.length > length ? text.substring(0, length) : text +} + +ScrapeCache.get = async (urlOrKey) => { + const key = /^[0-9a-f]{64}$/.test(urlOrKey) ? urlOrKey : UrlSafety.urlKey(urlOrKey) + if (!key) { return null } + + const [rows] = await db.promise().query('SELECT * FROM scrape_cache WHERE url_hash = ? LIMIT 1', [key]) + return rows.length ? rows[0] : null +} + +// Fresh = inside its TTL and successfully fetched. +ScrapeCache.isFresh = (row, at) => { + if (!row || !row.expires_at) { return false } + return row.expires_at > (at || now()) +} + +ScrapeCache.isUsable = (row) => { + return !!(row && row.fetch_state === 'ok' && row.search_text != null) +} + +// Has this URL failed enough times that we should stop trying? +ScrapeCache.isExhausted = (row) => { + return !!(row && row.fetch_state === 'failed' && row.failure_count >= MAX_FAILURES) +} + +// +// Conditional-request headers for a stale-but-successful row. Null when there +// is nothing to revalidate against. +// +ScrapeCache.conditionalFor = (row) => { + if (!row || row.fetch_state !== 'ok') { return null } + if (!row.etag && !row.last_modified) { return null } + + return { etag: row.etag || null, lastModified: row.last_modified || null } +} + +// +// Reserve a row before fetching, so a concurrent save for the same URL finds +// it rather than starting a second fetch. +// +ScrapeCache.markPending = async (url) => { + const key = UrlSafety.urlKey(url) + if (!key) { return null } + + await db.promise().query( + `INSERT INTO scrape_cache (url_hash, url, fetch_state, fetched_at) + VALUES (?, ?, 'pending', ?) + ON DUPLICATE KEY UPDATE fetch_state = IF(fetch_state = 'ok', fetch_state, 'pending')`, + [key, truncate(url, 1000), now()] + ) + + return key +} + +// +// Store a successful scrape. +// +ScrapeCache.putSuccess = async (url, result) => { + const key = UrlSafety.urlKey(url) + if (!key) { return null } + + const at = now() + const fields = { + url: truncate(url, 1000), + canonical_url: truncate(result.canonicalUrl, 1000), + status: result.status || 200, + title: truncate(result.title, 512), + site_name: truncate(result.siteName, 255), + description: result.description || null, + keywords: truncate(result.keywords, 512), + search_text: result.searchText || null, + image_url: truncate(result.imageUrl, 1000), + file_location: truncate(result.fileLocation, 500), + etag: truncate(result.etag, 255), + last_modified: truncate(result.lastModified, 128), + content_type: truncate(result.contentType, 128), + fetch_state: 'ok', + failure_count: 0, + last_error: null, + fetched_at: at, + expires_at: at + SUCCESS_TTL_SECONDS, + } + + const columns = Object.keys(fields) + const values = columns.map(column => fields[column]) + + await db.promise().query( + `INSERT INTO scrape_cache (url_hash, ${columns.join(', ')}) + VALUES (?, ${columns.map(() => '?').join(', ')}) + ON DUPLICATE KEY UPDATE ${columns.map(column => `${column} = VALUES(${column})`).join(', ')}`, + [key, ...values] + ) + + return key +} + +// +// A 304. The page is unchanged, so only the freshness window moves. +// +// Deliberately does NOT touch attachment.last_indexed - Attachment.search +// orders by it, so revalidating would silently reshuffle the user's list. +// +ScrapeCache.touchNotModified = async (url) => { + const key = UrlSafety.urlKey(url) + if (!key) { return null } + + const at = now() + await db.promise().query( + `UPDATE scrape_cache + SET fetched_at = ?, expires_at = ?, fetch_state = 'ok', failure_count = 0, last_error = NULL + WHERE url_hash = ?`, + [at, at + SUCCESS_TTL_SECONDS, key] + ) + + return key +} + +// +// Record a failure with a widening retry window. +// +// `blocked` (robots or the safety gate) is terminal: retrying will not change +// the answer, so it is parked for the full success TTL instead of backing off. +// +ScrapeCache.recordFailure = async (url, error) => { + const key = UrlSafety.urlKey(url) + if (!key) { return null } + + const existing = await ScrapeCache.get(key) + const failureCount = (existing ? existing.failure_count : 0) + 1 + + const kind = (error && error.kind) || 'unknown' + const terminal = kind === 'blocked' || kind === 'robots_disallowed' + + let ttl = null + if (terminal) { + ttl = SUCCESS_TTL_SECONDS + } else if (error && error.retryAfterMs) { + ttl = Math.round(error.retryAfterMs / 1000) + } else { + ttl = FAILURE_BACKOFF_SECONDS[Math.min(failureCount, MAX_FAILURES) - 1] + } + + const at = now() + const state = terminal ? 'blocked' : 'failed' + + await db.promise().query( + `INSERT INTO scrape_cache + (url_hash, url, status, fetch_state, failure_count, last_error, fetched_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + fetch_state = VALUES(fetch_state), + failure_count = VALUES(failure_count), + last_error = VALUES(last_error), + fetched_at = VALUES(fetched_at), + expires_at = VALUES(expires_at)`, + [ + key, + truncate(url, 1000), + (error && error.status) || 0, + state, + failureCount, + truncate(`${kind}: ${(error && error.message) || ''}`, 255), + at, + at + ttl, + ] + ) + + return key +} + +// +// Project a cache row onto the shape Attachment rows want. +// +ScrapeCache.toAttachmentFields = (row) => { + if (!row) { return null } + return { + text: row.search_text || '', + fileLocation: row.file_location || null, + url: row.canonical_url || row.url, + } +} + +ScrapeCache.purgeExpired = async (limit = 500) => { + const [result] = await db.promise().query( + `DELETE FROM scrape_cache + WHERE expires_at IS NOT NULL AND expires_at < ? AND fetch_state <> 'ok' + LIMIT ?`, + [now(), limit] + ) + return result.affectedRows || 0 +} diff --git a/server/tests/fixtures/html/README.md b/server/tests/fixtures/html/README.md new file mode 100644 index 0000000..c0dc078 --- /dev/null +++ b/server/tests/fixtures/html/README.md @@ -0,0 +1,7 @@ +# HTML fixtures + +Frozen, hand-trimmed snapshots used by `server/tests/helpers/SiteScrape.test.js`. + +These are **not** fetched live. Each file isolates one parsing situation so a +failure points at a specific branch. Add a new file rather than growing an +existing one when covering a new case. diff --git a/server/tests/fixtures/html/apple-touch-icon.html b/server/tests/fixtures/html/apple-touch-icon.html new file mode 100644 index 0000000..9b5e182 --- /dev/null +++ b/server/tests/fixtures/html/apple-touch-icon.html @@ -0,0 +1,9 @@ + + + + Icons By Size + + + +

x

+ diff --git a/server/tests/fixtures/html/extensionless-og-image.html b/server/tests/fixtures/html/extensionless-og-image.html new file mode 100644 index 0000000..0492e00 --- /dev/null +++ b/server/tests/fixtures/html/extensionless-og-image.html @@ -0,0 +1,8 @@ + + + + Generated OG Image + + +

x

+ diff --git a/server/tests/fixtures/html/h1-fallback.html b/server/tests/fixtures/html/h1-fallback.html new file mode 100644 index 0000000..1936716 --- /dev/null +++ b/server/tests/fixtures/html/h1-fallback.html @@ -0,0 +1,5 @@ + + + +

Heading Is All We Have

No title element, no meta tags.

+ diff --git a/server/tests/fixtures/html/imgs-missing-src.html b/server/tests/fixtures/html/imgs-missing-src.html new file mode 100644 index 0000000..c522c60 --- /dev/null +++ b/server/tests/fixtures/html/imgs-missing-src.html @@ -0,0 +1,10 @@ + + +Broken Images + + + no src here + + + + diff --git a/server/tests/fixtures/html/jsonld-article.html b/server/tests/fixtures/html/jsonld-article.html new file mode 100644 index 0000000..088e51c --- /dev/null +++ b/server/tests/fixtures/html/jsonld-article.html @@ -0,0 +1,15 @@ + + + + + +

x

+ diff --git a/server/tests/fixtures/html/jsonld-graph.html b/server/tests/fixtures/html/jsonld-graph.html new file mode 100644 index 0000000..02562f0 --- /dev/null +++ b/server/tests/fixtures/html/jsonld-graph.html @@ -0,0 +1,15 @@ + + + + + +

x

+ diff --git a/server/tests/fixtures/html/jsonld-malformed.html b/server/tests/fixtures/html/jsonld-malformed.html new file mode 100644 index 0000000..eb7e0a8 --- /dev/null +++ b/server/tests/fixtures/html/jsonld-malformed.html @@ -0,0 +1,11 @@ + + + + Survived Bad JSON + + + +

x

+ diff --git a/server/tests/fixtures/html/keyword-content.html b/server/tests/fixtures/html/keyword-content.html new file mode 100644 index 0000000..2b7f438 --- /dev/null +++ b/server/tests/fixtures/html/keyword-content.html @@ -0,0 +1,16 @@ + + +Beekeeping Guide + + + + +
+

Beekeeping is rewarding. Beekeeping requires patience. A beehive needs space.

+

The beehive must stay dry. Honey harvest comes later. Honey harvest rewards patience.

+

Beekeeping beginners should buy a beehive and study honey production carefully.

+

Honey production depends on the beehive and on steady beekeeping habits.

+
+
Footer footer footer links copyright
+ + diff --git a/server/tests/fixtures/html/malformed-unclosed.html b/server/tests/fixtures/html/malformed-unclosed.html new file mode 100644 index 0000000..ee5100e --- /dev/null +++ b/server/tests/fixtures/html/malformed-unclosed.html @@ -0,0 +1,7 @@ + + +Unclosed Tags +<body> + <div><p>Paragraph never closed + <ul><li>item one<li>item two +</html> diff --git a/server/tests/fixtures/html/no-metadata.html b/server/tests/fixtures/html/no-metadata.html new file mode 100644 index 0000000..019db17 --- /dev/null +++ b/server/tests/fixtures/html/no-metadata.html @@ -0,0 +1,2 @@ +<!doctype html> +<html><head></head><body></body></html> diff --git a/server/tests/fixtures/html/og-complete.html b/server/tests/fixtures/html/og-complete.html new file mode 100644 index 0000000..13d0090 --- /dev/null +++ b/server/tests/fixtures/html/og-complete.html @@ -0,0 +1,14 @@ +<!doctype html> +<html lang="en"> +<head> + <title>Fallback Title Nobody Should See + + + + + + + + +

Body text.

+ diff --git a/server/tests/fixtures/html/protocol-relative-image.html b/server/tests/fixtures/html/protocol-relative-image.html new file mode 100644 index 0000000..44e5ac8 --- /dev/null +++ b/server/tests/fixtures/html/protocol-relative-image.html @@ -0,0 +1,8 @@ + + + + Protocol Relative + + +

x

+ diff --git a/server/tests/fixtures/html/relative-images.html b/server/tests/fixtures/html/relative-images.html new file mode 100644 index 0000000..9739cbb --- /dev/null +++ b/server/tests/fixtures/html/relative-images.html @@ -0,0 +1,9 @@ + + + + Relative Assets + + + +

x

+ diff --git a/server/tests/fixtures/html/root-relative-icon.html b/server/tests/fixtures/html/root-relative-icon.html new file mode 100644 index 0000000..f6cdc99 --- /dev/null +++ b/server/tests/fixtures/html/root-relative-icon.html @@ -0,0 +1,8 @@ + + + + Icon Only + + +

x

+ diff --git a/server/tests/fixtures/html/sized-images.html b/server/tests/fixtures/html/sized-images.html new file mode 100644 index 0000000..72589f4 --- /dev/null +++ b/server/tests/fixtures/html/sized-images.html @@ -0,0 +1,9 @@ + + +Sized + + + + + + diff --git a/server/tests/fixtures/html/srcset.html b/server/tests/fixtures/html/srcset.html new file mode 100644 index 0000000..9f8e368 --- /dev/null +++ b/server/tests/fixtures/html/srcset.html @@ -0,0 +1,7 @@ + + +Srcset + + + + diff --git a/server/tests/fixtures/html/svg-only-image.html b/server/tests/fixtures/html/svg-only-image.html new file mode 100644 index 0000000..31657c5 --- /dev/null +++ b/server/tests/fixtures/html/svg-only-image.html @@ -0,0 +1,8 @@ + + + + SVG Only + + +

x

+ diff --git a/server/tests/fixtures/html/title-only.html b/server/tests/fixtures/html/title-only.html new file mode 100644 index 0000000..b997466 --- /dev/null +++ b/server/tests/fixtures/html/title-only.html @@ -0,0 +1,5 @@ + + + Just A Title +

An H1 Heading

No metadata here at all.

+ diff --git a/server/tests/fixtures/html/tracking-pixels.html b/server/tests/fixtures/html/tracking-pixels.html new file mode 100644 index 0000000..f5c4f2a --- /dev/null +++ b/server/tests/fixtures/html/tracking-pixels.html @@ -0,0 +1,9 @@ + + +Pixels + + + + + + diff --git a/server/tests/fixtures/html/twitter-only.html b/server/tests/fixtures/html/twitter-only.html new file mode 100644 index 0000000..079a3c5 --- /dev/null +++ b/server/tests/fixtures/html/twitter-only.html @@ -0,0 +1,10 @@ + + + + Document Title + + + + +

x

+ diff --git a/server/tests/fixtures/html/webp-og-image.html b/server/tests/fixtures/html/webp-og-image.html new file mode 100644 index 0000000..d36bf7b --- /dev/null +++ b/server/tests/fixtures/html/webp-og-image.html @@ -0,0 +1,8 @@ + + + + Modern Formats + + +

x

+ diff --git a/server/tests/helpers/HostQueue.test.js b/server/tests/helpers/HostQueue.test.js new file mode 100644 index 0000000..af673cd --- /dev/null +++ b/server/tests/helpers/HostQueue.test.js @@ -0,0 +1,318 @@ +require('module-alias/register') + +const HostQueue = require('@helpers/HostQueue') + +// +// A controllable clock that also drives the queue's own timers, so advancing +// time is instant and spacing assertions are exact rather than flaky. +// +const makeClock = () => { + let t = 1000 + let nextId = 1 + const timers = new Map() + + return { + now: () => t, + setTimeoutFn: (fn, ms) => { + const id = nextId++ + timers.set(id, { fn, dueAt: t + ms }) + return id + }, + clearTimeoutFn: (id) => { timers.delete(id) }, + advance: (ms) => { + t += ms + for (const [id, timer] of [...timers.entries()]) { + if (timer.dueAt <= t) { + timers.delete(id) + timer.fn() + } + } + }, + } +} + +const wireClock = (clock) => ({ + now: clock.now, + setTimeoutFn: clock.setTimeoutFn, + clearTimeoutFn: clock.clearTimeoutFn, +}) + +const deferred = () => { + let resolve = null + let reject = null + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +describe('global concurrency', () => { + + test('never exceeds the cap', async () => { + const queue = new HostQueue.Queue({ globalConcurrency: 2, minSpacingMs: 0, maxSpacingMs: 0 }) + + let active = 0 + let peak = 0 + const gates = [] + + const jobs = Array.from({ length: 6 }, (unused, i) => { + const gate = deferred() + gates.push(gate) + return queue.schedule(`host${i}.com`, async () => { + active++ + peak = Math.max(peak, active) + await gate.promise + active-- + return i + }) + }) + + // Let the first batch start. + await new Promise(resolve => setImmediate(resolve)) + expect(peak).toBe(2) + + gates.forEach(gate => gate.resolve()) + await Promise.all(jobs) + + expect(peak).toBe(2) + }) + + test('runs different hosts in parallel up to the cap', async () => { + const queue = new HostQueue.Queue({ globalConcurrency: 4, minSpacingMs: 0, maxSpacingMs: 0 }) + + let active = 0 + let peak = 0 + const gates = [] + + const jobs = ['a.com', 'b.com', 'c.com'].map(host => { + const gate = deferred() + gates.push(gate) + return queue.schedule(host, async () => { + active++ + peak = Math.max(peak, active) + await gate.promise + active-- + }) + }) + + await new Promise(resolve => setImmediate(resolve)) + expect(peak).toBe(3) + + gates.forEach(gate => gate.resolve()) + await Promise.all(jobs) + }) +}) + +describe('per-host serialisation', () => { + + test('runs only one request per host at a time', async () => { + const queue = new HostQueue.Queue({ globalConcurrency: 4, minSpacingMs: 0, maxSpacingMs: 0 }) + + let active = 0 + let peak = 0 + + const jobs = [1, 2, 3].map(() => queue.schedule('same.com', async () => { + active++ + peak = Math.max(peak, active) + await new Promise(resolve => setImmediate(resolve)) + active-- + })) + + await Promise.all(jobs) + + expect(peak).toBe(1) + }) + + test('preserves FIFO order within a host', async () => { + const queue = new HostQueue.Queue({ globalConcurrency: 1, minSpacingMs: 0, maxSpacingMs: 0 }) + const order = [] + + await Promise.all([1, 2, 3, 4].map(n => + queue.schedule('same.com', async () => { order.push(n) }) + )) + + expect(order).toEqual([1, 2, 3, 4]) + }) +}) + +describe('politeness spacing', () => { + + test('leaves a gap between two requests to the same host', async () => { + const clock = makeClock() + const queue = new HostQueue.Queue({ + globalConcurrency: 4, + minSpacingMs: 1000, + maxSpacingMs: 1000, + ...wireClock(clock), + }) + + const startedAt = [] + + const first = queue.schedule('same.com', async () => { startedAt.push(clock.now()) }) + await first + + const second = queue.schedule('same.com', async () => { startedAt.push(clock.now()) }) + + // The second job must not have run yet - the host is spaced out. + await new Promise(resolve => setImmediate(resolve)) + expect(startedAt).toHaveLength(1) + + clock.advance(1000) + await second + + expect(startedAt).toHaveLength(2) + expect(startedAt[1] - startedAt[0]).toBeGreaterThanOrEqual(1000) + }) + + test('spacing on one host does not delay a different host', async () => { + const clock = makeClock() + const queue = new HostQueue.Queue({ + globalConcurrency: 4, + minSpacingMs: 5000, + maxSpacingMs: 5000, + ...wireClock(clock), + }) + + await queue.schedule('slow.com', async () => {}) + + // slow.com is now parked for 5s. other.com must be unaffected. + const ran = [] + await queue.schedule('other.com', async () => { ran.push('other') }) + + expect(ran).toEqual(['other']) + }) + + test('honours an explicit spacing override (robots Crawl-delay)', async () => { + const clock = makeClock() + const queue = new HostQueue.Queue({ minSpacingMs: 0, maxSpacingMs: 0, ...wireClock(clock) }) + + await queue.schedule('slow.com', async () => {}, { spacingMs: 3000 }) + + const ran = [] + const second = queue.schedule('slow.com', async () => { ran.push(1) }) + + await new Promise(resolve => setImmediate(resolve)) + expect(ran).toEqual([]) + + clock.advance(3000) + await second + expect(ran).toEqual([1]) + }) +}) + +describe('error handling', () => { + + test('a rejecting task does not wedge the queue', async () => { + const queue = new HostQueue.Queue({ minSpacingMs: 0, maxSpacingMs: 0 }) + + await expect(queue.schedule('a.com', async () => { throw new Error('boom') })) + .rejects.toThrow('boom') + + await expect(queue.schedule('a.com', async () => 'fine')).resolves.toBe('fine') + }) + + test('a task that throws synchronously is caught', async () => { + const queue = new HostQueue.Queue({ minSpacingMs: 0, maxSpacingMs: 0 }) + + await expect(queue.schedule('a.com', () => { throw new Error('sync boom') })) + .rejects.toThrow('sync boom') + + expect(queue.inflight()).toBe(0) + }) + + test('one failing host does not block others', async () => { + const queue = new HostQueue.Queue({ globalConcurrency: 2, minSpacingMs: 0, maxSpacingMs: 0 }) + + const results = await Promise.allSettled([ + queue.schedule('bad.com', async () => { throw new Error('nope') }), + queue.schedule('good.com', async () => 'ok'), + ]) + + expect(results[0].status).toBe('rejected') + expect(results[1].value).toBe('ok') + }) +}) + +describe('drain and stop', () => { + + test('drain resolves when everything has finished', async () => { + const queue = new HostQueue.Queue({ globalConcurrency: 2, minSpacingMs: 0, maxSpacingMs: 0 }) + const done = [] + + ;[1, 2, 3].forEach(n => queue.schedule(`h${n}.com`, async () => { done.push(n) }).catch(() => {})) + + await queue.drain() + + expect(done).toHaveLength(3) + expect(queue.size()).toBe(0) + expect(queue.inflight()).toBe(0) + }) + + test('drain resolves immediately on an idle queue', async () => { + const queue = new HostQueue.Queue() + await expect(queue.drain()).resolves.toBeUndefined() + }) + + test('stop rejects queued work and refuses new work', async () => { + const queue = new HostQueue.Queue({ globalConcurrency: 1, minSpacingMs: 0, maxSpacingMs: 0 }) + + const gate = deferred() + const first = queue.schedule('a.com', () => gate.promise) + const queued = queue.schedule('b.com', async () => 'never') + + await new Promise(resolve => setImmediate(resolve)) + queue.stop() + + await expect(queued).rejects.toThrow('stopped') + await expect(queue.schedule('c.com', async () => 'x')).rejects.toThrow('stopped') + + gate.resolve('done') + await expect(first).resolves.toBe('done') + }) +}) + +describe('event loop', () => { + + // + // Regression: the politeness timer used to be unref'd, so if nothing else + // held the event loop open Node exited and queued jobs were abandoned with + // their promises never settling. + // + test('a spaced job still resolves with nothing else holding the loop open', async () => { + const queue = new HostQueue.Queue({ minSpacingMs: 25, maxSpacingMs: 25 }) + + await queue.schedule('same.com', async () => 'first') + + // Only the internal timer can resume this one. + await expect(queue.schedule('same.com', async () => 'second')).resolves.toBe('second') + }) + + test('stop() clears the pending timer so shutdown is not blocked', async () => { + const queue = new HostQueue.Queue({ minSpacingMs: 10000, maxSpacingMs: 10000 }) + + await queue.schedule('same.com', async () => 'first') + const queued = queue.schedule('same.com', async () => 'never') + + queue.stop() + + await expect(queued).rejects.toThrow('stopped') + }) +}) + +describe('stats', () => { + + test('reports pending, running and host counts', async () => { + const queue = new HostQueue.Queue({ globalConcurrency: 1, minSpacingMs: 0, maxSpacingMs: 0 }) + + const gate = deferred() + const first = queue.schedule('a.com', () => gate.promise) + const second = queue.schedule('b.com', async () => {}) + + await new Promise(resolve => setImmediate(resolve)) + + const stats = queue.stats() + expect(stats.running).toBe(1) + expect(stats.pending).toBe(1) + + gate.resolve() + await Promise.all([first, second]) + }) +}) diff --git a/server/tests/helpers/HttpFetch.test.js b/server/tests/helpers/HttpFetch.test.js new file mode 100644 index 0000000..0baf931 --- /dev/null +++ b/server/tests/helpers/HttpFetch.test.js @@ -0,0 +1,479 @@ +require('module-alias/register') + +const HttpFetch = require('@helpers/HttpFetch') +const BrowserProfiles = require('@helpers/BrowserProfiles') +const FetchMock = require('../support/fetchMock') + +const dns = require('dns').promises +let dnsSpy = null + +beforeEach(() => { + FetchMock.install() + // Every hostname in these tests resolves to one public address, so the + // safety gate passes and we are testing HttpFetch rather than DNS. + dnsSpy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) + // No real wall-time in backoff. + HttpFetch.__setSleep(() => Promise.resolve()) +}) + +afterEach(() => { + FetchMock.restore() + dnsSpy.mockRestore() + HttpFetch.__resetSleep() +}) + +describe('fetchDocument happy path', () => { + + test('returns decoded html, status and final url', async () => { + FetchMock.route('https://example.com/', FetchMock.html('Hello')) + + const result = await HttpFetch.fetchDocument('https://example.com/') + + expect(result.status).toBe(200) + expect(result.html).toContain('Hello') + expect(result.finalUrl).toBe('https://example.com/') + expect(result.notModified).toBe(false) + }) + + test('surfaces etag and last-modified for later conditional requests', async () => { + FetchMock.route('https://example.com/', FetchMock.html('

x

', { + headers: { etag: 'W/"abc"', 'last-modified': 'Wed, 01 Jan 2025 00:00:00 GMT' }, + })) + + const result = await HttpFetch.fetchDocument('https://example.com/') + + expect(result.etag).toBe('W/"abc"') + expect(result.lastModified).toBe('Wed, 01 Jan 2025 00:00:00 GMT') + }) +}) + +describe('fetchDocument browser identity', () => { + + test('sends a complete, coherent header set and never claims to be Googlebot', async () => { + FetchMock.route('https://example.com/', FetchMock.html('

x

')) + + await HttpFetch.fetchDocument('https://example.com/') + const headers = FetchMock.calls()[0].headers + + expect(headers['User-Agent']).toMatch(/^Mozilla\/5\.0/) + expect(headers['User-Agent']).not.toMatch(/Googlebot/i) + expect(headers['Accept']).toContain('text/html') + expect(headers['Accept-Language']).toBeTruthy() + expect(headers['Accept-Encoding']).toContain('gzip') + }) + + test('gives the same host the same profile across calls', async () => { + FetchMock.route(/example\.com/, () => FetchMock.html('

x

')) + + const first = await HttpFetch.fetchDocument('https://example.com/a') + const second = await HttpFetch.fetchDocument('https://example.com/b') + + expect(first.profileId).toBe(second.profileId) + }) + + test('pairs sec-ch-ua only with Chromium profiles', () => { + const chrome = BrowserProfiles.profileById('chrome-win') + const firefox = BrowserProfiles.profileById('firefox-win') + const safari = BrowserProfiles.profileById('safari-mac') + + const chromeHeaders = BrowserProfiles.documentHeaders(chrome, { url: 'https://a.com/' }) + const firefoxHeaders = BrowserProfiles.documentHeaders(firefox, { url: 'https://a.com/' }) + const safariHeaders = BrowserProfiles.documentHeaders(safari, { url: 'https://a.com/' }) + + expect(chromeHeaders['sec-ch-ua']).toBeTruthy() + expect(chromeHeaders['Sec-Fetch-Mode']).toBe('navigate') + + // Firefox sends Sec-Fetch-* but no client hints. + expect(firefoxHeaders['sec-ch-ua']).toBeUndefined() + expect(firefoxHeaders['Sec-Fetch-Mode']).toBe('navigate') + + // Safari sends neither. + expect(safariHeaders['sec-ch-ua']).toBeUndefined() + expect(safariHeaders['Sec-Fetch-Mode']).toBeUndefined() + }) + + test('never pairs a chrome UA with a firefox accept header', () => { + BrowserProfiles.PROFILES.forEach(profile => { + if (profile.family === 'chrome') { + expect(profile.ua).toMatch(/Chrome\//) + expect(profile.accept).toContain('application/signed-exchange') + } + if (profile.family === 'firefox') { + expect(profile.ua).toMatch(/Firefox\//) + expect(profile.accept).not.toContain('signed-exchange') + } + }) + }) +}) + +describe('fetchDocument redirects', () => { + + test('follows a single hop and reports the final url', async () => { + FetchMock.route('https://example.com/a', FetchMock.redirect('https://example.com/b', 301)) + FetchMock.route('https://example.com/b', () => FetchMock.html('B')) + + const result = await HttpFetch.fetchDocument('https://example.com/a') + + expect(result.finalUrl).toBe('https://example.com/b') + expect(result.html).toContain('B') + }) + + test('resolves a relative Location header', async () => { + FetchMock.route('https://example.com/a', FetchMock.redirect('/c', 302)) + FetchMock.route('https://example.com/c', () => FetchMock.html('C')) + + const result = await HttpFetch.fetchDocument('https://example.com/a') + expect(result.finalUrl).toBe('https://example.com/c') + }) + + test('detects a redirect loop', async () => { + FetchMock.route('https://example.com/a', () => FetchMock.redirect('https://example.com/b', 302)) + FetchMock.route('https://example.com/b', () => FetchMock.redirect('https://example.com/a', 302)) + + await expect(HttpFetch.fetchDocument('https://example.com/a')) + .rejects.toMatchObject({ kind: 'redirect_loop' }) + }) + + test('gives up past the redirect cap', async () => { + let n = 0 + FetchMock.route(/https:\/\/example\.com\/hop/, () => FetchMock.redirect(`https://example.com/hop${++n}`, 302)) + + await expect(HttpFetch.fetchDocument('https://example.com/hop')) + .rejects.toMatchObject({ kind: 'too_many_redirects' }) + }) + + // The SSRF case that matters: the first URL is perfectly public. + test('blocks a redirect pointing at loopback', async () => { + FetchMock.route('https://example.com/a', FetchMock.redirect('http://127.0.0.1:3000/admin', 302)) + + await expect(HttpFetch.fetchDocument('https://example.com/a')) + .rejects.toMatchObject({ kind: 'blocked' }) + }) + + test('blocks a redirect pointing at the cloud metadata endpoint', async () => { + FetchMock.route('https://example.com/a', FetchMock.redirect('http://169.254.169.254/latest/meta-data/', 302)) + + await expect(HttpFetch.fetchDocument('https://example.com/a')) + .rejects.toMatchObject({ kind: 'blocked' }) + }) + + test('refuses an https to http downgrade', async () => { + FetchMock.route('https://example.com/a', FetchMock.redirect('http://example.com/a', 302)) + + await expect(HttpFetch.fetchDocument('https://example.com/a')) + .rejects.toMatchObject({ kind: 'blocked' }) + }) +}) + +describe('fetchDocument retries', () => { + + test('retries a 500 and succeeds', async () => { + FetchMock.routeSequence('https://example.com/', [ + FetchMock.status(500), + FetchMock.html('Recovered'), + ]) + + const result = await HttpFetch.fetchDocument('https://example.com/') + + expect(result.html).toContain('Recovered') + expect(FetchMock.callCount()).toBe(2) + }) + + test('gives up after exhausting retries', async () => { + FetchMock.route('https://example.com/', () => FetchMock.status(503)) + + await expect(HttpFetch.fetchDocument('https://example.com/')) + .rejects.toMatchObject({ kind: 'http', status: 503 }) + + // 1 initial attempt + config.retries + expect(FetchMock.callCount()).toBe(3) + }) + + test('does NOT retry a 404 - it is an answer, not a failure', async () => { + FetchMock.route('https://example.com/', () => FetchMock.status(404)) + + await expect(HttpFetch.fetchDocument('https://example.com/')) + .rejects.toMatchObject({ kind: 'http', status: 404 }) + + expect(FetchMock.callCount()).toBe(1) + }) + + test('does NOT retry a 403', async () => { + FetchMock.route('https://example.com/', () => FetchMock.status(403)) + + await expect(HttpFetch.fetchDocument('https://example.com/')).rejects.toMatchObject({ status: 403 }) + expect(FetchMock.callCount()).toBe(1) + }) + + test('honours a short Retry-After on a 429', async () => { + const waits = [] + HttpFetch.__setSleep((ms) => { waits.push(ms); return Promise.resolve() }) + + FetchMock.routeSequence('https://example.com/', [ + FetchMock.status(429, { headers: { 'retry-after': '2' } }), + FetchMock.html('OK'), + ]) + + const result = await HttpFetch.fetchDocument('https://example.com/') + + expect(result.html).toContain('OK') + expect(waits[0]).toBe(2000) + }) + + test('refuses to sit on an hour-long Retry-After', async () => { + FetchMock.route('https://example.com/', () => FetchMock.status(429, { headers: { 'retry-after': '3600' } })) + + await expect(HttpFetch.fetchDocument('https://example.com/')) + .rejects.toMatchObject({ kind: 'http', status: 429 }) + + // Surfaced for the cache to park the URL rather than retried. + expect(FetchMock.callCount()).toBe(1) + }) + + test('retries a network error', async () => { + FetchMock.routeSequence('https://example.com/', [ + FetchMock.networkError('ECONNRESET'), + FetchMock.html('Up'), + ]) + + const result = await HttpFetch.fetchDocument('https://example.com/') + expect(result.html).toContain('Up') + }) +}) + +describe('fetchDocument limits and gates', () => { + + test('times out on a server that never responds', async () => { + FetchMock.route('https://example.com/', () => FetchMock.hang()) + + await expect(HttpFetch.fetchDocument('https://example.com/', { timeoutMs: 60, retries: 0 })) + .rejects.toMatchObject({ kind: 'timeout' }) + }) + + test('aborts a body that exceeds the cap mid-stream', async () => { + FetchMock.route('https://example.com/', () => FetchMock.oversized()) + + await expect(HttpFetch.fetchDocument('https://example.com/', { maxBytes: 128 * 1024, retries: 0 })) + .rejects.toMatchObject({ kind: 'too_large' }) + }) + + test('rejects an oversized declared content-length without reading the body', async () => { + FetchMock.route('https://example.com/', () => FetchMock.html('

small

', { + headers: { 'content-length': String(50 * 1024 * 1024) }, + })) + + await expect(HttpFetch.fetchDocument('https://example.com/', { retries: 0 })) + .rejects.toMatchObject({ kind: 'too_large' }) + }) + + test('refuses a non-html content type before parsing', async () => { + FetchMock.route('https://example.com/f.pdf', () => FetchMock.buffer(Buffer.alloc(16), { + contentType: 'application/pdf', + })) + + await expect(HttpFetch.fetchDocument('https://example.com/f.pdf', { retries: 0 })) + .rejects.toMatchObject({ kind: 'bad_content_type' }) + }) + + test('refuses a binary masquerading behind an image content type', async () => { + FetchMock.route('https://example.com/x', () => FetchMock.buffer(Buffer.alloc(16), { + contentType: 'image/png', + })) + + await expect(HttpFetch.fetchDocument('https://example.com/x', { retries: 0 })) + .rejects.toMatchObject({ kind: 'bad_content_type' }) + }) +}) + +describe('fetchDocument charset handling', () => { + + test('decodes windows-1252 declared in the content-type header', async () => { + // 0x93/0x94 are curly quotes in cp1252 and invalid in utf-8. + const body = Buffer.concat([ + Buffer.from('', 'latin1'), + Buffer.from([0x93]), + Buffer.from('caf', 'latin1'), + Buffer.from([0xe9, 0x94]), + Buffer.from('', 'latin1'), + ]) + FetchMock.route('https://example.com/', () => FetchMock.buffer(body, { + contentType: 'text/html; charset=windows-1252', + })) + + const result = await HttpFetch.fetchDocument('https://example.com/') + + expect(result.html).toContain('“café”') + expect(result.charset).toBe('windows-1252') + }) + + test('falls back to a meta charset when the header says nothing', async () => { + const body = Buffer.concat([ + Buffer.from('caf', 'latin1'), + Buffer.from([0xe9]), + Buffer.from('', 'latin1'), + ]) + FetchMock.route('https://example.com/', () => FetchMock.buffer(body, { contentType: 'text/html' })) + + const result = await HttpFetch.fetchDocument('https://example.com/') + expect(result.html).toContain('café') + }) + + test('header charset wins over the meta tag', async () => { + const body = Buffer.from('café', 'utf8') + FetchMock.route('https://example.com/', () => FetchMock.buffer(body, { + contentType: 'text/html; charset=utf-8', + })) + + const result = await HttpFetch.fetchDocument('https://example.com/') + expect(result.html).toContain('café') + }) + + test('survives a bogus charset label', async () => { + FetchMock.route('https://example.com/', () => FetchMock.buffer(Buffer.from('

ok

'), { + contentType: 'text/html; charset=not-a-real-charset', + })) + + const result = await HttpFetch.fetchDocument('https://example.com/') + expect(result.html).toContain('ok') + }) +}) + +describe('fetchDocument conditional requests', () => { + + test('sends If-None-Match and If-Modified-Since when given', async () => { + FetchMock.route('https://example.com/', () => FetchMock.html('

x

')) + + await HttpFetch.fetchDocument('https://example.com/', { + conditional: { etag: 'W/"v1"', lastModified: 'Wed, 01 Jan 2025 00:00:00 GMT' }, + }) + + const headers = FetchMock.calls()[0].headers + expect(headers['If-None-Match']).toBe('W/"v1"') + expect(headers['If-Modified-Since']).toBe('Wed, 01 Jan 2025 00:00:00 GMT') + }) + + test('reports a 304 as notModified with no body', async () => { + FetchMock.route('https://example.com/', () => FetchMock.status(304)) + + const result = await HttpFetch.fetchDocument('https://example.com/', { + conditional: { etag: 'W/"v1"' }, + }) + + expect(result.notModified).toBe(true) + expect(result.status).toBe(304) + expect(result.html).toBeNull() + }) +}) + +describe('fetchBinary', () => { + + const pngMagic = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + + test('returns the buffer for a real image', async () => { + FetchMock.route('https://example.com/i.png', () => FetchMock.buffer(pngMagic, { contentType: 'image/png' })) + + const result = await HttpFetch.fetchBinary('https://example.com/i.png') + + expect(result.mime).toBe('image/png') + expect(result.buffer.equals(pngMagic)).toBe(true) + }) + + // The old code wrote a 404 HTML error page to disk and handed it to gm. + test('refuses an html error page served where an image was expected', async () => { + FetchMock.route('https://example.com/i.png', () => FetchMock.html('

404

')) + + await expect(HttpFetch.fetchBinary('https://example.com/i.png', { retries: 0 })) + .rejects.toMatchObject({ kind: 'bad_content_type' }) + }) + + test('propagates a 404 status rather than writing anything', async () => { + FetchMock.route('https://example.com/i.png', () => FetchMock.status(404)) + + await expect(HttpFetch.fetchBinary('https://example.com/i.png', { retries: 0 })) + .rejects.toMatchObject({ kind: 'http', status: 404 }) + }) + + test('enforces the image size cap', async () => { + FetchMock.route('https://example.com/i.png', () => FetchMock.oversized('image/png')) + + await expect(HttpFetch.fetchBinary('https://example.com/i.png', { maxBytes: 64 * 1024, retries: 0 })) + .rejects.toMatchObject({ kind: 'too_large' }) + }) + + test('rejects svg by default', async () => { + FetchMock.route('https://example.com/i.svg', () => FetchMock.buffer(Buffer.from(''), { + contentType: 'image/svg+xml', + })) + + await expect(HttpFetch.fetchBinary('https://example.com/i.svg', { retries: 0 })) + .rejects.toMatchObject({ kind: 'bad_content_type' }) + }) + + test('sends image-appropriate fetch metadata and a page referer', async () => { + FetchMock.route('https://cdn.example.net/i.png', () => FetchMock.buffer(pngMagic, { contentType: 'image/png' })) + + await HttpFetch.fetchBinary('https://cdn.example.net/i.png', { pageUrl: 'https://example.com/post' }) + + const headers = FetchMock.calls()[0].headers + expect(headers['Referer']).toBe('https://example.com/post') + expect(headers['Accept']).toContain('image/') + if (headers['Sec-Fetch-Dest']) { + expect(headers['Sec-Fetch-Dest']).toBe('image') + expect(headers['Sec-Fetch-Site']).toBe('cross-site') + } + }) +}) + +describe('transient vs terminal safety failures', () => { + + // A private address is a permanent refusal; a DNS failure means the host + // may just be down. Classifying both as "blocked" parked transient + // failures for the full cache TTL. + test('classifies a private address as blocked', async () => { + FetchMock.route(/.*/, () => FetchMock.html('

x

')) + + await expect(HttpFetch.fetchDocument('http://10.0.0.1/', { retries: 0 })) + .rejects.toMatchObject({ kind: 'blocked', reason: 'private_ip' }) + }) + + test('classifies a DNS failure as network, not blocked', async () => { + dnsSpy.mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOTFOUND' })) + FetchMock.route(/.*/, () => FetchMock.html('

x

')) + + await expect(HttpFetch.fetchDocument('https://gone.example/', { retries: 0 })) + .rejects.toMatchObject({ kind: 'network', reason: 'dns_failed' }) + }) + + test('retries a DNS failure, since it may be transient', async () => { + let attempts = 0 + dnsSpy.mockImplementation(async () => { + attempts++ + if (attempts < 2) { throw Object.assign(new Error('nope'), { code: 'ENOTFOUND' }) } + return [{ address: '93.184.216.34', family: 4 }] + }) + FetchMock.route('https://flaky.example/', () => FetchMock.html('Back up')) + + const result = await HttpFetch.fetchDocument('https://flaky.example/') + expect(result.html).toContain('Back up') + expect(attempts).toBe(2) + }) + + test('does NOT retry a private address', async () => { + FetchMock.route(/.*/, () => FetchMock.html('

x

')) + + await expect(HttpFetch.fetchDocument('http://127.0.0.1/')).rejects.toMatchObject({ kind: 'blocked' }) + expect(FetchMock.callCount()).toBe(0) + }) +}) + +describe('safety gate on the initial url', () => { + + test('refuses to open a connection to a private address at all', async () => { + FetchMock.route(/.*/, () => FetchMock.html('

should never be reached

')) + + await expect(HttpFetch.fetchDocument('http://127.0.0.1:3000/', { retries: 0 })) + .rejects.toMatchObject({ kind: 'blocked' }) + + expect(FetchMock.callCount()).toBe(0) + }) +}) diff --git a/server/tests/helpers/Robots.test.js b/server/tests/helpers/Robots.test.js new file mode 100644 index 0000000..93f31dd --- /dev/null +++ b/server/tests/helpers/Robots.test.js @@ -0,0 +1,173 @@ +require('module-alias/register') + +const Robots = require('@helpers/Robots') +const config = require('@config/scrape') +const FetchMock = require('../support/fetchMock') + +const dns = require('dns').promises +let dnsSpy = null + +const UA = config.robotsUaToken + +beforeEach(() => { + FetchMock.install() + Robots.__resetCache() + dnsSpy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) +}) + +afterEach(() => { + FetchMock.restore() + dnsSpy.mockRestore() + Robots.__resetCache() +}) + +const serveRobots = (body, origin = 'https://example.com') => { + FetchMock.route(`${origin}/robots.txt`, () => new Response(body, { + status: 200, + headers: { 'content-type': 'text/plain' }, + })) +} + +describe('Robots.parse', () => { + + test('groups consecutive user-agent lines into one rule block', () => { + const rules = Robots.parse([ + 'User-agent: alpha', + 'User-agent: beta', + 'Disallow: /private', + ].join('\n')) + + expect(rules.groups).toHaveLength(1) + expect(rules.groups[0].agents).toEqual(['alpha', 'beta']) + expect(rules.groups[0].rules).toEqual([{ allow: false, path: '/private' }]) + }) + + test('ignores comments and blank lines', () => { + const rules = Robots.parse('# a comment\n\nUser-agent: *\nDisallow: /x # trailing\n') + expect(rules.groups[0].rules).toEqual([{ allow: false, path: '/x' }]) + }) + + test('treats an empty Disallow as no restriction', () => { + const rules = Robots.parse('User-agent: *\nDisallow:') + expect(rules.groups[0].rules).toHaveLength(0) + }) + + test('parses crawl-delay', () => { + const rules = Robots.parse('User-agent: *\nCrawl-delay: 2.5') + expect(rules.groups[0].crawlDelayMs).toBe(2500) + }) + + test('an empty file allows everything', () => { + expect(Robots.parse('').allowAll).toBe(true) + expect(Robots.parse(null).allowAll).toBe(true) + }) +}) + +describe('Robots.isPathAllowed', () => { + + test('respects a simple disallow', () => { + const rules = Robots.parse('User-agent: *\nDisallow: /private') + expect(Robots.isPathAllowed(rules, '/private/x', UA)).toBe(false) + expect(Robots.isPathAllowed(rules, '/public/x', UA)).toBe(true) + }) + + test('longest match wins, so a nested Allow overrides a broad Disallow', () => { + const rules = Robots.parse('User-agent: *\nDisallow: /a\nAllow: /a/b') + expect(Robots.isPathAllowed(rules, '/a/x', UA)).toBe(false) + expect(Robots.isPathAllowed(rules, '/a/b/c', UA)).toBe(true) + }) + + test('Allow beats Disallow at equal specificity', () => { + const rules = Robots.parse('User-agent: *\nDisallow: /p\nAllow: /p') + expect(Robots.isPathAllowed(rules, '/p', UA)).toBe(true) + }) + + test('supports the * wildcard', () => { + const rules = Robots.parse('User-agent: *\nDisallow: /*.pdf') + expect(Robots.isPathAllowed(rules, '/docs/report.pdf', UA)).toBe(false) + expect(Robots.isPathAllowed(rules, '/docs/report.html', UA)).toBe(true) + }) + + test('supports the $ anchor', () => { + const rules = Robots.parse('User-agent: *\nDisallow: /x$') + expect(Robots.isPathAllowed(rules, '/x', UA)).toBe(false) + expect(Robots.isPathAllowed(rules, '/x/y', UA)).toBe(true) + }) + + test('a named group beats the wildcard group even when listed later', () => { + const rules = Robots.parse([ + 'User-agent: *', + 'Disallow: /', + '', + `User-agent: ${UA}`, + 'Disallow:', + ].join('\n')) + + expect(Robots.isPathAllowed(rules, '/anything', UA)).toBe(true) + expect(Robots.isPathAllowed(rules, '/anything', 'SomeOtherBot')).toBe(false) + }) +}) + +describe('Robots.isAllowed over the network', () => { + + test('refuses a disallowed path', async () => { + serveRobots('User-agent: *\nDisallow: /secret') + await expect(Robots.isAllowed('https://example.com/secret/page')).resolves.toBe(false) + }) + + test('permits an allowed path', async () => { + serveRobots('User-agent: *\nDisallow: /secret') + await expect(Robots.isAllowed('https://example.com/blog/post')).resolves.toBe(true) + }) + + // Fail-open: a missing robots.txt is not a refusal. + test('allows everything when robots.txt 404s', async () => { + FetchMock.route('https://example.com/robots.txt', () => FetchMock.status(404)) + await expect(Robots.isAllowed('https://example.com/anything')).resolves.toBe(true) + }) + + test('allows everything when robots.txt cannot be reached', async () => { + FetchMock.route('https://example.com/robots.txt', FetchMock.networkError()) + await expect(Robots.isAllowed('https://example.com/anything')).resolves.toBe(true) + }) + + test('caches per origin - two queries, one network call', async () => { + serveRobots('User-agent: *\nDisallow: /secret') + + await Robots.isAllowed('https://example.com/a') + await Robots.isAllowed('https://example.com/secret/b') + + expect(FetchMock.callsTo('https://example.com/robots.txt')).toHaveLength(1) + }) + + test('keeps origins separate', async () => { + serveRobots('User-agent: *\nDisallow: /', 'https://blocked.com') + serveRobots('User-agent: *\nDisallow:', 'https://open.com') + + await expect(Robots.isAllowed('https://blocked.com/x')).resolves.toBe(false) + await expect(Robots.isAllowed('https://open.com/x')).resolves.toBe(true) + }) + + test('returns false for an unparseable url rather than throwing', async () => { + await expect(Robots.isAllowed('not a url')).resolves.toBe(false) + }) +}) + +describe('Robots.crawlDelayMs', () => { + + test('reports the declared delay', async () => { + serveRobots('User-agent: *\nCrawl-delay: 5') + await expect(Robots.crawlDelayMs('https://example.com/x')).resolves.toBe(5000) + }) + + test('is null when none is declared', async () => { + serveRobots('User-agent: *\nDisallow: /x') + await expect(Robots.crawlDelayMs('https://example.com/y')).resolves.toBeNull() + }) + + test('flags a delay beyond the cap instead of parking the job forever', async () => { + expect(Robots.exceedsCrawlDelayCap(3600 * 1000)).toBe(true) + expect(Robots.exceedsCrawlDelayCap(2000)).toBe(false) + expect(Robots.exceedsCrawlDelayCap(null)).toBe(false) + }) +}) diff --git a/server/tests/helpers/SiteScrape.test.js b/server/tests/helpers/SiteScrape.test.js new file mode 100644 index 0000000..c01c4ab --- /dev/null +++ b/server/tests/helpers/SiteScrape.test.js @@ -0,0 +1,318 @@ +require('module-alias/register') + +const fs = require('fs') +const path = require('path') +const cheerio = require('cheerio') + +const SiteScrape = require('@helpers/SiteScrape') + +const FIXTURES = path.join(__dirname, '..', 'fixtures', 'html') + +// Fixtures are frozen snapshots - see fixtures/html/README.md. No network here. +const load = (name) => cheerio.load(fs.readFileSync(path.join(FIXTURES, name), 'utf8')) + +const PAGE_URL = 'https://example.com/blog/2026/post' + +describe('getTitle', () => { + + test('prefers og:title over the title element', () => { + expect(SiteScrape.getTitle(load('og-complete.html'))).toBe('The Real Title') + }) + + test('falls back to twitter:title', () => { + expect(SiteScrape.getTitle(load('twitter-only.html'))).toBe('Twitter Card Title') + }) + + test('falls back to JSON-LD headline', () => { + expect(SiteScrape.getTitle(load('jsonld-article.html'))).toBe('JSON-LD Headline') + }) + + test('falls back to the title element and collapses whitespace', () => { + expect(SiteScrape.getTitle(load('title-only.html'))).toBe('Just A Title') + }) + + test('falls back to an h1 when there is no title element', () => { + expect(SiteScrape.getTitle(load('h1-fallback.html'))).toBe('Heading Is All We Have') + }) + + test('returns empty string for a page with nothing', () => { + expect(SiteScrape.getTitle(load('no-metadata.html'))).toBe('') + }) +}) + +describe('getDescription', () => { + + test('prefers og:description', () => { + expect(SiteScrape.getDescription(load('og-complete.html'))) + .toBe('A proper social description of the page.') + }) + + test('falls back to twitter:description', () => { + expect(SiteScrape.getDescription(load('twitter-only.html'))).toBe('Twitter card description.') + }) + + test('falls back to JSON-LD description', () => { + expect(SiteScrape.getDescription(load('jsonld-article.html'))).toBe('Described in structured data.') + }) + + test('is empty when no description exists', () => { + expect(SiteScrape.getDescription(load('title-only.html'))).toBe('') + }) +}) + +describe('getJsonLd', () => { + + test('reads a plain block', () => { + expect(SiteScrape.getJsonLd(load('jsonld-article.html')).headline).toBe('JSON-LD Headline') + }) + + test('digs the content node out of an @graph', () => { + expect(SiteScrape.getJsonLd(load('jsonld-graph.html')).headline).toBe('Graph Headline') + }) + + // Malformed JSON-LD is extremely common and must never throw. + test('survives malformed JSON without throwing', () => { + expect(() => SiteScrape.getJsonLd(load('jsonld-malformed.html'))).not.toThrow() + expect(SiteScrape.getTitle(load('jsonld-malformed.html'))).toBe('Survived Bad JSON') + }) + + test('is null when absent', () => { + expect(SiteScrape.getJsonLd(load('title-only.html'))).toBeNull() + }) +}) + +describe('getCanonicalUrl', () => { + + test('uses link rel=canonical', () => { + expect(SiteScrape.getCanonicalUrl(load('og-complete.html'), PAGE_URL)) + .toBe('https://example.com/canonical-post') + }) + + test('falls back to the requested url', () => { + expect(SiteScrape.getCanonicalUrl(load('title-only.html'), PAGE_URL)).toBe(PAGE_URL) + }) +}) + +describe('getDisplayImage', () => { + + test('prefers og:image', () => { + expect(SiteScrape.getDisplayImage(load('og-complete.html'), PAGE_URL)) + .toBe('https://cdn.example.com/hero.jpg') + }) + + test('falls back to twitter:image', () => { + expect(SiteScrape.getDisplayImage(load('twitter-only.html'), PAGE_URL)) + .toBe('https://cdn.example.com/twitter.png') + }) + + test('falls back to the JSON-LD image, taking the first of an array', () => { + expect(SiteScrape.getDisplayImage(load('jsonld-article.html'), PAGE_URL)) + .toBe('https://cdn.example.com/ld-primary.jpg') + }) + + test('reads an object-form JSON-LD image', () => { + expect(SiteScrape.getDisplayImage(load('jsonld-graph.html'), PAGE_URL)) + .toBe('https://cdn.example.com/graph.png') + }) + + // Resolution against the canonical base, not string concatenation. + test('resolves a relative path against the canonical url', () => { + expect(SiteScrape.getDisplayImage(load('relative-images.html'), PAGE_URL)) + .toBe('https://example.com/blog/images/hero.png') + }) + + test('resolves a root-relative icon', () => { + expect(SiteScrape.getDisplayImage(load('root-relative-icon.html'), PAGE_URL)) + .toBe('https://example.com/favicon.ico') + }) + + test('resolves a protocol-relative url', () => { + expect(SiteScrape.getDisplayImage(load('protocol-relative-image.html'), PAGE_URL)) + .toBe('https://cdn.example.com/proto.jpg') + }) + + // The old substring filter dropped all of these. + test('accepts .webp', () => { + expect(SiteScrape.getDisplayImage(load('webp-og-image.html'), PAGE_URL)) + .toBe('https://cdn.example.com/hero.webp') + }) + + test('accepts an extensionless og:image endpoint', () => { + expect(SiteScrape.getDisplayImage(load('extensionless-og-image.html'), PAGE_URL)) + .toBe('https://example.com/api/og?title=Hello&theme=dark') + }) + + test('rejects svg while allowSvgThumbnails is off', () => { + expect(SiteScrape.getDisplayImage(load('svg-only-image.html'), PAGE_URL)).toBe('') + }) + + test('picks the largest srcset candidate', () => { + expect(SiteScrape.getDisplayImage(load('srcset.html'), PAGE_URL)) + .toBe('https://cdn.example.com/large.jpg') + }) + + test('prefers the largest inline image by declared area', () => { + expect(SiteScrape.getDisplayImage(load('sized-images.html'), PAGE_URL)) + .toBe('https://cdn.example.com/big.jpg') + }) + + test('skips tracking pixels and spacers', () => { + expect(SiteScrape.getDisplayImage(load('tracking-pixels.html'), PAGE_URL)) + .toBe('https://cdn.example.com/real-photo.jpg') + }) + + // Regression: the old code read element.attribs.src directly and threw. + test('does not throw on img tags with no src, and finds a data-src', () => { + const $ = load('imgs-missing-src.html') + expect(() => SiteScrape.getDisplayImage($, PAGE_URL)).not.toThrow() + expect(SiteScrape.getDisplayImage($, PAGE_URL)).toBe('https://cdn.example.com/lazy.jpg') + }) + + test('prefers a large apple-touch-icon over a 16x16 favicon', () => { + expect(SiteScrape.getDisplayImage(load('apple-touch-icon.html'), PAGE_URL)) + .toBe('https://example.com/touch-180.png') + }) + + test('returns empty string when there is no usable image', () => { + expect(SiteScrape.getDisplayImage(load('no-metadata.html'), PAGE_URL)).toBe('') + }) + + test('survives malformed markup', () => { + expect(() => SiteScrape.getDisplayImage(load('malformed-unclosed.html'), PAGE_URL)).not.toThrow() + }) +}) + +describe('getImageCandidates', () => { + + test('returns an ordered, deduplicated list', () => { + const candidates = SiteScrape.getImageCandidates(load('tracking-pixels.html'), PAGE_URL) + expect(candidates).toEqual(['https://cdn.example.com/real-photo.jpg']) + }) + + test('is empty rather than null for a bare page', () => { + expect(SiteScrape.getImageCandidates(load('no-metadata.html'), PAGE_URL)).toEqual([]) + }) +}) + +describe('getKeywords', () => { + + test('extracts page terms and ignores nav, script and footer chrome', () => { + const keywords = SiteScrape.getKeywords(load('keyword-content.html')) + + expect(keywords).toMatch(/^Keywords: /) + expect(keywords).toMatch(/beekeeping|beehive|honey/) + expect(keywords).not.toMatch(/navigation/) + expect(keywords).not.toMatch(/tracking/) + expect(keywords).not.toMatch(/footer/) + }) + + test('returns empty string when there is nothing to count', () => { + expect(SiteScrape.getKeywords(load('no-metadata.html'))).toBe('') + }) + + test('does not log page content', () => { + // The old implementation dumped up to 6000 chars of every page. + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}) + SiteScrape.getKeywords(load('keyword-content.html')) + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) +}) + +describe('getHostName', () => { + + test('returns the https origin', () => { + expect(SiteScrape.getHostName('http://www.google.com/search?q=x')).toBe('https://www.google.com') + }) + + test('returns empty string for a bad url instead of throwing', () => { + expect(() => SiteScrape.getHostName('nonsense')).not.toThrow() + expect(SiteScrape.getHostName('nonsense')).toBe('') + }) +}) + +describe('buildSearchText', () => { + + test('joins the parts that exist', () => { + const text = SiteScrape.buildSearchText({ + title: 'T', description: 'D', siteName: 'S', keywords: 'Keywords: a, b', + }) + expect(text).toBe('T\nD\nS\nKeywords: a, b') + }) + + test('skips empty parts', () => { + expect(SiteScrape.buildSearchText({ title: 'T', description: '', siteName: '', keywords: '' })).toBe('T') + }) + + test('caps the length so it cannot bloat attachment.text', () => { + const text = SiteScrape.buildSearchText({ title: 'x'.repeat(50000) }) + expect(text.length).toBeLessThanOrEqual(8000) + }) + + // Sentinel strings used to land in attachment.text and pollute FULLTEXT. + test('never produces the old failure sentinels', () => { + const text = SiteScrape.buildSearchText({ title: '', description: '', siteName: '', keywords: '' }) + expect(text).toBe('') + expect(text).not.toContain('No site text') + expect(text).not.toContain('Request Timeout') + }) +}) + +describe('parse', () => { + + test('returns everything a link preview needs in one call', () => { + const result = SiteScrape.parse(load('og-complete.html'), PAGE_URL) + + expect(result).toMatchObject({ + title: 'The Real Title', + description: 'A proper social description of the page.', + siteName: 'Example Journal', + canonicalUrl: 'https://example.com/canonical-post', + imageUrl: 'https://cdn.example.com/hero.jpg', + hostname: 'https://example.com', + }) + expect(result.searchText).toContain('The Real Title') + expect(result.searchText).toContain('A proper social description') + }) + + test('degrades to empty fields rather than throwing on a bare page', () => { + const result = SiteScrape.parse(load('no-metadata.html'), PAGE_URL) + + expect(result.title).toBe('') + expect(result.imageUrl).toBe('') + expect(result.searchText).toBe('example.com') + }) +}) + +// +// getCleanUrls is deliberately unchanged - it decides which URLs are detected in +// note text and what string is stored in attachment.url. These tests pin the +// CURRENT behaviour so the rewrite around it cannot drift link detection. +// +describe('getCleanUrls (pinned, must not change)', () => { + + test('finds bare and full urls', () => { + expect(SiteScrape.getCleanUrls('see https://example.com/a and www.foo.com here')) + .toEqual(['https://example.com/a', 'https://www.foo.com']) + }) + + test('still forces http to https', () => { + expect(SiteScrape.getCleanUrls('http://example.com/x')).toEqual(['https://example.com/x']) + }) + + test('still prepends https to a bare www host', () => { + expect(SiteScrape.getCleanUrls('www.example.com')).toEqual(['https://www.example.com']) + }) + + test('deduplicates', () => { + expect(SiteScrape.getCleanUrls('https://a.com https://a.com')).toEqual(['https://a.com']) + }) + + test('returns an empty array when there are no urls', () => { + expect(SiteScrape.getCleanUrls('just some words')).toEqual([]) + }) + + test('returns an empty array for empty text', () => { + expect(SiteScrape.getCleanUrls('')).toEqual([]) + }) +}) diff --git a/server/tests/helpers/UrlSafety.test.js b/server/tests/helpers/UrlSafety.test.js new file mode 100644 index 0000000..dbdcc6f --- /dev/null +++ b/server/tests/helpers/UrlSafety.test.js @@ -0,0 +1,189 @@ +require('module-alias/register') + +const UrlSafety = require('@helpers/UrlSafety') + +describe('UrlSafety.isPrivateIp', () => { + + const privateAddresses = [ + '0.0.0.0', '10.0.0.1', '10.255.255.254', '127.0.0.1', '127.1.1.1', + '169.254.1.1', '169.254.169.254', // cloud metadata endpoint + '172.16.0.1', '172.31.255.254', '192.168.1.1', '192.0.0.1', '192.0.2.5', + '198.18.0.1', '100.64.0.1', '100.127.255.254', '224.0.0.1', '255.255.255.255', + '::1', '::', 'fc00::1', 'fd12:3456::1', 'fe80::1', 'ff02::1', + '::ffff:127.0.0.1', '::ffff:10.0.0.1', '64:ff9b::127.0.0.1', + ] + + privateAddresses.forEach(address => { + test(`blocks ${address}`, () => { + expect(UrlSafety.isPrivateIp(address)).toBe(true) + }) + }) + + const publicAddresses = ['1.1.1.1', '8.8.8.8', '93.184.216.34', '172.32.0.1', '2606:4700::1111'] + + publicAddresses.forEach(address => { + test(`allows ${address}`, () => { + expect(UrlSafety.isPrivateIp(address)).toBe(false) + }) + }) + + test('treats a non-IP string as unsafe', () => { + expect(UrlSafety.isPrivateIp('not-an-ip')).toBe(true) + expect(UrlSafety.isPrivateIp('')).toBe(true) + }) +}) + +describe('UrlSafety.parse', () => { + + test('rejects non-http schemes', () => { + expect(UrlSafety.parse('file:///etc/passwd')).toBeNull() + expect(UrlSafety.parse('ftp://example.com/x')).toBeNull() + expect(UrlSafety.parse('javascript:alert(1)')).toBeNull() + expect(UrlSafety.parse('data:text/html,

x

')).toBeNull() + }) + + test('rejects garbage and empty input', () => { + expect(UrlSafety.parse('')).toBeNull() + expect(UrlSafety.parse(null)).toBeNull() + expect(UrlSafety.parse('not a url at all')).toBeNull() + }) + + test('rejects URLs longer than the attachment.url column', () => { + const long = 'https://example.com/' + 'a'.repeat(1200) + expect(UrlSafety.parse(long)).toBeNull() + }) + + test('accepts ordinary http and https', () => { + expect(UrlSafety.parse('https://example.com/a').href).toBe('https://example.com/a') + expect(UrlSafety.parse('http://example.com/a').href).toBe('http://example.com/a') + }) +}) + +describe('UrlSafety.normalizeForKey / urlKey', () => { + + test('strips tracking params so the cache key is stable', () => { + const bare = UrlSafety.urlKey('https://example.com/post') + const noisy = UrlSafety.urlKey('https://example.com/post?utm_source=x&utm_medium=y&fbclid=z') + expect(noisy).toBe(bare) + }) + + test('keeps meaningful query params', () => { + const a = UrlSafety.urlKey('https://example.com/search?q=cats') + const b = UrlSafety.urlKey('https://example.com/search?q=dogs') + expect(a).not.toBe(b) + }) + + test('is insensitive to param order, host case, fragment and trailing host dot', () => { + const canonical = UrlSafety.urlKey('https://example.com/p?a=1&b=2') + expect(UrlSafety.urlKey('https://example.com/p?b=2&a=1')).toBe(canonical) + expect(UrlSafety.urlKey('https://EXAMPLE.com/p?a=1&b=2')).toBe(canonical) + expect(UrlSafety.urlKey('https://example.com/p?a=1&b=2#section')).toBe(canonical) + expect(UrlSafety.urlKey('https://example.com./p?a=1&b=2')).toBe(canonical) + }) + + test('distinguishes http from https', () => { + expect(UrlSafety.urlKey('http://example.com/')).not.toBe(UrlSafety.urlKey('https://example.com/')) + }) + + test('returns a 64-char hex digest', () => { + expect(UrlSafety.urlKey('https://example.com/')).toMatch(/^[0-9a-f]{64}$/) + }) + + test('returns null for an unusable URL', () => { + expect(UrlSafety.urlKey('javascript:alert(1)')).toBeNull() + }) +}) + +describe('UrlSafety.sameResource', () => { + + // This is what stops scanTextForWebsites deleting and re-scraping a row + // every time a note is saved. + test('treats http/https, www. and trailing slash as the same resource', () => { + expect(UrlSafety.sameResource('http://foo.com/a', 'https://foo.com/a')).toBe(true) + expect(UrlSafety.sameResource('https://www.foo.com/a', 'https://foo.com/a')).toBe(true) + expect(UrlSafety.sameResource('https://foo.com/a/', 'https://foo.com/a')).toBe(true) + expect(UrlSafety.sameResource('https://foo.com', 'https://foo.com/')).toBe(true) + }) + + test('does not conflate different paths, hosts or queries', () => { + expect(UrlSafety.sameResource('https://foo.com/a', 'https://foo.com/b')).toBe(false) + expect(UrlSafety.sameResource('https://foo.com/a', 'https://bar.com/a')).toBe(false) + expect(UrlSafety.sameResource('https://foo.com/a?x=1', 'https://foo.com/a?x=2')).toBe(false) + }) + + test('is false when either side is unusable', () => { + expect(UrlSafety.sameResource('https://foo.com/a', 'javascript:x')).toBe(false) + }) +}) + +describe('UrlSafety.assertFetchable', () => { + + const expectRejection = async (url, reason) => { + await expect(UrlSafety.assertFetchable(url)).rejects.toMatchObject({ name: 'SafetyError', reason }) + } + + test('refuses non-http schemes', async () => { + await expectRejection('file:///etc/passwd', 'bad_url') + }) + + test('refuses embedded credentials', async () => { + await expectRejection('https://user:pass@example.com/', 'credentials_in_url') + }) + + test('refuses localhost and internal suffixes', async () => { + await expectRejection('http://localhost:3000/', 'blocked_host') + await expectRejection('http://db.internal/', 'blocked_host') + await expectRejection('http://thing.local/', 'blocked_host') + await expectRejection('http://box.lan/', 'blocked_host') + }) + + test('refuses literal private addresses without touching DNS', async () => { + await expectRejection('http://127.0.0.1:3000/', 'private_ip') + await expectRejection('http://169.254.169.254/latest/meta-data/', 'private_ip') + await expectRejection('http://10.1.2.3/', 'private_ip') + await expectRejection('http://[::1]:8080/', 'private_ip') + }) + + test('allows a literal public address', async () => { + await expect(UrlSafety.assertFetchable('http://1.1.1.1/')).resolves.toEqual(['1.1.1.1']) + }) + + test('refuses a hostname that resolves to a private address', async () => { + const dns = require('dns').promises + const spy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '192.168.0.5', family: 4 }]) + + await expectRejection('https://rebind.example.com/', 'private_ip') + + spy.mockRestore() + }) + + test('refuses when ANY address in a round-robin record is private', async () => { + const dns = require('dns').promises + const spy = jest.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + { address: '127.0.0.1', family: 4 }, + ]) + + await expectRejection('https://mixed.example.com/', 'private_ip') + + spy.mockRestore() + }) + + test('allows a hostname that resolves entirely to public addresses', async () => { + const dns = require('dns').promises + const spy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) + + await expect(UrlSafety.assertFetchable('https://example.com/')).resolves.toEqual(['93.184.216.34']) + + spy.mockRestore() + }) + + test('surfaces a DNS failure as dns_failed', async () => { + const dns = require('dns').promises + const spy = jest.spyOn(dns, 'lookup').mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOTFOUND' })) + + await expectRejection('https://nonexistent.example/', 'dns_failed') + + spy.mockRestore() + }) +}) diff --git a/server/tests/models/ScrapeQueue.test.js b/server/tests/models/ScrapeQueue.test.js new file mode 100644 index 0000000..7ebc7c2 --- /dev/null +++ b/server/tests/models/ScrapeQueue.test.js @@ -0,0 +1,354 @@ +require('module-alias/register') + +const db = require('@config/database') +const User = require('@models/User') +const Attachment = require('@models/Attachment') +const ScrapeCache = require('@models/ScrapeCache') +const ScrapeQueue = require('@workers/ScrapeQueue') +const Schema = require('@helpers/Schema') +const Robots = require('@helpers/Robots') +const HttpFetch = require('@helpers/HttpFetch') +const UrlSafety = require('@helpers/UrlSafety') +const FetchMock = require('../support/fetchMock') + +const dns = require('dns').promises + +const testUserName = 'jestTestUserScrapeQueue' +const password = 'Beans19934!!!' + +let userId = null +let dnsSpy = null + +const page = (title, description) => FetchMock.html(` + + ${title} + +

body

+`) + +// Clear cache rows for a URL so each test starts from a real miss. +const clearCache = (url) => db.promise() + .query('DELETE FROM scrape_cache WHERE url_hash = ?', [UrlSafety.urlKey(url)]) + +const attachmentRow = (id) => db.promise() + .query('SELECT * FROM attachment WHERE id = ?', [id]) + .then(([rows]) => rows[0] || null) + +beforeAll(async () => { + await Schema.ensureScrapeSchema() + + try { + const existing = await User.getByUserName(testUserName) + if (existing && existing.id) { await User.deleteUser(existing.id, password) } + } catch (error) { /* first run */ } + + const registered = await User.register(testUserName, password) + userId = registered.userId +}) + +beforeEach(() => { + FetchMock.install() + Robots.__resetCache() + dnsSpy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) + HttpFetch.__setSleep(() => Promise.resolve()) + + // Exercise the real code path with politeness spacing and robots disabled. + ScrapeQueue.configure({ perHostMinSpacingMs: 0, perHostMaxSpacingMs: 0, checkRobots: false }) +}) + +afterEach(() => { + FetchMock.restore() + dnsSpy.mockRestore() + HttpFetch.__resetSleep() + ScrapeQueue.__reset() +}) + +afterAll(async () => { + await db.promise().query('DELETE FROM attachment WHERE user_id = ?', [userId]) + db.end() +}) + +describe('fetch and persist', () => { + + test('writes parsed metadata onto the attachment row as plaintext', async () => { + const url = 'https://example.com/post-one' + await clearCache(url) + FetchMock.route(url, () => page('Post One', 'the first post')) + + const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url }) + + const row = await attachmentRow(attachmentId) + + expect(row.text).toContain('Post One') + expect(row.text).toContain('the first post') + expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE) + // Must stay unencrypted - the FULLTEXT index depends on it. + expect(row.text).not.toMatch(/^[0-9a-f]{64}/) + }) + + test('keeps the url the user wrote rather than the canonical url', async () => { + const url = 'https://example.com/original' + await clearCache(url) + + FetchMock.route(url, () => FetchMock.html(` + Canonical Test + + x + `)) + + const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url }) + + const row = await attachmentRow(attachmentId) + expect(row.url).toBe(url) + + // The canonical URL is recorded in the cache, not on the attachment. + const cached = await ScrapeCache.get(url) + expect(cached.canonical_url).toBe('https://example.com/different-canonical') + }) +}) + +describe('caching', () => { + + test('a second request for the same url makes zero network calls', async () => { + const url = 'https://example.com/cached' + await clearCache(url) + FetchMock.route(url, () => page('Cached Page')) + + const firstId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: firstId, url }) + + const callsAfterFirst = FetchMock.callCount() + expect(callsAfterFirst).toBeGreaterThan(0) + + const secondId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: secondId, url }) + + expect(FetchMock.callCount()).toBe(callsAfterFirst) + + // The cached row still populates the second attachment. + const row = await attachmentRow(secondId) + expect(row.text).toContain('Cached Page') + expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE) + }) + + test('two notes sharing a url coalesce into one fetch', async () => { + const url = 'https://example.com/shared' + await clearCache(url) + FetchMock.route(url, () => page('Shared Page')) + + const firstId = await Attachment.createPendingUrlAttachment(userId, null, url) + const secondId = await Attachment.createPendingUrlAttachment(userId, null, url) + + // Enqueued together, so they overlap in flight. + await ScrapeQueue.enqueueUrls([ + { userId, noteId: null, attachmentId: firstId, url }, + { userId, noteId: null, attachmentId: secondId, url }, + ]) + + expect(FetchMock.callsTo(url)).toHaveLength(1) + + const rows = await Promise.all([attachmentRow(firstId), attachmentRow(secondId)]) + rows.forEach(row => { + expect(row.text).toContain('Shared Page') + expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE) + }) + }) + + test('a stale entry revalidates and a 304 reuses the stored text', async () => { + const url = 'https://example.com/revalidate' + await clearCache(url) + + FetchMock.routeSequence(url, [ + FetchMock.html('Original Titlex', { + headers: { etag: 'W/"v1"' }, + }), + FetchMock.status(304), + ]) + + const firstId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: firstId, url }) + + // Force staleness so the next pass revalidates instead of hitting cache. + await db.promise().query('UPDATE scrape_cache SET expires_at = ? WHERE url_hash = ?', + [Math.round(Date.now() / 1000) - 10, UrlSafety.urlKey(url)]) + + const secondId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: secondId, url }) + + // Conditional request was sent. + const conditional = FetchMock.callsTo(url).find(call => call.headers['If-None-Match']) + expect(conditional).toBeTruthy() + + const row = await attachmentRow(secondId) + expect(row.text).toContain('Original Title') + }) +}) + +describe('failure handling', () => { + + test('a 500 leaves the row failed and never writes a sentinel string', async () => { + const url = 'https://example.com/broken' + await clearCache(url) + FetchMock.route(url, () => FetchMock.status(500)) + + const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url }) + + const row = await attachmentRow(attachmentId) + + expect(row.scrape_state).toBe(ScrapeQueue.STATE.FAILED) + // The old code stored these in attachment.text and fed them to FULLTEXT. + expect(row.text).not.toContain('No site text') + expect(row.text).not.toContain('Request Timeout') + }) + + test('a 404 removes the shell row rather than orphaning it', async () => { + const url = 'https://example.com/gone' + await clearCache(url) + FetchMock.route(url, () => FetchMock.status(404)) + + const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url }) + + expect(await attachmentRow(attachmentId)).toBeNull() + }) + + test('a blocked url removes the shell row and makes no request', async () => { + const url = 'http://127.0.0.1:9999/internal' + FetchMock.route(/.*/, () => page('should never load')) + + const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url }) + + expect(await attachmentRow(attachmentId)).toBeNull() + expect(FetchMock.callCount()).toBe(0) + }) + + test('one failing url does not strand the rest of a batch', async () => { + const good = 'https://example.com/good-one' + const bad = 'https://example.com/bad-one' + await clearCache(good) + await clearCache(bad) + + FetchMock.route(good, () => page('Good One')) + FetchMock.route(bad, () => FetchMock.status(500)) + + const goodId = await Attachment.createPendingUrlAttachment(userId, null, good) + const badId = await Attachment.createPendingUrlAttachment(userId, null, bad) + + // The old counter-based implementation never settled in this case. + const texts = await ScrapeQueue.enqueueUrls([ + { userId, noteId: null, attachmentId: badId, url: bad }, + { userId, noteId: null, attachmentId: goodId, url: good }, + ]) + + expect(texts).toHaveLength(2) + expect((await attachmentRow(goodId)).text).toContain('Good One') + }) + + test('a failed url is not refetched while inside its backoff window', async () => { + const url = 'https://example.com/flaky' + await clearCache(url) + FetchMock.route(url, () => FetchMock.status(500)) + + const firstId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: firstId, url }) + + const callsAfterFirst = FetchMock.callCount() + + const secondId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: secondId, url }) + + expect(FetchMock.callCount()).toBe(callsAfterFirst) + }) +}) + +describe('robots.txt', () => { + + test('refuses a disallowed url and records it', async () => { + const url = 'https://example.com/no-bots/page' + await clearCache(url) + + ScrapeQueue.configure({ perHostMinSpacingMs: 0, perHostMaxSpacingMs: 0 }) // robots ON + + FetchMock.route('https://example.com/robots.txt', () => new Response( + 'User-agent: *\nDisallow: /no-bots', { status: 200, headers: { 'content-type': 'text/plain' } } + )) + FetchMock.route(url, () => page('Should Not Be Fetched')) + + const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url }) + + expect(FetchMock.callsTo(url)).toHaveLength(0) + + const cached = await ScrapeCache.get(url) + expect(cached.fetch_state).toBe('blocked') + }) + + test('proceeds when robots.txt allows the path', async () => { + const url = 'https://example.com/allowed/page' + await clearCache(url) + + ScrapeQueue.configure({ perHostMinSpacingMs: 0, perHostMaxSpacingMs: 0 }) + + FetchMock.route('https://example.com/robots.txt', () => new Response( + 'User-agent: *\nDisallow: /private', { status: 200, headers: { 'content-type': 'text/plain' } } + )) + FetchMock.route(url, () => page('Allowed Page')) + + const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url) + await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url }) + + expect((await attachmentRow(attachmentId)).text).toContain('Allowed Page') + }) +}) + +describe('crash recovery', () => { + + test('re-enqueues rows left pending by a restart', async () => { + const url = 'https://example.com/recovered' + await clearCache(url) + FetchMock.route(url, () => page('Recovered Page')) + + // Simulate a row that was enqueued but never processed. + const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url) + + const recovery = await ScrapeQueue.recoverPending() + expect(recovery.requeued).toBeGreaterThan(0) + + await ScrapeQueue.drain() + + const row = await attachmentRow(attachmentId) + expect(row.text).toContain('Recovered Page') + expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE) + }) + + test('resets rows stuck mid-fetch so they are retried', async () => { + const url = 'https://example.com/stuck' + await clearCache(url) + FetchMock.route(url, () => page('Unstuck Page')) + + const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url) + // Pretend the process died while this one was in flight. + await db.promise().query('UPDATE attachment SET scrape_state = ? WHERE id = ?', + [ScrapeQueue.STATE.FETCHING, attachmentId]) + + await ScrapeQueue.recoverPending() + await ScrapeQueue.drain() + + expect((await attachmentRow(attachmentId)).scrape_state).toBe(ScrapeQueue.STATE.DONE) + }) + + test('does not sweep up long-settled rows', async () => { + // Rows default to DONE, which is what keeps the pre-existing ~1400 + // attachment rows out of the recovery queue on first boot. + const [rows] = await db.promise().query( + 'SELECT COUNT(*) AS n FROM attachment WHERE scrape_state = ? AND user_id = ?', + [ScrapeQueue.STATE.PENDING, userId] + ) + const recovery = await ScrapeQueue.recoverPending() + expect(recovery.requeued).toBeLessThanOrEqual(rows[0].n + 1) + }) +}) diff --git a/server/tests/support/fetchMock.js b/server/tests/support/fetchMock.js new file mode 100644 index 0000000..e8c9b84 --- /dev/null +++ b/server/tests/support/fetchMock.js @@ -0,0 +1,127 @@ +// +// Test harness for the outbound HTTP layer. +// +// Responses are built from the REAL Response/ReadableStream globals rather than +// hand-rolled stubs, so the streaming size cap, the charset decoder and the +// body-cancel paths in HttpFetch are genuinely exercised instead of being +// stubbed around. +// + +let FetchMock = module.exports = {} + +let originalFetch = null +let routes = [] +let callLog = [] + +FetchMock.install = () => { + originalFetch = global.fetch + routes = [] + callLog = [] + + global.fetch = jest.fn(async (url, options = {}) => { + const href = typeof url === 'string' ? url : url.href + callLog.push({ url: href, headers: options.headers || {}, options }) + + const route = routes.find(entry => entry.match(href)) + if (!route) { + throw Object.assign(new Error(`No route for ${href}`), { code: 'ENOTFOUND' }) + } + + // A hanging route must still honour the abort signal, or the test would + // wait forever instead of proving the AbortController fires. + if (options.signal) { + return await Promise.race([ + Promise.resolve(route.responder(href, options)), + abortPromise(options.signal), + ]) + } + + return route.responder(href, options) + }) +} + +const abortPromise = (signal) => new Promise((resolve, reject) => { + if (signal.aborted) { + return reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })) + } + signal.addEventListener('abort', () => { + reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })) + }, { once: true }) +}) + +FetchMock.restore = () => { + if (originalFetch) { global.fetch = originalFetch } else { delete global.fetch } + originalFetch = null + routes = [] + callLog = [] +} + +// +// route('https://example.com/', responder) or route(/example/, responder). +// Responders are called with (url, options) and may be functions returning a +// Response, so a route can answer differently on each call. +// +FetchMock.route = (pattern, responder) => { + const match = pattern instanceof RegExp + ? (href) => pattern.test(href) + : (href) => href === pattern + + routes.push({ match, responder: typeof responder === 'function' ? responder : () => responder }) +} + +// Answer with a counter so "retried exactly N times" is assertable. +FetchMock.routeSequence = (pattern, responses) => { + let index = 0 + FetchMock.route(pattern, () => { + const response = responses[Math.min(index, responses.length - 1)] + index++ + return typeof response === 'function' ? response() : response + }) +} + +FetchMock.html = (body, { status = 200, headers = {}, contentType = 'text/html; charset=utf-8' } = {}) => { + return new Response(body, { + status, + headers: { 'content-type': contentType, ...headers }, + }) +} + +// Raw bytes, for the charset tests where the encoding is the point. +FetchMock.buffer = (buffer, { status = 200, contentType = 'text/html', headers = {} } = {}) => { + return new Response(buffer, { + status, + headers: { 'content-type': contentType, ...headers }, + }) +} + +FetchMock.status = (status, { headers = {}, body = '' } = {}) => { + return new Response(body || null, { status, headers }) +} + +FetchMock.redirect = (location, status = 302) => { + return new Response(null, { status, headers: { location } }) +} + +// +// A body that never ends. Proves the size cap aborts mid-stream rather than +// buffering until the process dies. +// +FetchMock.oversized = (contentType = 'text/html') => { + const chunk = Buffer.alloc(64 * 1024, 0x61) + const stream = new ReadableStream({ + pull(controller) { controller.enqueue(new Uint8Array(chunk)) }, + }) + return new Response(stream, { status: 200, headers: { 'content-type': contentType } }) +} + +// Never settles - the AbortController is the only way out. +FetchMock.hang = () => new Promise(() => {}) + +FetchMock.networkError = (code = 'ECONNRESET') => () => { + throw Object.assign(new Error(`socket ${code}`), { code }) +} + +FetchMock.calls = () => callLog.slice() +FetchMock.callsTo = (pattern) => callLog.filter(entry => + pattern instanceof RegExp ? pattern.test(entry.url) : entry.url === pattern) +FetchMock.callCount = () => callLog.length diff --git a/server/workers/ScrapeQueue.js b/server/workers/ScrapeQueue.js new file mode 100644 index 0000000..3b13adc --- /dev/null +++ b/server/workers/ScrapeQueue.js @@ -0,0 +1,466 @@ +// +// The scrape worker. +// +// Owns the whole lifecycle for one URL: cache check -> robots -> polite fetch -> +// parse -> thumbnail -> persist -> notify. Saving a note enqueues URLs and +// returns immediately; nothing in the request path waits on the network. +// +// Structural fixes over the old implementation: +// - Promise.allSettled semantics instead of a completion counter, so a single +// rejection can no longer strand a batch forever. +// - Every fetch goes through HostQueue, so there is no unbounded fan-out. +// - Jobs for the same URL coalesce into one fetch, fanned out to every +// attachment row that wanted it. +// - Pending work is recoverable after a restart from attachment.scrape_state. +// + +const config = require('@config/scrape') +const db = require('@config/database') +const UrlSafety = require('@helpers/UrlSafety') +const HttpFetch = require('@helpers/HttpFetch') +const HostQueue = require('@helpers/HostQueue') +const Robots = require('@helpers/Robots') +const Realtime = require('@helpers/Realtime') +const SiteScrape = require('@helpers/SiteScrape') +const ScrapeCache = require('@models/ScrapeCache') + +const cheerio = require('cheerio') + +let ScrapeQueue = module.exports = {} + +// attachment.scrape_state +const STATE = ScrapeQueue.STATE = { PENDING: 0, FETCHING: 1, DONE: 2, FAILED: 3 } + +let settings = { ...config } +let queue = HostQueue.shared +let started = false + +// urlKey -> { promise, jobs: [] } so concurrent requests for one URL share a fetch. +const inFlight = new Map() +// userId -> pending job count, for backpressure. +const perUser = new Map() +// userId -> debounce timer, so a 10-link note triggers one refresh not ten. +const emitTimers = new Map() +// +// Every job promise currently outstanding. Needed because callers like +// recoverPending() and scanTextForWebsites() deliberately do not await their +// batches - without this, drain() could return while fire-and-forget work was +// still sitting in front of its first await. +// +const outstanding = new Set() + +const EMIT_DEBOUNCE_MS = 1500 + +const now = () => Math.round(Date.now() / 1000) + +// +// Test seam. Lets a test run with zero politeness spacing and no robots lookups +// while still exercising the real code path. +// +ScrapeQueue.configure = (overrides = {}) => { + settings = { ...config, ...overrides } + + queue = new HostQueue.Queue({ + globalConcurrency: settings.maxConcurrentFetches, + perHostConcurrency: settings.perHostConcurrency, + minSpacingMs: settings.perHostMinSpacingMs, + maxSpacingMs: settings.perHostMaxSpacingMs, + }) + + return settings +} + +ScrapeQueue.__state = () => ({ + inFlight: inFlight.size, + perUser: new Map(perUser), + queue: queue.stats(), + started, +}) + +ScrapeQueue.__reset = () => { + inFlight.clear() + outstanding.clear() + perUser.clear() + emitTimers.forEach(timer => clearTimeout(timer)) + emitTimers.clear() + settings = { ...config } + queue = HostQueue.shared + started = false +} + +ScrapeQueue.start = () => { + started = true + return ScrapeQueue +} + +ScrapeQueue.stop = () => { + started = false + queue.stop() + emitTimers.forEach(timer => clearTimeout(timer)) + emitTimers.clear() +} + +// +// Resolves once every queued and in-flight job has settled. +// +// Loops because jobs enqueue follow-up work: a page fetch spawns a thumbnail +// fetch, and a recovery batch is registered asynchronously. +// +ScrapeQueue.drain = async () => { + for (let pass = 0; pass < 20; pass++) { + await Promise.allSettled([...outstanding]) + await queue.drain() + await Promise.allSettled([...inFlight.values()].map(entry => entry.promise)) + + if (!outstanding.size && !inFlight.size && !queue.size() && !queue.inflight()) { + return + } + } +} + +ScrapeQueue.size = () => queue.size() + inFlight.size + outstanding.size + +// +// Debounced notification. The client already refetches on these events, so one +// per user per window is enough - the old code emitted once per URL. +// +const notifyUser = (userId) => { + if (emitTimers.has(userId)) { return } + + const timer = setTimeout(() => { + emitTimers.delete(userId) + Realtime.emitToUser(userId, 'update_counts') + Realtime.emitToUser(userId, 'update_note_attachments') + }, EMIT_DEBOUNCE_MS) + + if (timer.unref) { timer.unref() } + emitTimers.set(userId, timer) +} + +// Tests want the emit without waiting out the debounce. +ScrapeQueue.__flushNotifications = () => { + for (const [userId, timer] of [...emitTimers.entries()]) { + clearTimeout(timer) + emitTimers.delete(userId) + Realtime.emitToUser(userId, 'update_counts') + Realtime.emitToUser(userId, 'update_note_attachments') + } +} + +const setAttachmentState = async (attachmentId, state) => { + if (!attachmentId) { return } + await db.promise().query( + 'UPDATE attachment SET scrape_state = ?, scrape_attempts = scrape_attempts + 1 WHERE id = ?', + [state, attachmentId] + ) +} + +// +// Write a successful result onto an attachment row. +// +// attachment.text stays PLAINTEXT - the FULLTEXT index depends on it. +// +const applyResultToAttachment = async (attachmentId, fields) => { + if (!attachmentId) { return } + + await db.promise().query( + `UPDATE attachment + SET text = ?, url = ?, last_indexed = ?, file_location = ?, scrape_state = ?, + scrape_attempts = scrape_attempts + 1 + WHERE id = ?`, + [fields.text, fields.url, now(), fields.fileLocation, STATE.DONE, attachmentId] + ) +} + +// +// A failed scrape must not leave a sentinel string in the search index. The old +// code stored 'No site text' / 'Request Timeout' in attachment.text, which then +// got concatenated into the note's searchable content. +// +const applyFailureToAttachment = async (attachmentId, error) => { + if (!attachmentId) { return } + + const kind = (error && error.kind) || 'unknown' + + // A blocked or permanently-gone URL has no preview to show, ever. Drop the + // shell row rather than leaving an orphan showing the bare URL. + const terminal = kind === 'blocked' || kind === 'robots_disallowed' || + (kind === 'http' && (error.status === 404 || error.status === 410)) + + if (terminal) { + await db.promise().query('DELETE FROM attachment WHERE id = ? AND scrape_state IN (?, ?)', + [attachmentId, STATE.PENDING, STATE.FETCHING]) + return + } + + await setAttachmentState(attachmentId, STATE.FAILED) +} + +// +// Fetch and store the preview image. Never fatal: a page with no usable +// thumbnail is still a perfectly good link preview. +// +const fetchThumbnail = async (imageUrl, pageUrl) => { + if (!imageUrl) { return null } + + // Lazy require: Attachment requires this module, so a top-level require + // would be a cycle. + const Attachment = require('@models/Attachment') + + try { + return await queue.schedule(hostKeyFor(imageUrl), () => + Attachment.downloadThumbnail(imageUrl, pageUrl)) + } catch (error) { + console.log('[scrape] thumbnail failed', imageUrl, error.kind || error.message) + return null + } +} + +const hostKeyFor = (url) => { + try { return UrlSafety.registrableHost(new URL(url).hostname) } + catch (error) { return '(none)' } +} + +// +// Fetch one URL and write it to the cache. Returns the cache row fields. +// +const performFetch = async (url) => { + const cached = await ScrapeCache.get(url) + + // Fresh and usable - zero network I/O. + if (ScrapeCache.isFresh(cached) && ScrapeCache.isUsable(cached)) { + return { fromCache: true, fields: ScrapeCache.toAttachmentFields(cached) } + } + + // Parked failure still inside its backoff window. + if (ScrapeCache.isFresh(cached) && cached.fetch_state !== 'ok') { + const error = new Error(cached.last_error || 'previously failed') + error.kind = cached.fetch_state === 'blocked' ? 'blocked' : 'http' + error.status = cached.status || null + throw error + } + + if (settings.checkRobots !== false) { + const allowed = await Robots.isAllowed(url) + if (!allowed) { + const error = new Error(`robots.txt disallows ${url}`) + error.kind = 'robots_disallowed' + await ScrapeCache.recordFailure(url, error) + throw error + } + } + + let spacingMs = null + if (settings.checkRobots !== false) { + const crawlDelay = await Robots.crawlDelayMs(url) + if (Robots.exceedsCrawlDelayCap(crawlDelay)) { + const error = new Error(`Crawl-delay of ${crawlDelay}ms exceeds cap`) + error.kind = 'blocked' + await ScrapeCache.recordFailure(url, error) + throw error + } + spacingMs = crawlDelay + } + + await ScrapeCache.markPending(url) + + const conditional = ScrapeCache.conditionalFor(cached) + + let response = null + try { + response = await queue.schedule(hostKeyFor(url), () => HttpFetch.fetchDocument(url, { + timeoutMs: settings.pageTimeoutMs, + budgetMs: settings.totalBudgetMs, + maxBytes: settings.maxHtmlBytes, + conditional, + }), { spacingMs }) + } catch (error) { + await ScrapeCache.recordFailure(url, error) + throw error + } + + // Unchanged since last time - reuse what we already stored. + if (response.notModified && cached) { + await ScrapeCache.touchNotModified(url) + const refreshed = await ScrapeCache.get(url) + return { fromCache: true, notModified: true, fields: ScrapeCache.toAttachmentFields(refreshed) } + } + + const $ = cheerio.load(response.html || '') + const parsed = SiteScrape.parse($, response.finalUrl || url) + + // Reuse an existing thumbnail file rather than refetching the image. + let fileLocation = (cached && cached.image_url === parsed.imageUrl && cached.file_location) + ? cached.file_location + : await fetchThumbnail(parsed.imageUrl, response.finalUrl || url) + + await ScrapeCache.putSuccess(url, { + ...parsed, + status: response.status, + etag: response.etag, + lastModified: response.lastModified, + contentType: response.contentType, + fileLocation, + }) + + return { + fromCache: false, + fields: { + text: parsed.searchText, + fileLocation, + // Keep the URL the user wrote. The canonical URL is recorded in the + // cache row; rewriting attachment.url would change link behaviour. + url, + }, + } +} + +// +// Coalesce: one network fetch per URL no matter how many notes asked for it. +// +const fetchCoalesced = (url) => { + const key = UrlSafety.urlKey(url) + if (!key) { return Promise.reject(Object.assign(new Error(`unusable url ${url}`), { kind: 'blocked' })) } + + const existing = inFlight.get(key) + if (existing) { return existing.promise } + + const promise = performFetch(url).finally(() => inFlight.delete(key)) + inFlight.set(key, { promise, url }) + + return promise +} + +const releaseUser = (userId) => { + const count = (perUser.get(userId) || 0) - 1 + if (count <= 0) { perUser.delete(userId) } else { perUser.set(userId, count) } +} + +// +// Queue one URL. Resolves with the search text (or '' on failure) and never +// rejects, so a caller can await a whole batch without special handling. +// +ScrapeQueue.enqueueUrl = ({ userId, noteId, attachmentId, url }) => { + const job = runJob({ userId, noteId, attachmentId, url }) + + // Registered synchronously, so drain() sees the job even before it reaches + // its first await. + outstanding.add(job) + job.finally(() => outstanding.delete(job)) + + return job +} + +const runJob = async ({ userId, noteId, attachmentId, url }) => { + if (!settings.enabled) { return '' } + + const pending = perUser.get(userId) || 0 + if (pending >= settings.maxPendingPerUser) { + // Left at scrape_state = 0, so recoverPending() picks it up later. + // Deferred, not dropped. + console.log(`[scrape] deferring ${url} - user ${userId} at pending cap`) + return '' + } + perUser.set(userId, pending + 1) + + try { + await setAttachmentState(attachmentId, STATE.FETCHING) + + const result = await fetchCoalesced(url) + await applyResultToAttachment(attachmentId, result.fields) + + notifyUser(userId) + + return result.fields.text || '' + } catch (error) { + await applyFailureToAttachment(attachmentId, error) + notifyUser(userId) + + console.log(`[scrape] ${url} failed: ${error.kind || 'error'} ${error.message}`) + return '' + } finally { + releaseUser(userId) + } +} + +// +// Queue a batch. allSettled, so one bad URL cannot strand the rest - this is +// the structural replacement for the old completion counter. +// +ScrapeQueue.enqueueUrls = async (jobs) => { + if (!jobs || !jobs.length) { return [] } + + const results = await Promise.allSettled(jobs.map(job => ScrapeQueue.enqueueUrl(job))) + + return results.map(result => result.status === 'fulfilled' ? result.value : '') +} + +// +// Wait for one URL already queued, with a hard cap. Used by the bookmarklet so +// it keeps responding after the scrape without being able to hang forever. +// +ScrapeQueue.awaitUrl = async (promise, capMs) => { + let timer = null + + const timeout = new Promise(resolve => { + timer = setTimeout(() => resolve('__scrape_timeout__'), capMs || settings.totalBudgetMs) + if (timer.unref) { timer.unref() } + }) + + try { + return await Promise.race([promise, timeout]) + } finally { + if (timer) { clearTimeout(timer) } + } +} + +// +// Crash recovery. +// +// The queue is in memory, so a pm2 reload loses it - but every enqueued URL +// already has an attachment row, which makes the work re-derivable with no +// extra table. Rows stuck mid-fetch are reset, rows still pending are +// re-enqueued, and anything older than a day is retired so nothing +// recirculates forever. +// +ScrapeQueue.recoverPending = async () => { + if (!settings.enabled) { return { requeued: 0, retired: 0 } } + + // Anything left at FETCHING died with the previous process. + await db.promise().query( + 'UPDATE attachment SET scrape_state = ? WHERE scrape_state = ?', + [STATE.PENDING, STATE.FETCHING] + ) + + const cutoff = now() - (24 * 60 * 60) + + const [retiredResult] = await db.promise().query( + `UPDATE attachment SET scrape_state = ? + WHERE scrape_state = ? AND enqueued_at IS NOT NULL AND enqueued_at < ?`, + [STATE.FAILED, STATE.PENDING, cutoff] + ) + + const [rows] = await db.promise().query( + `SELECT id, user_id, note_id, url FROM attachment + WHERE attachment_type = 1 AND visible = 1 AND scrape_state = ? + ORDER BY enqueued_at IS NULL, enqueued_at + LIMIT ?`, + [STATE.PENDING, settings.maxQueueLength] + ) + + if (rows.length) { + console.log(`[scrape] recovering ${rows.length} pending URL(s) after restart`) + } + + // Fire and forget: boot must not wait on the network. + // Fire and forget, but the individual jobs register themselves in + // `outstanding`, so drain() can still wait for them. + ScrapeQueue.enqueueUrls(rows.map(row => ({ + userId: row.user_id, + noteId: row.note_id, + attachmentId: row.id, + url: row.url, + }))).catch(error => console.log('[scrape] recovery failed', error.message)) + + return { requeued: rows.length, retired: retiredResult.affectedRows || 0 } +}