mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add edit-metadata API route with inspect and edit endpoints
This commit is contained in:
@@ -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<string, unknown> = {
|
||||||
|
filename,
|
||||||
|
fileSize: fileBuffer.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (metadata.exif) {
|
||||||
|
try {
|
||||||
|
const parsed = parseExif(metadata.exif);
|
||||||
|
const exifData: Record<string, unknown> = {
|
||||||
|
...parsed.image,
|
||||||
|
...parsed.photo,
|
||||||
|
...parsed.iop,
|
||||||
|
};
|
||||||
|
const gpsData: Record<string, unknown> = { ...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<string, string> = {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { registerCompose } from "./compose.js";
|
|||||||
import { registerCompress } from "./compress.js";
|
import { registerCompress } from "./compress.js";
|
||||||
import { registerConvert } from "./convert.js";
|
import { registerConvert } from "./convert.js";
|
||||||
import { registerCrop } from "./crop.js";
|
import { registerCrop } from "./crop.js";
|
||||||
|
import { registerEditMetadata } from "./edit-metadata.js";
|
||||||
import { registerEraseObject } from "./erase-object.js";
|
import { registerEraseObject } from "./erase-object.js";
|
||||||
import { registerFavicon } from "./favicon.js";
|
import { registerFavicon } from "./favicon.js";
|
||||||
import { registerFindDuplicates } from "./find-duplicates.js";
|
import { registerFindDuplicates } from "./find-duplicates.js";
|
||||||
@@ -81,6 +82,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
{ id: "convert", register: registerConvert },
|
{ id: "convert", register: registerConvert },
|
||||||
{ id: "compress", register: registerCompress },
|
{ id: "compress", register: registerCompress },
|
||||||
{ id: "strip-metadata", register: registerStripMetadata },
|
{ id: "strip-metadata", register: registerStripMetadata },
|
||||||
|
{ id: "edit-metadata", register: registerEditMetadata },
|
||||||
{ id: "color-adjustments", register: registerColorAdjustments },
|
{ id: "color-adjustments", register: registerColorAdjustments },
|
||||||
|
|
||||||
// Watermark & Overlay
|
// Watermark & Overlay
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png"));
|
|||||||
const JPG_100x100 = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
const JPG_100x100 = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
||||||
const WEBP_50x50 = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
const WEBP_50x50 = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
||||||
const PNG_1x1 = readFileSync(join(FIXTURES, "test-1x1.png"));
|
const PNG_1x1 = readFileSync(join(FIXTURES, "test-1x1.png"));
|
||||||
|
const EXIF_JPG = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared state
|
// Shared state
|
||||||
@@ -3382,3 +3383,103 @@ describe("Workspace integrity", () => {
|
|||||||
expect(jobId1).not.toBe(jobId2);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user