const pull = require('../server/node_modules/pull-stream'); const FileType = require("../server/node_modules/file-type"); const promisesFs = require('fs').promises; const ssb = require("../client/gui"); const config = require("../server/SSB_server").config; const cooler = ssb({ offline: config.offline }); let sharpLib; const getSharp = () => { if (sharpLib === undefined) { try { sharpLib = require('../server/node_modules/sharp'); } catch (_) { sharpLib = null; } } return sharpLib; }; /** * 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 { 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. const sharp = getSharp(); if (sharp) { 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' ]; const stripPdfMetadata = (buffer) => { try { let str = buffer.toString('binary'); for (const key of PDF_METADATA_KEYS) { const keyBytes = key; const regex = new RegExp( keyBytes.replace(/\//g, '\\/') + '\\s*\\([^)]*\\)', 'g' ); str = str.replace(regex, keyBytes + ' ()'); const hexRegex = new RegExp( keyBytes.replace(/\//g, '\\/') + '\\s*<[^>]*>', 'g' ); str = str.replace(hexRegex, keyBytes + ' <>'); } return Buffer.from(str, 'binary'); } catch { return buffer; } }; const MAX_BLOB_SIZE = 75 * 1024 * 1024; class FileTooLargeError extends Error { constructor(fileName, fileSize) { super(`File too large: ${fileName} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`); this.name = 'FileTooLargeError'; this.fileName = fileName; this.fileSize = fileSize; } } const OGG_VIDEO_MARKERS = ['theora', 'VP80', 'OggDS', 'Dirac']; const OGG_AUDIO_MARKERS = ['vorbis', 'OpusHead', 'FLAC', 'Speex']; const oggMime = (buffer) => { const head = buffer.slice(0, 65536); for (const marker of OGG_VIDEO_MARKERS) if (head.includes(Buffer.from(marker))) return 'video/ogg'; for (const marker of OGG_AUDIO_MARKERS) if (head.includes(Buffer.from(marker))) return 'audio/ogg'; return 'video/ogg'; }; const sealedRef = (sealed, mime, name) => `/blob/sealed/${encodeURIComponent(sealed.manifestBlobId)}?k=${encodeURIComponent(sealed.key)}&t=${encodeURIComponent(mime)}&n=${encodeURIComponent(name)}`; const storeUploadedFile = async function (blobUpload, seal = null) { if (!blobUpload || !blobUpload.filepath) return null; let data = await promisesFs.readFile(blobUpload.filepath); if (data.length === 0) return null; if (data.length > MAX_BLOB_SIZE) { throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || 'file', data.length); } const EXTENSION_MIME_MAP = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg', '.ogv': 'video/ogg', '.oga': 'audio/ogg', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus', '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.torrent': 'application/x-bittorrent' }; const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' }; try { const fileType = await FileType.fromBuffer(data); blob.mime = (fileType && fileType.mime) ? fileType.mime : null; } catch { blob.mime = null; } if (blob.mime === 'application/ogg') blob.mime = oggMime(data); const GENERIC_MIMES = new Set(['application/octet-stream', 'video/x-matroska']); if ((!blob.mime || GENERIC_MIMES.has(blob.mime)) && blob.name) { const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase(); blob.mime = EXTENSION_MIME_MAP[ext] || blob.mime || 'application/octet-stream'; } if (!blob.mime) { blob.mime = 'application/octet-stream'; } if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') { data = await stripImageMetadata(data); } else if (blob.mime === 'application/pdf') { data = stripPdfMetadata(data); } if (typeof seal === 'function') { await promisesFs.writeFile(blobUpload.filepath, data); const sealed = await seal({ filepath: blobUpload.filepath, size: data.length, name: blob.name, mime: blob.mime }); blob.id = sealedRef(sealed, blob.mime, blob.name); } else { const ssbClient = await cooler.open(); blob.id = await new Promise((resolve, reject) => { pull( pull.values([data]), ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref))) ); }); } if (blob.mime.startsWith("image/")) return `\n![image:${blob.name}](${blob.id})`; if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`; if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`; if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`; if (blob.mime === "application/x-bittorrent") return `\n[torrent:${blob.name}](${blob.id})`; return `\n[${blob.name}](${blob.id})`; }; const handleBlobUpload = async function (ctx, fileFieldName, seal = null) { if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null; const entry = ctx.request.files[fileFieldName]; const first = Array.isArray(entry) ? entry[0] : entry; return storeUploadedFile(first, seal); }; const handleBlobUploads = async function (ctx, fileFieldName, max = 8, seal = null) { if (!ctx.request.files || !ctx.request.files[fileFieldName]) return []; const entry = ctx.request.files[fileFieldName]; const files = (Array.isArray(entry) ? entry : [entry]).slice(0, Math.max(0, max)); const out = []; for (const file of files) { const stored = await storeUploadedFile(file, seal); if (stored) out.push(stored); } return out; }; function waitForBlob(ssbClient, blobId, timeoutMs = 8000) { return new Promise((resolve, reject) => { let done = false; const finishOk = () => { if (done) return; done = true; clearTimeout(timer); resolve(); }; const finishErr = (err) => { if (done) return; done = true; clearTimeout(timer); reject(err); }; const timer = setTimeout(() => { finishErr(new Error(`Timeout waiting for blob ${blobId}`)); }, timeoutMs); if (!ssbClient.blobs || typeof ssbClient.blobs.has !== 'function') { return finishErr(new Error('ssb.blobs.has is not available')); } ssbClient.blobs.has(blobId, (err, has) => { if (err) return finishErr(err); if (has) return finishOk(); if (typeof ssbClient.blobs.want !== 'function') { return finishErr(new Error('ssb.blobs.want is not available')); } ssbClient.blobs.want(blobId, (err2) => { if (err2) return finishErr(err2); finishOk(); }); }); }); } const contentDisposition = (type, name) => { const clean = String(name || 'file').replace(/["\r\n\\/]/g, '').trim() || 'file'; const ascii = clean.replace(/[^\x20-\x7e]/g, '_'); if (ascii === clean) return `${type}; filename="${clean}"`; return `${type}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())}`; }; const sendBlobBuffer = (ctx, buffer) => { const size = buffer.length; const range = ctx.headers.range; if (range) { const match = /^bytes=(\d*)-(\d*)$/.exec(range); if (!match) { ctx.status = 416; ctx.set('Content-Range', `bytes */${size}`); return; } let start = match[1] ? parseInt(match[1], 10) : 0; let end = match[2] ? parseInt(match[2], 10) : size - 1; if (Number.isNaN(start) || start < 0) start = 0; if (Number.isNaN(end) || end >= size) end = size - 1; if (start > end || start >= size) { ctx.status = 416; ctx.set('Content-Range', `bytes */${size}`); return; } const chunk = buffer.slice(start, end + 1); ctx.status = 206; ctx.set('Content-Range', `bytes ${start}-${end}/${size}`); ctx.set('Accept-Ranges', 'bytes'); ctx.set('Content-Length', String(chunk.length)); ctx.body = chunk; } else { ctx.status = 200; ctx.set('Accept-Ranges', 'bytes'); ctx.set('Content-Length', String(size)); ctx.body = buffer; } }; const serveBlob = async function (ctx) { const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim(); const raw = decodeURIComponent(encodedParam); if (!raw) { ctx.status = 400; ctx.body = 'Invalid blob id'; return; } const blobId = raw.startsWith('&') ? raw : `&${raw}`; const ssbClient = await cooler.open(); try { await waitForBlob(ssbClient, blobId, 8000); } catch (err) { ctx.status = 504; ctx.body = 'Blob not available'; return; } let buffer; try { buffer = await new Promise((resolve, reject) => { pull( ssbClient.blobs.get(blobId), pull.collect((err, chunks) => { if (err) return reject(err); resolve(Buffer.concat(chunks)); }) ); }); } catch (err) { ctx.status = 500; ctx.body = 'Error reading blob'; return; } let mime = 'application/octet-stream'; try { const ft = await FileType.fromBuffer(buffer); if (ft && ft.mime) mime = ft.mime; } catch {} if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) { const head = buffer.slice(0, 512).toString('ascii'); if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent'; } if (mime === 'application/octet-stream' || mime === 'text/plain' || mime === 'application/xml' || mime === 'text/xml') { let head = buffer.slice(0, 512).toString('utf8'); if (head.charCodeAt(0) === 0xFEFF) head = head.slice(1); const trimmed = head.replace(/^\s+/, '').toLowerCase(); if (trimmed.startsWith('