feat: add image URL fetching service with tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 13:15:56 +02:00
parent d104e9788f
commit 0004329895
2 changed files with 127 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const FETCH_TIMEOUT = 10_000; // 10 seconds
interface FetchImageResult {
filename: string;
sourceUrl: string;
}
export async function fetchImageFromUrl(
url: string,
uploadsDir = "uploads",
): Promise<FetchImageResult> {
// Validate URL format
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
throw new Error("Invalid URL format");
}
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
throw new Error("URL must use HTTP or HTTPS");
}
// Fetch with timeout
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
let response: Response;
try {
response = await fetch(url, { signal: controller.signal });
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
throw new Error("Request timed out");
}
throw new Error(`Failed to fetch image: ${(err as Error).message}`);
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: Failed to fetch image`);
}
// Validate content type
const contentType = response.headers
.get("content-type")
?.split(";")[0]
.trim();
if (!contentType || !ALLOWED_TYPES.includes(contentType)) {
throw new Error(
`Invalid content type: ${contentType ?? "unknown"}. Accepted: jpeg, png, webp`,
);
}
// Check content length if available
const contentLength = response.headers.get("content-length");
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
throw new Error("File too large. Maximum size is 5MB");
}
// Read body and check actual size
const buffer = await response.arrayBuffer();
if (buffer.byteLength > MAX_SIZE) {
throw new Error("File too large. Maximum size is 5MB");
}
// Determine extension
const ext = contentType === "image/jpeg" ? "jpg" : contentType.split("/")[1];
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
// Ensure directory exists and write
await mkdir(uploadsDir, { recursive: true });
await Bun.write(join(uploadsDir, filename), buffer);
return { filename, sourceUrl: url };
}

View File

@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it } from "bun:test";
import { existsSync, rmSync } from "node:fs";
import { fetchImageFromUrl } from "../../src/server/services/image.service.ts";
const TEST_UPLOADS_DIR = "test-uploads";
describe("Image Service", () => {
afterEach(() => {
if (existsSync(TEST_UPLOADS_DIR)) {
rmSync(TEST_UPLOADS_DIR, { recursive: true });
}
});
describe("fetchImageFromUrl", () => {
it("fetches a valid image URL and saves to disk", async () => {
const imageUrl =
"https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png";
const result = await fetchImageFromUrl(imageUrl, TEST_UPLOADS_DIR);
expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/);
expect(result.sourceUrl).toBe(imageUrl);
const filePath = `${TEST_UPLOADS_DIR}/${result.filename}`;
expect(existsSync(filePath)).toBe(true);
});
it("rejects non-image content type", async () => {
await expect(
fetchImageFromUrl("https://example.com/", TEST_UPLOADS_DIR),
).rejects.toThrow("Invalid content type");
});
it("rejects invalid URL format", async () => {
await expect(
fetchImageFromUrl("not-a-url", TEST_UPLOADS_DIR),
).rejects.toThrow("Invalid URL format");
});
it("rejects non-HTTP protocols", async () => {
await expect(
fetchImageFromUrl("ftp://example.com/image.png", TEST_UPLOADS_DIR),
).rejects.toThrow("URL must use HTTP or HTTPS");
});
});
});