/** * ============================================================ * sftp.js — Extractor SFTP inteligente de TomateSMP v2.0 * ============================================================ * Sistema de prioridades: CRÍTICO (2MB), ALTA (512KB), NORMAL (256KB). * Elimina ruido: UUIDs, logs viejos, chunks. * Genera .txt.gz con árbol + JARs + contenido por prioridad. */ import { readConfig } from './utils.js'; const config = readConfig(); // ── Prioridades ─────────────────────────────────────────────────────────────── const PRIORITY = { CRITICAL: 1, HIGH: 2, NORMAL: 3 }; const SIZE_LIMIT = { [PRIORITY.CRITICAL]: 2 * 1024 * 1024, [PRIORITY.HIGH] : 512 * 1024, [PRIORITY.NORMAL] : 256 * 1024, }; const MAX_TOTAL_CHARS = 20_000_000; const PARALLEL = 16; const MAX_DEPTH = 12; const TEXT_EXT = new Set([ '.yml','.yaml','.json','.toml','.conf','.cfg','.config', '.properties','.txt','.log','.md','.xml','.ini','.sh', '.js','.java','.py','.sql','.csv','.ts','.hocon', ]); const BINARY_EXT = new Set([ '.jar','.class','.png','.jpg','.jpeg','.gif','.webp','.ico', '.ogg','.wav','.mp3','.flac', '.zip','.gz','.tar','.7z','.rar','.bz2', '.mca','.mcapm','.nbt','.dat_old', '.ldb','.sst','.bson','.db','.sqlite','.h2', '.lock','.lck','.trc','.dmp','.so','.dll', ]); const SKIP_DIRS = new Set([ 'region','entities','poi','DIM-1','DIM1','DIM2','datapacks', 'cache','libraries','versions','bundler','META-INF', '.git','node_modules','crash-reports','debug', 'web','maps','assets','temp','tmp','generated', ]); const CRITICAL_PATHS = new Set([ 'server.properties','bukkit.yml','spigot.yml','paper.yml', 'paper-global.yml','paper-world-defaults.yml','commands.yml', 'permissions.yml','ops.json','whitelist.json', 'plugins/NotRanks/ranks.yml', 'plugins/LuckPerms/config.yml', 'plugins/Essentials/config.yml', 'plugins/Essentials/worth.yml', 'plugins/Essentials/kits.yml', 'plugins/EconomyShopGUI/config.yml', 'plugins/AuraSkills/config.yml', 'plugins/AuraSkills/skills.yml', 'plugins/ExcellentEnchants/config.yml', 'plugins/ExcellentCrates/config.yml', 'plugins/BattlePass/config.yml', 'plugins/Quests/config.yml', 'plugins/UltimateMobCoins/config.yml', 'plugins/MythicMobs/config.yml', 'plugins/LevelledMobs/rules.yml', 'plugins/LevelledMobs/customdrops.yml', 'plugins/Multiverse-Core/worlds.yml', 'plugins/WorldGuard/config.yml', 'plugins/ProtectionStones/config.yml', 'plugins/BetterRTP/config.yml', 'plugins/Duel/config.yml', 'plugins/Duel/arenas.yml', 'plugins/SH-Koth/config.yml', 'plugins/Vulcan/config.yml', 'plugins/AxMinions/config.yml', 'plugins/AlonsoChat/config.yml', 'plugins/AlonsoTags/tags.yml', 'plugins/TAB/config.yml', 'plugins/TAB/groups.yml', 'plugins/PlasmoVoice/config.yml', 'plugins/FancyNpcs/npcs.yml', 'plugins/FancyHolograms/holograms.yml', 'plugins/CommandPanels/panels.yml', 'plugins/AxRewards/config.yml', 'plugins/AxEnvoy/config.yml', 'plugins/zVoteParty/config.yml', 'plugins/Jobs/config.yml', 'plugins/Jobs/jobConfig.yml', 'plugins/AdvancedBan/config.yml', 'plugins/NexAuth/config.yml', ]); const HIGH_DIRS = [ 'plugins/NotRanks', 'plugins/ExcellentEnchants/enchants', 'plugins/ExcellentCrates/crates', 'plugins/EconomyShopGUI/shops', 'plugins/MythicMobs/Mobs', 'plugins/MythicMobs/Skills', 'plugins/MythicMobs/Items', 'plugins/Quests/quests', 'plugins/BattlePass', 'plugins/AuraSkills', 'plugins/LevelledMobs', 'plugins/AlonsoTags', 'plugins/Jobs/jobs', 'plugins/TAB', 'plugins/ConditionalEvents', 'plugins/CommandPanels', 'plugins/AxMinions/minions', 'plugins/SH-Koth', ]; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; const SKIP_PATTERNS = [ UUID_RE, /^\d+\.dat$/, /^session\.lock$/, /^level\.dat_old$/, /^uid\.dat$/, /^usercache\.json$/, /^banned-ips\.json$/, ]; function shouldSkipFile(filename, dirPath) { if (SKIP_PATTERNS.some(p => p.test(filename))) return true; if (dirPath.includes('/logs') && filename !== 'latest.log') return true; const playerDirs = ['/playerdata', '/stats', '/advancements']; if (playerDirs.some(d => dirPath.includes(d)) && UUID_RE.test(filename)) return true; return false; } function getPriority(absPath, root) { const rel = absPath.startsWith(root + '/') ? absPath.slice(root.length + 1) : absPath.replace(/^\//, ''); if (CRITICAL_PATHS.has(rel)) return PRIORITY.CRITICAL; const base = rel.split('/').pop(); if (base === 'plugin.yml') return PRIORITY.HIGH; if (/^messages.*\.yml$/.test(base)) return PRIORITY.HIGH; for (const d of HIGH_DIRS) { if (rel.startsWith(d + '/')) return PRIORITY.HIGH; } return PRIORITY.NORMAL; } // ── SFTP helpers ────────────────────────────────────────────────────────────── async function makeSFTP(cfg) { const { Client } = await import('ssh2'); return new Promise((resolve, reject) => { const conn = new Client(); conn.on('ready', () => { conn.sftp((err, sftp) => { if (err) { conn.end(); return reject(err); } resolve({ sftp, conn }); }); }); conn.on('error', reject); conn.connect({ host: cfg.host, port: cfg.port ?? 22, username: cfg.username, password: cfg.password, readyTimeout: 20000, algorithms: { serverHostKey: ['ssh-rsa','ecdsa-sha2-nistp256','ssh-ed25519'] }, }); }); } function ls(sftp, dir) { return new Promise(resolve => { sftp.readdir(dir, (err, list) => resolve(err ? [] : (list || []))); }); } function readFile(sftp, path, maxBytes) { return new Promise(resolve => { const chunks = []; let total = 0, truncated = false; const stream = sftp.createReadStream(path, { encoding: 'utf8' }); stream.on('data', chunk => { if (total + chunk.length <= maxBytes) { chunks.push(chunk); total += chunk.length; } else { const remaining = maxBytes - total; if (remaining > 0) chunks.push(chunk.slice(0, remaining)); total = maxBytes; truncated = true; stream.destroy(); } }); stream.on('close', () => resolve({ text: chunks.join(''), truncated, bytes: total })); stream.on('end', () => resolve({ text: chunks.join(''), truncated, bytes: total })); stream.on('error', () => resolve(null)); }); } async function detectRoot(sftp, preferred) { const candidates = [preferred, '/', '/home/container', '/data', '/server']; for (const c of [...new Set(candidates)]) { const entries = await ls(sftp, c); const names = entries.map(e => e.filename); const isMC = names.some(n => ['plugins','world','server.properties','paper.yml','spigot.yml','bukkit.yml'].includes(n)); if (isMC) return { root: c, entries }; if (entries.length > 3) return { root: c, entries }; } return { root: preferred, entries: [] }; } async function buildFileTree(sftp, root, rootEntries, onProgress) { const fileQueue = [], jarList = [], treeLines = []; let fileCount = 0; async function crawl(dir, depth) { if (depth > MAX_DEPTH) return; const entries = dir === root ? rootEntries : await ls(sftp, dir); const relDir = dir.startsWith(root) ? dir.slice(root.length) || '/' : dir; const indent = ' '.repeat(depth); const subdirs = entries.filter(e => (e.attrs.mode & 0o040000) && !SKIP_DIRS.has(e.filename) && !e.filename.startsWith('.')); const files = entries.filter(e => !(e.attrs.mode & 0o040000)); treeLines.push(`${indent}📁 ${relDir}/`); for (const f of files) { const ext = f.filename.includes('.') ? '.' + f.filename.split('.').pop().toLowerCase() : ''; const absPath = `${dir}/${f.filename}`; const isBin = BINARY_EXT.has(ext); const isTxt = TEXT_EXT.has(ext); if (ext === '.jar' && dir.endsWith('/plugins')) jarList.push({ name: f.filename, sizeKB: Math.round(f.attrs.size / 1024) }); const skip = isBin || !isTxt || shouldSkipFile(f.filename, dir); treeLines.push(`${indent} ${skip ? '○' : '●'} ${f.filename} (${(f.attrs.size/1024).toFixed(1)}KB)`); if (!skip) { fileQueue.push({ path: absPath, size: f.attrs.size, priority: getPriority(absPath, root) }); fileCount++; if (fileCount % 200 === 0) onProgress?.(`🌲 Indexando árbol: ${fileCount} archivos...`); } } for (let i = 0; i < subdirs.length; i += 4) { await Promise.all(subdirs.slice(i, i+4).map(d => crawl(`${dir}/${d.filename}`, depth+1))); } } await crawl(root, 0); fileQueue.sort((a, b) => a.priority - b.priority); return { fileQueue, jarList, treeLines }; } async function readAllFiles(sftp, fileQueue, onProgress) { const results = new Map(); const queue = [...fileQueue]; let done = 0, totalChars = 0, lastUpdate = Date.now(); const total = queue.length; const skipped = { overflow: 0, error: 0 }; async function worker() { while (queue.length > 0) { if (totalChars >= MAX_TOTAL_CHARS) { skipped.overflow += queue.length; queue.length = 0; break; } const item = queue.shift(); if (!item) break; const maxBytes = SIZE_LIMIT[item.priority] ?? SIZE_LIMIT[PRIORITY.NORMAL]; const res = await readFile(sftp, item.path, maxBytes); if (res) { results.set(item.path, { ...res, priority: item.priority }); totalChars += res.text.length; } else skipped.error++; done++; if (Date.now() - lastUpdate > 1500) { onProgress?.(`📖 ${done}/${total} archivos | ${(totalChars/1024/1024).toFixed(1)}MB leídos...`); lastUpdate = Date.now(); } } } await Promise.all(Array.from({ length: PARALLEL }, worker)); return { results, skipped, totalChars }; } function buildDocument(root, treeLines, jarList, results, stats) { const sep = '═'.repeat(70); const lines = [ `TOMATESMP — EXTRACCIÓN COMPLETA v2.0`, `Fecha: ${new Date().toISOString()}`, `Raíz: ${root} | Host: ${config.sftp.host}:${config.sftp.port ?? 22}`, `Archivos leídos: ${results.size} | Tamaño texto: ${(stats.totalChars/1024/1024).toFixed(2)}MB`, sep, '', '🗂️ ÁRBOL DE ARCHIVOS', '─'.repeat(70), treeLines.join('\n'), '', sep, `📦 PLUGINS INSTALADOS (${jarList.length} JARs detectados)`, '─'.repeat(70), jarList.map(j => ` ${j.name} (${j.sizeKB}KB)`).join('\n'), '', sep, '🔴 ARCHIVOS CRÍTICOS', sep, ]; for (const [path, { text, truncated, priority }] of results.entries()) { if (priority !== PRIORITY.CRITICAL) continue; lines.push(`\n━━━ ${path}${truncated ? ' [TRUNCADO]' : ''}\n${text}`); } lines.push('', sep, '🟡 ARCHIVOS DE ALTA PRIORIDAD', sep); for (const [path, { text, truncated, priority }] of results.entries()) { if (priority !== PRIORITY.HIGH) continue; lines.push(`\n━━━ ${path}${truncated ? ' [TRUNCADO]' : ''}\n${text}`); } lines.push('', sep, '⚪ RESTO DE ARCHIVOS', sep); for (const [path, { text, truncated, priority }] of results.entries()) { if (priority !== PRIORITY.NORMAL) continue; lines.push(`\n━━━ ${path}${truncated ? ' [TRUNCADO]' : ''}\n${text}`); } lines.push('', sep, `FIN DE EXTRACCIÓN — ${results.size} archivos procesados`); return lines.join('\n'); } export async function extractServerInfo(onProgress, overridePassword = null) { const cfg = config.sftp; if (!cfg?.host || !cfg?.username || !cfg?.password) throw new Error('Credenciales SFTP no configuradas'); const effectiveCfg = overridePassword ? { ...cfg, password: overridePassword } : cfg; onProgress?.('🔌 Conectando...'); const { sftp, conn } = await makeSFTP(effectiveCfg); try { onProgress?.('🔍 Detectando raíz del servidor...'); const { root, entries: rootEntries } = await detectRoot(sftp, cfg.rootPath ?? '/home/container'); onProgress?.(`📂 Raíz: \`${root}\` — construyendo árbol de archivos...`); const { fileQueue, jarList, treeLines } = await buildFileTree(sftp, root, rootEntries, onProgress); const critical = fileQueue.filter(f => f.priority === PRIORITY.CRITICAL).length; const high = fileQueue.filter(f => f.priority === PRIORITY.HIGH).length; onProgress?.(`📋 ${fileQueue.length} archivos indexados — ${critical} críticos, ${high} alta prioridad, ${jarList.length} JARs`); const { results, skipped, totalChars } = await readAllFiles(sftp, fileQueue, onProgress); onProgress?.(`📦 Listo — ${results.size} archivos leídos (${(totalChars/1024/1024).toFixed(1)}MB) — comprimiendo...`); const document = buildDocument(root, treeLines, jarList, results, { totalChars, skipped }); const { gzipSync } = await import('zlib'); const compressed = gzipSync(Buffer.from(document, 'utf8'), { level: 9 }); return { buffer : compressed, filename: `tomatesmp_${new Date().toISOString().slice(0,10)}.txt.gz`, stats : { files : results.size, critical: [...results.values()].filter(r => r.priority === PRIORITY.CRITICAL).length, high : [...results.values()].filter(r => r.priority === PRIORITY.HIGH).length, normal : [...results.values()].filter(r => r.priority === PRIORITY.NORMAL).length, plugins : jarList.length, skipped : skipped.overflow + skipped.error, errors : skipped.error, sizeMB : (totalChars / 1024 / 1024).toFixed(2), }, sizeKB : Math.round(compressed.length / 1024), }; } finally { conn.end(); } }