const sharp = require("sharp"); const exifReader = require("exif-reader"); const { logger } = require("../shared"); /** * Extract metadata from an image buffer * @param {Buffer} buffer - Image buffer * @returns {Object} Extracted metadata */ async function extractMetadata(buffer) { const image = sharp(buffer); const metadata = await image.metadata(); let exifData = {}; if (metadata.exif) { try { exifData = exifReader(metadata.exif); } catch (e) { // EXIF parsing failed, continue without it logger.warn("Failed to parse EXIF data", { error: e.message }); } } return { // GPS data latitude: parseGpsCoordinate( exifData?.gps?.GPSLatitude, exifData?.gps?.GPSLatitudeRef ), longitude: parseGpsCoordinate( exifData?.gps?.GPSLongitude, exifData?.gps?.GPSLongitudeRef ), // Camera info cameraMake: exifData?.image?.Make || null, cameraModel: exifData?.image?.Model || null, cameraSoftware: exifData?.image?.Software || null, // Date/time dateTaken: parseExifDate( exifData?.exif?.DateTimeOriginal || exifData?.image?.DateTime ), // Dimensions width: metadata.width, height: metadata.height, orientation: metadata.orientation || 1, // File info fileSize: buffer.length, }; } /** * Strip all metadata from an image buffer * Uses sharp's rotate() which auto-orients AND strips EXIF * @param {Buffer} buffer - Image buffer * @param {string} format - Output format (default: 'jpeg') * @returns {Buffer} Processed image buffer */ async function stripMetadata(buffer, format = "jpeg") { const image = sharp(buffer); const metadata = await image.metadata(); // Handle different formats let processed; if (metadata.format === "gif") { // For GIFs, try to preserve animation but strip metadata processed = await image .gif() .toBuffer(); } else if (metadata.format === "png") { // For PNGs, rotate strips metadata and we output as PNG processed = await image .rotate() // Auto-orient and strip EXIF .png() .toBuffer(); } else if (metadata.format === "webp") { processed = await image .rotate() .webp() .toBuffer(); } else { // Default to JPEG for best compatibility processed = await image .rotate() // Auto-orient and strip EXIF .jpeg({ quality: 90 }) .toBuffer(); } return processed; } /** * Convert GPS DMS (degrees, minutes, seconds) to decimal * @param {Array} dms - [degrees, minutes, seconds] * @param {string} ref - N/S/E/W reference * @returns {number|null} Decimal coordinate */ function parseGpsCoordinate(dms, ref) { if (!dms || !Array.isArray(dms) || dms.length !== 3) { return null; } const [degrees, minutes, seconds] = dms; let decimal = degrees + minutes / 60 + seconds / 3600; // South and West are negative if (ref === "S" || ref === "W") { decimal = -decimal; } // Round to 8 decimal places (about 1mm precision) return Math.round(decimal * 100000000) / 100000000; } /** * Parse EXIF date string to ISO date * EXIF format: "YYYY:MM:DD HH:MM:SS" * @param {string|Date} dateStr - EXIF date string or Date object * @returns {Date|null} Parsed date */ function parseExifDate(dateStr) { if (!dateStr) { return null; } // If already a Date object if (dateStr instanceof Date) { return dateStr; } // EXIF format: "YYYY:MM:DD HH:MM:SS" const match = String(dateStr).match( /(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})/ ); if (match) { const [, year, month, day, hour, minute, second] = match; return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}`); } // Try parsing as ISO date const date = new Date(dateStr); return isNaN(date.getTime()) ? null : date; } /** * Get the MIME type for a format * @param {string} format - Image format * @returns {string} MIME type */ function getMimeType(format) { const mimeTypes = { jpeg: "image/jpeg", jpg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", }; return mimeTypes[format] || "image/jpeg"; } module.exports = { extractMetadata, stripMetadata, parseGpsCoordinate, parseExifDate, getMimeType, };