feat(images): add sha256-deduplicated image downloader
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
40
src/lib/server/images/image-downloader.ts
Normal file
40
src/lib/server/images/image-downloader.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fetchBuffer } from '../http';
|
||||||
|
|
||||||
|
const EXT_BY_CONTENT_TYPE: Record<string, string> = {
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/jpg': '.jpg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/gif': '.gif',
|
||||||
|
'image/avif': '.avif'
|
||||||
|
};
|
||||||
|
|
||||||
|
function extensionFor(contentType: string | null): string {
|
||||||
|
if (!contentType) return '.bin';
|
||||||
|
const base = contentType.split(';')[0].trim().toLowerCase();
|
||||||
|
return EXT_BY_CONTENT_TYPE[base] ?? '.bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadImage(
|
||||||
|
url: string,
|
||||||
|
targetDir: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { data, contentType } = await fetchBuffer(url, { maxBytes: 10 * 1024 * 1024 });
|
||||||
|
const hash = createHash('sha256').update(data).digest('hex');
|
||||||
|
const ext = extensionFor(contentType);
|
||||||
|
const filename = `${hash}${ext}`;
|
||||||
|
const target = join(targetDir, filename);
|
||||||
|
if (!existsSync(target)) {
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
await writeFile(target, data);
|
||||||
|
}
|
||||||
|
return filename;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
tests/integration/image-downloader.test.ts
Normal file
64
tests/integration/image-downloader.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { createServer, type Server } from 'node:http';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { downloadImage } from '../../src/lib/server/images/image-downloader';
|
||||||
|
|
||||||
|
// 1×1 PNG (transparent)
|
||||||
|
const PNG_BYTES = Buffer.from(
|
||||||
|
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489' +
|
||||||
|
'0000000d49444154789c6300010000000500010d0a2db40000000049454e44ae426082',
|
||||||
|
'hex'
|
||||||
|
);
|
||||||
|
|
||||||
|
let server: Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = createServer();
|
||||||
|
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = server.address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||||
|
dir = await mkdtemp(join(tmpdir(), 'kochwas-img-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await new Promise<void>((r) => server.close(() => r()));
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadImage', () => {
|
||||||
|
it('downloads and stores a PNG with sha256 filename', async () => {
|
||||||
|
server.on('request', (_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'image/png' });
|
||||||
|
res.end(PNG_BYTES);
|
||||||
|
});
|
||||||
|
const fname = await downloadImage(`${baseUrl}/img.png`, dir);
|
||||||
|
expect(fname).not.toBeNull();
|
||||||
|
expect(fname!.endsWith('.png')).toBe(true);
|
||||||
|
const saved = await readFile(join(dir, fname!));
|
||||||
|
expect(saved.equals(PNG_BYTES)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — returns same filename, does not rewrite', async () => {
|
||||||
|
server.on('request', (_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'image/png' });
|
||||||
|
res.end(PNG_BYTES);
|
||||||
|
});
|
||||||
|
const first = await downloadImage(`${baseUrl}/img.png`, dir);
|
||||||
|
const second = await downloadImage(`${baseUrl}/img.png`, dir);
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on 404', async () => {
|
||||||
|
server.on('request', (_req, res) => {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
const fname = await downloadImage(`${baseUrl}/missing.png`, dir);
|
||||||
|
expect(fname).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user