diff --git a/src/backend/blobHandler.js b/src/backend/blobHandler.js index 60dfd38..dfa6e17 100644 --- a/src/backend/blobHandler.js +++ b/src/backend/blobHandler.js @@ -11,15 +11,166 @@ try { } catch (e) { } -const stripImageMetadata = async (buffer) => { - if (typeof sharp !== "function") return buffer; +/** + * Quitar metadatos de una imagen SIN sharp. + * + * sharp no se empaqueta en Android (lleva binarios nativos y el bundle del movil + * va sin ninguno), asi que alli esta funcion devolvia la imagen intacta y las + * fotos subian con su EXIF entero, ubicacion GPS incluida. + * + * Esto recorre el contenedor y tira los bloques de metadatos, sin decodificar ni + * recomprimir la imagen: los pixeles se quedan byte a byte como estaban. + * + * De la orientacion se guarda solo eso, la orientacion. Sin sharp no se pueden + * girar los pixeles, y tirar la etiqueta dejaria las fotos verticales tumbadas; + * asi que se reconstruye un bloque EXIF minimo con esa unica etiqueta, que no + * dice nada de quien hizo la foto ni donde. + */ + +// JPEG: cada segmento es FF . Se tiran los que llevan +// metadatos y se dejan JFIF (APP0) y el perfil de color (APP2), que afectan a como +// se ve la imagen. +const JPEG_DROP = new Set([ + 0xE1, // APP1 Exif y XMP + 0xE5, // APP5 + 0xE6, // APP6 + 0xEC, // APP12 Ducky/Picture Info + 0xED, // APP13 IPTC / Photoshop + 0xEE, // APP14 Adobe + 0xFE // COM comentario +]); + +const jpegOrientation = (seg) => { + // seg empieza en "Exif\0\0"; detras va una cabecera TIFF con la IFD0 + if (seg.length < 14 || seg.toString("latin1", 0, 6) !== "Exif\0\0") return 0; + const tiff = seg.subarray(6); + const le = tiff.toString("latin1", 0, 2) === "II"; + const u16 = (o) => (le ? tiff.readUInt16LE(o) : tiff.readUInt16BE(o)); + const u32 = (o) => (le ? tiff.readUInt32LE(o) : tiff.readUInt32BE(o)); + if (u16(2) !== 0x002A) return 0; + const ifd0 = u32(4); + if (ifd0 + 2 > tiff.length) return 0; + const n = u16(ifd0); + for (let i = 0; i < n; i++) { + const e = ifd0 + 2 + i * 12; + if (e + 12 > tiff.length) break; + if (u16(e) === 0x0112) { // Orientation + const v = u16(e + 8); + return v >= 1 && v <= 8 ? v : 0; + } + } + return 0; +}; + +/** Bloque APP1 de 32 bytes con la orientacion y nada mas. */ +const exifOnlyOrientation = (orientation) => { + const b = Buffer.alloc(32); + b.write("Exif\0\0", 0, "latin1"); + b.write("II", 6, "latin1"); + b.writeUInt16LE(0x002A, 8); + b.writeUInt32LE(8, 10); // la IFD0 empieza justo detras de la cabecera TIFF + b.writeUInt16LE(1, 14); // una sola entrada + b.writeUInt16LE(0x0112, 16); // Orientation + b.writeUInt16LE(3, 18); // SHORT + b.writeUInt32LE(1, 20); // un valor + b.writeUInt16LE(orientation, 24); + b.writeUInt32LE(0, 28); // no hay mas IFDs + return b; +}; + +const stripJpeg = (buf) => { + if (buf.length < 4 || buf[0] !== 0xFF || buf[1] !== 0xD8) return null; + const out = [buf.subarray(0, 2)]; + let orientation = 0; + let i = 2; + while (i + 4 <= buf.length) { + if (buf[i] !== 0xFF) return null; // no es un JPEG sano: no tocarlo + const marker = buf[i + 1]; + if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD9)) { i += 2; continue; } + if (marker === 0xDA) { out.push(buf.subarray(i)); break; } // datos comprimidos: hasta el final + const len = buf.readUInt16BE(i + 2); + if (len < 2 || i + 2 + len > buf.length) return null; + const seg = buf.subarray(i + 4, i + 2 + len); + if (marker === 0xE1 && !orientation) orientation = jpegOrientation(seg); + if (!JPEG_DROP.has(marker)) out.push(buf.subarray(i, i + 2 + len)); + i += 2 + len; + } + if (orientation > 1) { + const exif = exifOnlyOrientation(orientation); + const head = Buffer.alloc(4); + head.writeUInt16BE(0xFFE1, 0); + head.writeUInt16BE(exif.length + 2, 2); + out.splice(1, 0, head, exif); + } + return Buffer.concat(out); +}; + +// PNG: cadena de bloques . +const PNG_DROP = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME", "dSIG"]); + +const stripPng = (buf) => { + const SIG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + if (buf.length < 8 || !buf.subarray(0, 8).equals(SIG)) return null; + const out = [buf.subarray(0, 8)]; + let i = 8; + while (i + 8 <= buf.length) { + const len = buf.readUInt32BE(i); + const type = buf.toString("latin1", i + 4, i + 8); + const end = i + 12 + len; + if (end > buf.length) return null; + if (!PNG_DROP.has(type)) out.push(buf.subarray(i, end)); + i = end; + if (type === "IEND") break; + } + return Buffer.concat(out); +}; + +// WebP: contenedor RIFF con bloques (+1 de relleno +// si la longitud es impar). +const stripWebp = (buf) => { + if (buf.length < 12) return null; + if (buf.toString("latin1", 0, 4) !== "RIFF" || buf.toString("latin1", 8, 12) !== "WEBP") return null; + const out = []; + let i = 12; + while (i + 8 <= buf.length) { + const type = buf.toString("latin1", i, i + 4); + const len = buf.readUInt32LE(i + 4); + const end = i + 8 + len + (len % 2); + if (end > buf.length) return null; + if (type !== "EXIF" && type !== "XMP ") out.push(buf.subarray(i, end)); + i = end; + } + const body = Buffer.concat(out); + const head = Buffer.alloc(12); + head.write("RIFF", 0, "latin1"); + head.writeUInt32LE(body.length + 4, 4); + head.write("WEBP", 8, "latin1"); + return Buffer.concat([head, body]); +}; + +/** Limpia segun el formato; si algo no cuadra, devuelve la imagen tal cual. */ +const stripMetadataPure = (buffer) => { try { - return await sharp(buffer).rotate().toBuffer(); + const out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer); + return out && out.length ? out : buffer; } catch { return buffer; } }; +const stripImageMetadata = async (buffer) => { + // Con sharp se prefiere su camino: ademas de quitar metadatos, aplica la + // orientacion girando los pixeles de verdad. + if (typeof sharp === "function") { + try { + return await sharp(buffer).rotate().toBuffer(); + } catch { + return stripMetadataPure(buffer); + } + } + return stripMetadataPure(buffer); +}; + const PDF_METADATA_KEYS = [ '/Title', '/Author', '/Subject', '/Keywords', '/Creator', '/Producer', '/CreationDate', '/ModDate'