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