2026-04-12 08:50:19 +08:00
|
|
|
import { execFile } from "node:child_process";
|
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
|
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { extname, join } from "node:path";
|
|
|
|
|
import { promisify } from "node:util";
|
2026-05-13 21:33:50 +08:00
|
|
|
import { stripInternalPaths } from "./errors.js";
|
2026-04-12 08:50:19 +08:00
|
|
|
|
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
/** Maximum character length for any single ExifTool tag value. */
|
|
|
|
|
const MAX_TAG_VALUE_LENGTH = 10_000;
|
|
|
|
|
|
|
|
|
|
/** Allowed pattern for ExifTool tag names (alphanumeric, colon, underscore, hyphen). */
|
|
|
|
|
const TAG_NAME_PATTERN = /^[a-zA-Z0-9:_-]+$/;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate and sanitize a tag value: strip null bytes, enforce length limit.
|
|
|
|
|
* Returns the sanitized value or throws if it exceeds the length limit.
|
|
|
|
|
*/
|
|
|
|
|
export function sanitizeTagValue(value: string, tagName: string): string {
|
|
|
|
|
// Strip null bytes
|
|
|
|
|
const cleaned = value.replace(/\0/g, "");
|
|
|
|
|
if (cleaned.length > MAX_TAG_VALUE_LENGTH) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Tag value for "${tagName}" exceeds maximum length of ${MAX_TAG_VALUE_LENGTH} characters`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return cleaned;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate a tag name against the allowed pattern.
|
|
|
|
|
* Throws if the name contains invalid characters.
|
|
|
|
|
*/
|
|
|
|
|
export function validateTagName(name: string): void {
|
|
|
|
|
if (!TAG_NAME_PATTERN.test(name)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Invalid tag name "${name}": only alphanumeric, colon, underscore, and hyphen are allowed`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 08:50:19 +08:00
|
|
|
/** Grouped metadata returned by ExifTool -json -G */
|
|
|
|
|
export interface ExifToolMetadata {
|
|
|
|
|
[group: string]: Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Structured inspect result for the frontend */
|
|
|
|
|
export interface InspectResult {
|
|
|
|
|
filename: string;
|
|
|
|
|
fileSize: number;
|
|
|
|
|
exif: Record<string, unknown> | null;
|
|
|
|
|
iptc: Record<string, unknown> | null;
|
|
|
|
|
xmp: Record<string, unknown> | null;
|
|
|
|
|
gps: Record<string, unknown> | null;
|
|
|
|
|
keywords: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cachedBinary: string | null = null;
|
|
|
|
|
|
|
|
|
|
async function findExiftool(): Promise<string> {
|
|
|
|
|
if (cachedBinary) return cachedBinary;
|
|
|
|
|
try {
|
|
|
|
|
await execFileAsync("exiftool", ["-ver"], { timeout: 5_000 });
|
|
|
|
|
cachedBinary = "exiftool";
|
|
|
|
|
return "exiftool";
|
|
|
|
|
} catch {
|
|
|
|
|
throw new Error(
|
|
|
|
|
"ExifTool not found. Install libimage-exiftool-perl (Linux) or brew install exiftool (macOS).",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Read all metadata from an image buffer using ExifTool.
|
|
|
|
|
* Returns grouped metadata sections (EXIF, IPTC, XMP, GPS, etc.)
|
|
|
|
|
*/
|
|
|
|
|
export async function inspectMetadata(buffer: Buffer, filename: string): Promise<InspectResult> {
|
|
|
|
|
const bin = await findExiftool();
|
|
|
|
|
const ext = extname(filename) || ".jpg";
|
|
|
|
|
const id = randomUUID();
|
|
|
|
|
const tempPath = join(tmpdir(), `exif-inspect-${id}${ext}`);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await writeFile(tempPath, buffer);
|
|
|
|
|
const { stdout } = await execFileAsync(bin, ["-json", "-G", "-struct", "-n", tempPath], {
|
2026-04-20 21:46:07 +08:00
|
|
|
timeout: 60_000,
|
2026-04-12 08:50:19 +08:00
|
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const parsed = JSON.parse(stdout);
|
|
|
|
|
const raw = parsed[0] ?? {};
|
|
|
|
|
|
|
|
|
|
// Group fields by their ExifTool group prefix (e.g. "EXIF:Artist", "IPTC:Keywords")
|
|
|
|
|
const exif: Record<string, unknown> = {};
|
|
|
|
|
const iptc: Record<string, unknown> = {};
|
|
|
|
|
const xmp: Record<string, unknown> = {};
|
|
|
|
|
const gps: Record<string, unknown> = {};
|
|
|
|
|
const keywords: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
|
|
|
if (key === "SourceFile") continue;
|
|
|
|
|
|
|
|
|
|
const [group, field] = key.includes(":") ? key.split(":", 2) : ["File", key];
|
|
|
|
|
|
|
|
|
|
if (group === "EXIF") {
|
|
|
|
|
exif[field] = value;
|
|
|
|
|
} else if (group === "IPTC") {
|
|
|
|
|
if (field === "Keywords") {
|
|
|
|
|
if (Array.isArray(value)) keywords.push(...value.map(String));
|
|
|
|
|
else if (value) keywords.push(String(value));
|
|
|
|
|
}
|
|
|
|
|
iptc[field] = value;
|
|
|
|
|
} else if (group === "XMP") {
|
|
|
|
|
if (field === "Subject") {
|
|
|
|
|
if (Array.isArray(value)) keywords.push(...value.map(String));
|
|
|
|
|
}
|
|
|
|
|
xmp[field] = value;
|
|
|
|
|
} else if (group === "GPS" || group === "Composite") {
|
|
|
|
|
if (field.startsWith("GPS") || field === "GPSPosition") {
|
|
|
|
|
gps[field] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Deduplicate keywords
|
|
|
|
|
const uniqueKeywords = [...new Set(keywords)];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
filename,
|
|
|
|
|
fileSize: buffer.length,
|
|
|
|
|
exif: Object.keys(exif).length > 0 ? exif : null,
|
|
|
|
|
iptc: Object.keys(iptc).length > 0 ? iptc : null,
|
|
|
|
|
xmp: Object.keys(xmp).length > 0 ? xmp : null,
|
|
|
|
|
gps: Object.keys(gps).length > 0 ? gps : null,
|
|
|
|
|
keywords: uniqueKeywords,
|
|
|
|
|
};
|
2026-05-13 21:33:50 +08:00
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
throw new Error(stripInternalPaths(message));
|
2026-04-12 08:50:19 +08:00
|
|
|
} finally {
|
|
|
|
|
await rm(tempPath, { force: true }).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Write metadata tags to an image buffer using ExifTool.
|
|
|
|
|
* Modifies metadata in-place without re-encoding pixels.
|
|
|
|
|
*/
|
|
|
|
|
export async function writeMetadata(
|
|
|
|
|
buffer: Buffer,
|
|
|
|
|
filename: string,
|
|
|
|
|
tags: string[],
|
|
|
|
|
): Promise<Buffer> {
|
|
|
|
|
if (tags.length === 0) return buffer;
|
|
|
|
|
|
|
|
|
|
const bin = await findExiftool();
|
|
|
|
|
const ext = extname(filename) || ".jpg";
|
|
|
|
|
const id = randomUUID();
|
|
|
|
|
const tempPath = join(tmpdir(), `exif-write-${id}${ext}`);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await writeFile(tempPath, buffer);
|
|
|
|
|
await execFileAsync(bin, ["-overwrite_original", ...tags, tempPath], {
|
2026-04-20 21:46:07 +08:00
|
|
|
timeout: 60_000,
|
2026-04-12 08:50:19 +08:00
|
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
|
|
|
});
|
|
|
|
|
return await readFile(tempPath);
|
2026-05-13 21:33:50 +08:00
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
throw new Error(stripInternalPaths(message));
|
2026-04-12 08:50:19 +08:00
|
|
|
} finally {
|
|
|
|
|
await rm(tempPath, { force: true }).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 22:13:58 +08:00
|
|
|
export async function readImageDimensions(
|
|
|
|
|
buffer: Buffer,
|
|
|
|
|
ext?: string,
|
|
|
|
|
): Promise<{ width: number; height: number } | null> {
|
|
|
|
|
try {
|
|
|
|
|
const bin = await findExiftool();
|
|
|
|
|
const suffix = ext ? `.${ext.replace(/^\./, "")}` : ".jpg";
|
|
|
|
|
const id = randomUUID();
|
|
|
|
|
const tempPath = join(tmpdir(), `exif-dim-${id}${suffix}`);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await writeFile(tempPath, buffer);
|
|
|
|
|
const { stdout } = await execFileAsync(
|
|
|
|
|
bin,
|
|
|
|
|
["-json", "-ImageWidth", "-ImageHeight", tempPath],
|
|
|
|
|
{ timeout: 10_000 },
|
|
|
|
|
);
|
|
|
|
|
const [data] = JSON.parse(stdout);
|
|
|
|
|
if (!data?.ImageWidth || !data?.ImageHeight) return null;
|
|
|
|
|
return { width: data.ImageWidth, height: data.ImageHeight };
|
|
|
|
|
} finally {
|
|
|
|
|
await rm(tempPath, { force: true }).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 08:50:19 +08:00
|
|
|
/** Settings shape that buildTagArgs accepts */
|
|
|
|
|
export interface EditMetadataSettings {
|
2026-04-21 23:54:25 +08:00
|
|
|
title?: string;
|
|
|
|
|
author?: string;
|
2026-04-12 08:50:19 +08:00
|
|
|
artist?: string;
|
|
|
|
|
copyright?: string;
|
|
|
|
|
imageDescription?: string;
|
|
|
|
|
software?: string;
|
|
|
|
|
dateTime?: string;
|
|
|
|
|
dateTimeOriginal?: string;
|
|
|
|
|
clearGps?: boolean;
|
|
|
|
|
fieldsToRemove?: string[];
|
|
|
|
|
gpsLatitude?: number;
|
|
|
|
|
gpsLongitude?: number;
|
|
|
|
|
gpsAltitude?: number;
|
|
|
|
|
keywords?: string[];
|
|
|
|
|
keywordsMode?: "add" | "set";
|
|
|
|
|
dateShift?: string;
|
|
|
|
|
setAllDates?: string;
|
|
|
|
|
iptcTitle?: string;
|
|
|
|
|
iptcHeadline?: string;
|
|
|
|
|
iptcCity?: string;
|
|
|
|
|
iptcState?: string;
|
|
|
|
|
iptcCountry?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert settings object into ExifTool CLI tag arguments.
|
2026-05-13 21:33:50 +08:00
|
|
|
* All tag values are sanitized (null bytes stripped, length limited).
|
|
|
|
|
* All tag names in fieldsToRemove are validated against an allowed pattern.
|
2026-04-12 08:50:19 +08:00
|
|
|
*/
|
|
|
|
|
export function buildTagArgs(settings: EditMetadataSettings): string[] {
|
|
|
|
|
const args: string[] = [];
|
|
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
/** Helper: sanitize a string value before adding as a tag argument. */
|
|
|
|
|
const s = (value: string, tagName: string): string => sanitizeTagValue(value, tagName);
|
|
|
|
|
|
2026-04-21 23:54:25 +08:00
|
|
|
// Common aliases
|
|
|
|
|
const artist = settings.artist || settings.author;
|
|
|
|
|
const description = settings.imageDescription || settings.title;
|
|
|
|
|
|
2026-04-12 08:50:19 +08:00
|
|
|
// Basic EXIF fields
|
2026-05-13 21:33:50 +08:00
|
|
|
if (artist) args.push(`-Artist=${s(artist, "Artist")}`);
|
|
|
|
|
if (settings.copyright) args.push(`-Copyright=${s(settings.copyright, "Copyright")}`);
|
|
|
|
|
if (description) args.push(`-ImageDescription=${s(description, "ImageDescription")}`);
|
|
|
|
|
if (settings.software) args.push(`-Software=${s(settings.software, "Software")}`);
|
|
|
|
|
if (settings.title) args.push(`-XMP:Title=${s(settings.title, "XMP:Title")}`);
|
2026-04-12 08:50:19 +08:00
|
|
|
|
|
|
|
|
// Date fields
|
2026-05-13 21:33:50 +08:00
|
|
|
if (settings.dateTime) args.push(`-ModifyDate=${s(settings.dateTime, "ModifyDate")}`);
|
|
|
|
|
if (settings.dateTimeOriginal)
|
|
|
|
|
args.push(`-DateTimeOriginal=${s(settings.dateTimeOriginal, "DateTimeOriginal")}`);
|
2026-04-12 08:50:19 +08:00
|
|
|
|
|
|
|
|
// Date shift (applies to all date fields)
|
|
|
|
|
if (settings.dateShift) {
|
2026-05-13 21:33:50 +08:00
|
|
|
const cleaned = s(settings.dateShift, "dateShift");
|
|
|
|
|
const direction = cleaned.startsWith("-") ? "-" : "+";
|
|
|
|
|
const value = cleaned.replace(/^[+-]/, "");
|
2026-04-12 08:50:19 +08:00
|
|
|
args.push(`-AllDates${direction}=0:0:0 ${value}:0`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set all dates to a specific value
|
|
|
|
|
if (settings.setAllDates) {
|
2026-05-13 21:33:50 +08:00
|
|
|
args.push(`-AllDates=${s(settings.setAllDates, "setAllDates")}`);
|
2026-04-12 08:50:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GPS coordinates
|
|
|
|
|
if (settings.clearGps) {
|
|
|
|
|
args.push("-gps:all=");
|
|
|
|
|
} else if (settings.gpsLatitude !== undefined && settings.gpsLongitude !== undefined) {
|
|
|
|
|
const lat = settings.gpsLatitude;
|
|
|
|
|
const lon = settings.gpsLongitude;
|
|
|
|
|
args.push(`-GPSLatitude=${Math.abs(lat)}`);
|
|
|
|
|
args.push(`-GPSLatitudeRef=${lat >= 0 ? "N" : "S"}`);
|
|
|
|
|
args.push(`-GPSLongitude=${Math.abs(lon)}`);
|
|
|
|
|
args.push(`-GPSLongitudeRef=${lon >= 0 ? "E" : "W"}`);
|
|
|
|
|
if (settings.gpsAltitude !== undefined) {
|
|
|
|
|
args.push(`-GPSAltitude=${Math.abs(settings.gpsAltitude)}`);
|
|
|
|
|
args.push(
|
|
|
|
|
`-GPSAltitudeRef=${settings.gpsAltitude >= 0 ? "Above Sea Level" : "Below Sea Level"}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keywords
|
|
|
|
|
if (settings.keywords && settings.keywords.length > 0) {
|
|
|
|
|
if (settings.keywordsMode === "set") {
|
|
|
|
|
// Clear existing first, then set new ones
|
|
|
|
|
args.push("-IPTC:Keywords=");
|
|
|
|
|
args.push("-XMP:Subject=");
|
|
|
|
|
}
|
|
|
|
|
for (const kw of settings.keywords) {
|
2026-05-13 21:33:50 +08:00
|
|
|
const trimmed = s(kw, "keyword").trim();
|
|
|
|
|
if (trimmed) {
|
|
|
|
|
args.push(`-IPTC:Keywords+=${trimmed}`);
|
|
|
|
|
args.push(`-XMP:Subject+=${trimmed}`);
|
2026-04-12 08:50:19 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IPTC fields
|
2026-05-13 21:33:50 +08:00
|
|
|
if (settings.iptcTitle) args.push(`-IPTC:ObjectName=${s(settings.iptcTitle, "IPTC:ObjectName")}`);
|
|
|
|
|
if (settings.iptcHeadline)
|
|
|
|
|
args.push(`-IPTC:Headline=${s(settings.iptcHeadline, "IPTC:Headline")}`);
|
|
|
|
|
if (settings.iptcCity) args.push(`-IPTC:City=${s(settings.iptcCity, "IPTC:City")}`);
|
|
|
|
|
if (settings.iptcState)
|
|
|
|
|
args.push(`-IPTC:Province-State=${s(settings.iptcState, "IPTC:Province-State")}`);
|
|
|
|
|
if (settings.iptcCountry)
|
|
|
|
|
args.push(
|
|
|
|
|
`-IPTC:Country-PrimaryLocationName=${s(settings.iptcCountry, "IPTC:Country-PrimaryLocationName")}`,
|
|
|
|
|
);
|
2026-04-12 08:50:19 +08:00
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
// Field removal -- validate tag names against allowed pattern
|
2026-04-12 08:50:19 +08:00
|
|
|
if (settings.fieldsToRemove && settings.fieldsToRemove.length > 0) {
|
|
|
|
|
for (const field of settings.fieldsToRemove) {
|
2026-05-13 21:33:50 +08:00
|
|
|
validateTagName(field);
|
|
|
|
|
args.push(`-${field}=`);
|
2026-04-12 08:50:19 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return args;
|
|
|
|
|
}
|