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:
82
src/server/services/image.service.ts
Normal file
82
src/server/services/image.service.ts
Normal 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 };
|
||||
}
|
||||
45
tests/services/image.service.test.ts
Normal file
45
tests/services/image.service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user