diff --git a/src/lib/server/images/image-downloader.ts b/src/lib/server/images/image-downloader.ts new file mode 100644 index 0000000..cb7254d --- /dev/null +++ b/src/lib/server/images/image-downloader.ts @@ -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 = { + '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 { + 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; + } +} diff --git a/tests/integration/image-downloader.test.ts b/tests/integration/image-downloader.test.ts new file mode 100644 index 0000000..eeb26c0 --- /dev/null +++ b/tests/integration/image-downloader.test.ts @@ -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((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((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(); + }); +});