From 5284d5ece5dd393bf346f29d4d95b506f0c87c94 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:27:33 +0800 Subject: [PATCH] feat: add edit-metadata API route with inspect and edit endpoints --- apps/api/src/routes/tools/edit-metadata.ts | 150 +++++++++++++++++++++ apps/api/src/routes/tools/index.ts | 2 + tests/integration/api.test.ts | 101 ++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 apps/api/src/routes/tools/edit-metadata.ts diff --git a/apps/api/src/routes/tools/edit-metadata.ts b/apps/api/src/routes/tools/edit-metadata.ts new file mode 100644 index 00000000..1ec5bb1a --- /dev/null +++ b/apps/api/src/routes/tools/edit-metadata.ts @@ -0,0 +1,150 @@ +import { basename } from "node:path"; +import { editMetadata, parseExif, parseGps, parseXmp } from "@stirling-image/image-engine"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + artist: z.string().optional(), + copyright: z.string().optional(), + imageDescription: z.string().optional(), + software: z.string().optional(), + dateTime: z.string().optional(), + dateTimeOriginal: z.string().optional(), + clearGps: z.boolean().default(false), + fieldsToRemove: z.array(z.string()).default([]), +}); + +export function registerEditMetadata(app: FastifyInstance) { + // Inspect endpoint - returns parsed metadata as JSON + app.post( + "/api/v1/tools/edit-metadata/inspect", + async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + let filename = "image"; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + fileBuffer = Buffer.concat(chunks); + filename = basename(part.filename ?? "image"); + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse multipart request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + + try { + const metadata = await sharp(fileBuffer).metadata(); + const result: Record = { + filename, + fileSize: fileBuffer.length, + }; + + if (metadata.exif) { + try { + const parsed = parseExif(metadata.exif); + const exifData: Record = { + ...parsed.image, + ...parsed.photo, + ...parsed.iop, + }; + const gpsData: Record = { ...parsed.gps }; + + if (Object.keys(parsed.gps).length > 0) { + const coords = parseGps(parsed.gps); + if (coords.latitude !== null) gpsData._latitude = coords.latitude; + if (coords.longitude !== null) gpsData._longitude = coords.longitude; + if (coords.altitude !== null) gpsData._altitude = coords.altitude; + } + + if (Object.keys(exifData).length > 0) result.exif = exifData; + if (Object.keys(gpsData).length > 0) result.gps = gpsData; + } catch { + result.exif = null; + result.exifError = "Failed to parse EXIF data"; + } + } + + if (metadata.xmp) { + try { + result.xmp = parseXmp(metadata.xmp); + } catch { + result.xmp = null; + } + } + + return reply.send(result); + } catch (err) { + return reply.status(422).send({ + error: "Failed to read image metadata", + details: err instanceof Error ? err.message : "Unknown error", + }); + } + }, + ); + + // Edit endpoint - writes metadata and returns updated image + createToolRoute(app, { + toolId: "edit-metadata", + settingsSchema, + process: async (inputBuffer, settings, filename) => { + const metadata = await sharp(inputBuffer).metadata(); + const format = metadata.format ?? "jpeg"; + const image = sharp(inputBuffer); + const result = await editMetadata(image, settings); + + switch (format) { + case "jpeg": + result.jpeg({ quality: 95, mozjpeg: true }); + break; + case "png": + result.png({ compressionLevel: 6 }); + break; + case "webp": + result.webp({ quality: 90 }); + break; + case "avif": + result.avif({ quality: 60 }); + break; + case "tiff": + result.tiff({ quality: 90 }); + break; + default: + result.jpeg({ quality: 95 }); + break; + } + + const buffer = await result.toBuffer(); + const ext = format === "jpeg" ? "jpg" : format; + const outFilename = filename.replace(/\.[^.]+$/, `.${ext}`); + const mimeMap: Record = { + jpeg: "image/jpeg", + png: "image/png", + webp: "image/webp", + avif: "image/avif", + tiff: "image/tiff", + gif: "image/gif", + }; + + return { + buffer, + filename: outFilename, + contentType: mimeMap[format] ?? "image/jpeg", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index a3d62f92..6f1849df 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -14,6 +14,7 @@ import { registerCompose } from "./compose.js"; import { registerCompress } from "./compress.js"; import { registerConvert } from "./convert.js"; import { registerCrop } from "./crop.js"; +import { registerEditMetadata } from "./edit-metadata.js"; import { registerEraseObject } from "./erase-object.js"; import { registerFavicon } from "./favicon.js"; import { registerFindDuplicates } from "./find-duplicates.js"; @@ -81,6 +82,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "convert", register: registerConvert }, { id: "compress", register: registerCompress }, { id: "strip-metadata", register: registerStripMetadata }, + { id: "edit-metadata", register: registerEditMetadata }, { id: "color-adjustments", register: registerColorAdjustments }, // Watermark & Overlay diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index d0faa88c..6f9ab2d6 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -20,6 +20,7 @@ const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png")); const JPG_100x100 = readFileSync(join(FIXTURES, "test-100x100.jpg")); const WEBP_50x50 = readFileSync(join(FIXTURES, "test-50x50.webp")); const PNG_1x1 = readFileSync(join(FIXTURES, "test-1x1.png")); +const EXIF_JPG = readFileSync(join(FIXTURES, "test-with-exif.jpg")); // --------------------------------------------------------------------------- // Shared state @@ -3382,3 +3383,103 @@ describe("Workspace integrity", () => { expect(jobId1).not.toBe(jobId2); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// EDIT METADATA +// ═══════════════════════════════════════════════════════════════════════════ +describe("Edit metadata", () => { + describe("POST /api/v1/tools/edit-metadata/inspect", () => { + it("returns parsed EXIF for JPEG with metadata", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "exif.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata/inspect", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.filename).toBe("exif.jpg"); + expect(body.exif).toBeTruthy(); + expect(body.exif.Artist).toBe("Test Artist"); + expect(body.exif.Copyright).toBe("2026 Test Copyright"); + }); + + it("returns no exif for metadata-free PNG", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "plain.png", contentType: "image/png", content: PNG_1x1 }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata/inspect", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.exif).toBeUndefined(); + }); + + it("rejects request with no file", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata/inspect", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "multipart/form-data; boundary=---test", + }, + payload: Buffer.from("-----test--\r\n"), + }); + expect(res.statusCode).toBe(400); + }); + }); + + describe("POST /api/v1/tools/edit-metadata", () => { + it("writes metadata and returns downloadable file", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "edit.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + { name: "settings", content: JSON.stringify({ artist: "New Author" }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.downloadUrl).toBeDefined(); + expect(body.jobId).toBeDefined(); + }); + + it("strips specific fields via fieldsToRemove", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "strip.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + { name: "settings", content: JSON.stringify({ fieldsToRemove: ["Software"] }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload, + }); + expect(res.statusCode).toBe(200); + }); + + it("preserves metadata with empty settings", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "noop.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload, + }); + expect(res.statusCode).toBe(200); + }); + }); +});