Skip to content

Commit 57c0880

Browse files
committed
feat: improve extractor robustness and add test workflow
harden backend fetch safety and URL normalization add inline SVG extraction + frontend data URL asset support expand image candidate discovery from head metadata add fixture tests and corpus fetch/report/test tooling document new commands and config
1 parent 8c057f9 commit 57c0880

16 files changed

Lines changed: 626 additions & 28 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,8 @@ pnpm-debug.log*
2121
coverage/
2222
dist/
2323
tmp/
24+
25+
# Generated corpus snapshots/reports
26+
tests/fixtures/corpus/*.html
27+
tests/fixtures/corpus/index.tsv
28+
tests/fixtures/corpus/report.tsv

Justfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
run:
22
node server.js
33

4+
test:
5+
node --test tests/*.test.js
6+
7+
corpus-fetch:
8+
sh scripts/fetch-corpus.sh
9+
10+
test-corpus:
11+
node --test tests/corpus.extract.test.js
12+
13+
corpus-report:
14+
node scripts/corpus-report.js
15+
416
docker-build:
517
docker build --pull --no-cache -t imagefetch:latest .
618

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ just --list
8080
Current commands:
8181

8282
- `just run` - run Node server directly
83+
- `just test` - run fixture-based extraction tests
84+
- `just corpus-fetch` - fetch `tests/corpus/urls.txt` into local corpus fixtures
85+
- `just test-corpus` - run extraction tests against fetched corpus fixtures
86+
- `just corpus-report` - generate per-site detected-image report from fetched corpus
8387
- `just docker-build` - build latest container image
8488
- `just docker-run` - run hardened container
8589
- `just ci-lint` - lint GitHub Actions workflows
@@ -98,9 +102,25 @@ Environment variables:
98102
- `MAX_HTML_BYTES` (default `2097152`)
99103
- `MAX_ASSET_BYTES` (default `26214400`)
100104
- `MAX_REQUEST_TARGET_CHARS` (default `4096`)
105+
- `APK_UPGRADE_ON_START` (default `1`, set `0` to skip `apk update && apk upgrade` at container startup)
101106

102107
App version is read from the `VERSION` file at server startup.
103108

109+
## Corpus Workflow
110+
111+
Use this to validate extraction behavior against a real-world URL corpus:
112+
113+
1. Add target URLs to `tests/corpus/urls.txt` (one per line)
114+
2. Fetch snapshots:
115+
- `just corpus-fetch`
116+
3. Run corpus tests:
117+
- `just test-corpus`
118+
4. Generate a detection report:
119+
- `just corpus-report`
120+
121+
Fetched snapshots are stored in `tests/fixtures/corpus/` with an index manifest at `tests/fixtures/corpus/index.tsv`.
122+
Detection report is written to `tests/fixtures/corpus/report.tsv`.
123+
104124
## Security Notes
105125

106126
- SSRF protections block localhost/private address targets

index.html

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,19 @@ <h1 class="title">ImageFetch</h1>
758758
return { controller, clear: () => clearTimeout(timer) };
759759
}
760760

761+
function dataUrlToBlob(dataUrl) {
762+
const str = String(dataUrl || '');
763+
const match = str.match(/^data:([^;,]+)?(?:;base64)?,(.*)$/i);
764+
if (!match) throw new Error('Invalid data URL');
765+
const mime = match[1] || 'application/octet-stream';
766+
const payload = match[2] || '';
767+
const isBase64 = /;base64,/i.test(str.slice(0, str.indexOf(',') + 1));
768+
const bytes = isBase64
769+
? Uint8Array.from(atob(payload), (ch) => ch.charCodeAt(0))
770+
: Uint8Array.from(decodeURIComponent(payload), (ch) => ch.charCodeAt(0));
771+
return new Blob([bytes], { type: mime });
772+
}
773+
761774
async function fetchExtraction(url, config) {
762775
const guard = withTimeout(config.timeoutMs, 'Page request');
763776
try {
@@ -781,6 +794,19 @@ <h1 class="title">ImageFetch</h1>
781794
}
782795

783796
async function fetchAssetBlob(item, timeoutMs) {
797+
if (String(item.url || '').startsWith('data:image/')) {
798+
try {
799+
const blob = dataUrlToBlob(item.url);
800+
item.blob = blob;
801+
item.mime = blob.type || 'image/svg+xml';
802+
item.objectUrl = URL.createObjectURL(blob);
803+
} catch (err) {
804+
item.loadError = String(err.message || err);
805+
}
806+
item.dimensions = await detectDimensions(item.objectUrl || item.url);
807+
return item;
808+
}
809+
784810
const guard = withTimeout(timeoutMs, `Asset request (${item.filename})`);
785811
try {
786812
const params = new URLSearchParams({ url: item.url, timeoutMs: String(timeoutMs) });

scripts/corpus-report.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
const { readFileSync, writeFileSync, existsSync } = require('node:fs');
5+
const path = require('node:path');
6+
const { extractCandidates } = require('../server.js');
7+
8+
const corpusDir = path.join(__dirname, '..', 'tests', 'fixtures', 'corpus');
9+
const indexPath = path.join(corpusDir, 'index.tsv');
10+
const outPath = path.join(corpusDir, 'report.tsv');
11+
12+
function parseIndex(tsv) {
13+
const lines = String(tsv || '')
14+
.split(/\r?\n/)
15+
.map((line) => line.trim())
16+
.filter(Boolean);
17+
if (lines.length < 2) return [];
18+
const out = [];
19+
for (let i = 1; i < lines.length; i += 1) {
20+
const cols = lines[i].split('\t');
21+
out.push({
22+
id: cols[0] || '',
23+
url: cols[1] || '',
24+
file: cols[2] || '',
25+
status: cols[3] || '',
26+
detail: cols[4] || '',
27+
});
28+
}
29+
return out;
30+
}
31+
32+
if (!existsSync(indexPath)) {
33+
console.error(`Missing corpus index: ${indexPath}`);
34+
process.exit(1);
35+
}
36+
37+
const rows = parseIndex(readFileSync(indexPath, 'utf8')).filter((r) => r.status === 'ok');
38+
if (rows.length === 0) {
39+
console.error('No successful corpus rows. Run: just corpus-fetch');
40+
process.exit(2);
41+
}
42+
43+
const reportLines = ['id\turl\tgroup\tsourceType\tfilename\tassetUrl'];
44+
let totalAssets = 0;
45+
for (const row of rows) {
46+
const fixturePath = path.join(corpusDir, row.file);
47+
if (!existsSync(fixturePath)) continue;
48+
const html = readFileSync(fixturePath, 'utf8');
49+
const groups = extractCandidates(html, row.url, 3);
50+
for (const group of groups) {
51+
for (const item of group.items) {
52+
totalAssets += 1;
53+
reportLines.push([
54+
row.id,
55+
row.url,
56+
group.key,
57+
item.sourceType || '',
58+
item.filename || '',
59+
item.url || '',
60+
].join('\t'));
61+
}
62+
}
63+
}
64+
65+
writeFileSync(outPath, `${reportLines.join('\n')}\n`, 'utf8');
66+
console.log(`Corpus report written: ${outPath}`);
67+
console.log(`Sites: ${rows.length}, Assets: ${totalAssets}`);

scripts/fetch-corpus.sh

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
URLS_FILE="${1:-tests/corpus/urls.txt}"
5+
OUT_DIR="${2:-tests/fixtures/corpus}"
6+
INDEX_FILE="${OUT_DIR}/index.tsv"
7+
PRECHECK_HOST="${CORPUS_PRECHECK_HOST:-github.com}"
8+
9+
if [ ! -f "${URLS_FILE}" ]; then
10+
echo "URLs file not found: ${URLS_FILE}" >&2
11+
exit 1
12+
fi
13+
14+
if ! command -v curl >/dev/null 2>&1; then
15+
echo "curl is required but not found in PATH" >&2
16+
exit 1
17+
fi
18+
19+
# Fail fast when command-line DNS/network is unavailable in this runtime.
20+
if ! node -e "require('node:dns').lookup('${PRECHECK_HOST}', (e) => process.exit(e ? 1 : 0))" >/dev/null 2>&1; then
21+
echo "DNS precheck failed for ${PRECHECK_HOST}. This runtime cannot resolve external hosts." >&2
22+
echo "Run corpus fetch in a network-enabled shell (outside restricted sandbox) and retry." >&2
23+
exit 2
24+
fi
25+
26+
mkdir -p "${OUT_DIR}"
27+
rm -f "${OUT_DIR}"/*.html
28+
printf "id\turl\tfile\tstatus\tdetail\n" > "${INDEX_FILE}"
29+
30+
i=0
31+
while IFS= read -r line || [ -n "${line}" ]; do
32+
url="$(printf "%s" "${line}" \
33+
| tr -d '\r' \
34+
| sed '1s/^\xEF\xBB\xBF//' \
35+
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
36+
| sed 's/^[-*][[:space:]]*//' \
37+
| sed 's/[[:space:]]\+#.*$//' \
38+
| sed 's/[;,][[:space:]]*$//')"
39+
case "${url}" in
40+
""|\#*) continue ;;
41+
esac
42+
43+
case "${url}" in
44+
http://*|https://*) ;;
45+
*) url="https://${url}" ;;
46+
esac
47+
48+
i=$((i + 1))
49+
id="$(printf "%03d" "${i}")"
50+
host="$(printf "%s" "${url}" | sed -E 's#^[a-zA-Z]+://##; s#/.*$##; s#[:?&=]#_#g')"
51+
[ -n "${host}" ] || host="site"
52+
file="${id}-${host}.html"
53+
out_path="${OUT_DIR}/${file}"
54+
55+
err_file="${OUT_DIR}/.${id}.curl.err"
56+
if curl -fsSL \
57+
--retry 2 \
58+
--retry-all-errors \
59+
--connect-timeout 10 \
60+
--max-time 30 \
61+
-H 'Accept: text/html,application/xhtml+xml' \
62+
-H 'Accept-Language: en-US,en;q=0.9' \
63+
-A 'ImageFetchCorpus/1.0 (+https://github.com/jtbrough/ImageFetch)' \
64+
"${url}" \
65+
-o "${out_path}" \
66+
2>"${err_file}"; then
67+
printf "%s\t%s\t%s\tok\t-\n" "${id}" "${url}" "${file}" >> "${INDEX_FILE}"
68+
rm -f "${err_file}"
69+
echo "ok ${url} -> ${file}"
70+
else
71+
rc=$?
72+
detail="$(tr '\n' ' ' < "${err_file}" | sed 's/[[:space:]]\+/ /g; s/^[[:space:]]*//; s/[[:space:]]*$//')"
73+
rm -f "${err_file}"
74+
[ -n "${detail}" ] || detail="curl_exit_${rc}"
75+
rm -f "${out_path}"
76+
printf "%s\t%s\t%s\tfetch_failed\t%s\n" "${id}" "${url}" "${file}" "${detail}" >> "${INDEX_FILE}"
77+
echo "fail ${url} (${detail})" >&2
78+
fi
79+
done < "${URLS_FILE}"
80+
81+
echo "Wrote corpus index: ${INDEX_FILE}"

0 commit comments

Comments
 (0)