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"; import { stripInternalPaths } from "./errors.js"; const execFileAsync = promisify(execFile); /** 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`, ); } } /** Grouped metadata returned by ExifTool -json -G */ export interface ExifToolMetadata { [group: string]: Record; } /** Structured inspect result for the frontend */ export interface InspectResult { filename: string; fileSize: number; exif: Record | null; iptc: Record | null; xmp: Record | null; gps: Record | null; keywords: string[]; } let cachedBinary: string | null = null; async function findExiftool(): Promise { 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 { 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], { timeout: 60_000, 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 = {}; const iptc: Record = {}; const xmp: Record = {}; const gps: Record = {}; 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, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(stripInternalPaths(message)); } 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 { 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], { timeout: 60_000, maxBuffer: 10 * 1024 * 1024, }); return await readFile(tempPath); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(stripInternalPaths(message)); } finally { await rm(tempPath, { force: true }).catch(() => {}); } } 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; } } /** Settings shape that buildTagArgs accepts */ export interface EditMetadataSettings { title?: string; author?: string; 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. * All tag values are sanitized (null bytes stripped, length limited). * All tag names in fieldsToRemove are validated against an allowed pattern. */ export function buildTagArgs(settings: EditMetadataSettings): string[] { const args: string[] = []; /** Helper: sanitize a string value before adding as a tag argument. */ const s = (value: string, tagName: string): string => sanitizeTagValue(value, tagName); // Common aliases const artist = settings.artist || settings.author; const description = settings.imageDescription || settings.title; // Basic EXIF fields 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")}`); // Date fields if (settings.dateTime) args.push(`-ModifyDate=${s(settings.dateTime, "ModifyDate")}`); if (settings.dateTimeOriginal) args.push(`-DateTimeOriginal=${s(settings.dateTimeOriginal, "DateTimeOriginal")}`); // Date shift (applies to all date fields) if (settings.dateShift) { const cleaned = s(settings.dateShift, "dateShift"); const direction = cleaned.startsWith("-") ? "-" : "+"; const value = cleaned.replace(/^[+-]/, ""); args.push(`-AllDates${direction}=0:0:0 ${value}:0`); } // Set all dates to a specific value if (settings.setAllDates) { args.push(`-AllDates=${s(settings.setAllDates, "setAllDates")}`); } // 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) { const trimmed = s(kw, "keyword").trim(); if (trimmed) { args.push(`-IPTC:Keywords+=${trimmed}`); args.push(`-XMP:Subject+=${trimmed}`); } } } // IPTC fields 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")}`, ); // Field removal -- validate tag names against allowed pattern if (settings.fieldsToRemove && settings.fieldsToRemove.length > 0) { for (const field of settings.fieldsToRemove) { validateTagName(field); args.push(`-${field}=`); } } return args; }