feat: version 4.10.0 -- Conectores de datos externos y conector FoxPro

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-29 11:32:08 -05:00
parent 28444b1485
commit d0f90542e9
23 changed files with 945 additions and 9 deletions
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// Carga y valida config.json. Ruta: variable CONECTOR_CONFIG o <raíz>/config.json.
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
function fail(msg) {
throw new Error(`config.json: ${msg}`);
}
function upperList(list, where) {
if (list === undefined) return [];
if (!Array.isArray(list)) fail(`${where} debe ser una lista`);
return list.map((f) => String(f).trim().toUpperCase());
}
function normalizeTables(tables, where) {
// "*" = todas las .dbf de la carpeta; objeto = solo esas tablas (y opcionalmente solo esos campos).
if (tables === undefined || tables === '*') return '*';
if (typeof tables !== 'object' || Array.isArray(tables)) fail(`${where}.tables debe ser "*" o un objeto`);
const out = {};
for (const [name, rule] of Object.entries(tables)) {
if (!ID_RE.test(name)) fail(`${where}.tables: nombre inválido "${name}"`);
out[name.toUpperCase()] = { fields: rule && rule.fields ? upperList(rule.fields, `${where}.tables.${name}.fields`) : null };
}
return out;
}
function load() {
const file = process.env.CONECTOR_CONFIG || path.join(ROOT, 'config.json');
if (!fs.existsSync(file)) fail(`no existe ${file} (copie config.example.json)`);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
const tokens = Array.isArray(raw.tokenSha256) ? raw.tokenSha256 : [raw.tokenSha256];
if (!tokens.length || tokens.some((t) => !/^[a-f0-9]{64}$/i.test(String(t || '')))) {
fail('tokenSha256 debe ser el SHA-256 (hex) del token; genérelo con "npm run token"');
}
if (!Array.isArray(raw.allowIps) || !raw.allowIps.length) fail('allowIps es obligatorio (IPs o CIDR del backend)');
if (!Array.isArray(raw.sources) || !raw.sources.length) fail('sources debe tener al menos una carpeta');
const ids = new Set();
const sources = raw.sources.map((s, i) => {
const where = `sources[${i}]`;
if (!ID_RE.test(String(s.id || ''))) fail(`${where}.id inválido`);
if (ids.has(s.id)) fail(`${where}.id repetido: ${s.id}`);
ids.add(s.id);
if (!s.path || !fs.existsSync(s.path)) fail(`${where}.path no existe: ${s.path}`);
return {
id: s.id,
name: s.name || s.id,
path: path.resolve(s.path),
encoding: s.encoding || 'cp1252',
tables: normalizeTables(s.tables, where),
denyFields: upperList(s.denyFields, `${where}.denyFields`),
};
});
const limits = raw.limits || {};
return {
host: raw.host || '0.0.0.0',
port: Number(raw.port || 8455),
tls: raw.tls && raw.tls.pfx ? { pfx: raw.tls.pfx, passphraseEnv: raw.tls.passphraseEnv || 'CONECTOR_TLS_PASS' } : null,
tokenHashes: tokens.map((t) => String(t).toLowerCase()),
allowIps: raw.allowIps.map(String),
sources,
limits: {
maxPageSize: Number(limits.maxPageSize || 1000),
requestsPerMinute: Number(limits.requestsPerMinute || 120),
maxConcurrentReads: Number(limits.maxConcurrentReads || 2),
maxQueuedReads: Number(limits.maxQueuedReads || 10),
cacheMaxRecordsPerTable: Number(limits.cacheMaxRecordsPerTable || 100000),
cacheMaxTables: Number(limits.cacheMaxTables || 10),
requestTimeoutMs: Number(limits.requestTimeoutMs || 30000),
},
logDir: path.resolve(ROOT, raw.logDir || 'logs'),
};
}
module.exports = { load, ROOT, ID_RE };
+227
View File
@@ -0,0 +1,227 @@
'use strict';
// Lectura de tablas FoxPro en SOLO LECTURA. Nunca se abre el original para leer registros:
// se copia DBF+FPT a una carpeta temporal y se lee la copia (no choca con los bloqueos de FoxPro).
const fs = require('fs');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { DBFFile, DELETED } = require('dbffile');
const { ID_RE } = require('./config');
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// --- Resolución segura de archivos (el nombre llega del cliente; solo se acepta si existe en la carpeta) ---
async function dirIndex(source) {
const names = await fsp.readdir(source.path);
const map = new Map(); // MAYÚSCULAS -> nombre real
for (const n of names) map.set(n.toUpperCase(), n);
return map;
}
function tableAllowed(source, table) {
return source.tables === '*' || Object.prototype.hasOwnProperty.call(source.tables, table);
}
async function resolveTable(source, tableName) {
if (!ID_RE.test(tableName)) throw new HttpError(400, 'Nombre de tabla inválido');
const table = tableName.toUpperCase();
if (!tableAllowed(source, table)) throw new HttpError(404, 'Tabla no encontrada');
const index = await dirIndex(source);
const dbfName = index.get(`${table}.DBF`);
if (!dbfName) throw new HttpError(404, 'Tabla no encontrada');
const memoName = index.get(`${table}.FPT`) || index.get(`${table}.DBT`) || null;
return {
table,
dbfPath: path.join(source.path, dbfName),
memoPath: memoName ? path.join(source.path, memoName) : null,
};
}
function visibleFields(source, table, fields) {
const rule = source.tables === '*' ? null : source.tables[table];
const allow = rule && rule.fields ? new Set(rule.fields) : null;
const deny = new Set(source.denyFields);
return fields.filter((f) => {
const n = f.name.toUpperCase();
return !deny.has(n) && (!allow || allow.has(n));
});
}
// Firma del archivo: cambia cuando FoxPro escribe (sirve al sistema para saber si hay que resincronizar).
async function signature(ref) {
const a = await fsp.stat(ref.dbfPath);
const b = ref.memoPath ? await fsp.stat(ref.memoPath) : null;
const raw = `${a.size}:${a.mtimeMs}:${b ? `${b.size}:${b.mtimeMs}` : '-'}`;
return { version: crypto.createHash('sha1').update(raw).digest('hex').slice(0, 16), modifiedAt: new Date(Math.max(a.mtimeMs, b ? b.mtimeMs : 0)) };
}
function lockError(err) {
if (['EBUSY', 'EPERM', 'EACCES'].includes(err.code)) {
return new HttpError(423, 'La tabla está bloqueada por FoxPro (uso exclusivo); intente más tarde');
}
return err;
}
async function withSnapshot(ref, fn) {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'conector-fox-'));
try {
const dbfCopy = path.join(dir, 't.dbf');
try {
await fsp.copyFile(ref.dbfPath, dbfCopy);
if (ref.memoPath) await fsp.copyFile(ref.memoPath, path.join(dir, ref.memoPath.toLowerCase().endsWith('.dbt') ? 't.dbt' : 't.fpt'));
} catch (err) {
throw lockError(err);
}
// VFP con autoincrement/varchar (0x31/0x32) usa el mismo formato de memo que 0x30; la librería solo
// busca el .fpt para 0x30/0xF5, así que se normaliza el byte de versión EN LA COPIA.
const fd = await fsp.open(dbfCopy, 'r+');
try {
const b = Buffer.alloc(1);
await fd.read(b, 0, 1, 0);
if (b[0] === 0x31 || b[0] === 0x32) await fd.write(Buffer.from([0x30]), 0, 1, 0);
} finally {
await fd.close();
}
return await fn(dbfCopy);
} finally {
await fsp.rm(dir, { recursive: true, force: true });
}
}
function toPlain(value, type) {
if (value === null || value === undefined) return null;
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return null;
return type === 'D' ? value.toISOString().slice(0, 10) : value.toISOString();
}
if (typeof value === 'string') {
const s = value.replace(/\0+$/, '').trim();
return s === '' ? null : s;
}
return value;
}
// --- Caché por tabla (se invalida sola cuando cambia la firma) ---
const cache = new Map(); // `${source}/${table}` -> { version, fields, records }
function cachePut(key, entry, limits) {
cache.delete(key);
cache.set(key, entry);
while (cache.size > limits.cacheMaxTables) cache.delete(cache.keys().next().value);
}
async function loadTable(source, ref, limits) {
const key = `${source.id}/${ref.table}`;
const sig = await signature(ref);
const hit = cache.get(key);
if (hit && hit.version === sig.version) {
cache.delete(key);
cache.set(key, hit); // LRU
return hit;
}
const entry = await withSnapshot(ref, async (copy) => {
const dbf = await DBFFile.open(copy, { readMode: 'loose', encoding: source.encoding, includeDeletedRecords: true });
const fields = visibleFields(source, ref.table, dbf.fields);
const records = [];
let recno = 0;
for await (const r of dbf) {
recno += 1;
const row = { _recno: recno, _deleted: Boolean(r[DELETED]) };
for (const f of fields) row[f.name] = toPlain(r[f.name], f.type);
records.push(row);
}
return { version: sig.version, modifiedAt: sig.modifiedAt, fields, records };
});
if (entry.records.length <= limits.cacheMaxRecordsPerTable) cachePut(key, entry, limits);
return entry;
}
// --- Operaciones públicas ---
async function listTables(source) {
const index = await dirIndex(source);
const out = [];
for (const [upper] of index) {
if (!upper.endsWith('.DBF')) continue;
const table = upper.slice(0, -4);
if (!ID_RE.test(table) || !tableAllowed(source, table)) continue;
try {
const ref = await resolveTable(source, table);
const dbf = await DBFFile.open(ref.dbfPath, { readMode: 'loose', encoding: source.encoding }); // solo lee la cabecera
const sig = await signature(ref);
out.push({ name: table, records: dbf.recordCount, fields: visibleFields(source, table, dbf.fields).length, hasMemo: Boolean(ref.memoPath), version: sig.version, modifiedAt: sig.modifiedAt });
} catch (err) {
out.push({ name: table, error: lockError(err).status === 423 ? 'bloqueada' : 'no legible' });
}
}
return out.sort((a, b) => a.name.localeCompare(b.name));
}
async function getSchema(source, tableName) {
const ref = await resolveTable(source, tableName);
let dbf;
try {
dbf = await DBFFile.open(ref.dbfPath, { readMode: 'loose', encoding: source.encoding });
} catch (err) {
throw lockError(err);
}
const sig = await signature(ref);
return {
table: ref.table,
records: dbf.recordCount,
version: sig.version,
modifiedAt: sig.modifiedAt,
fields: visibleFields(source, ref.table, dbf.fields).map((f) => ({ name: f.name, type: f.type, size: f.size, decimals: f.decimalPlaces || 0 })),
};
}
function fold(s) {
return String(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toUpperCase();
}
async function readRows(source, tableName, q, limits) {
const ref = await resolveTable(source, tableName);
const data = await loadTable(source, ref, limits);
const known = new Set(data.fields.map((f) => f.name.toUpperCase()));
const pick = (list, what) => {
const names = String(list).split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
for (const n of names) if (!known.has(n)) throw new HttpError(400, `Campo desconocido en ${what}: ${n}`);
return names;
};
const fields = q.fields ? pick(q.fields, 'fields') : data.fields.map((f) => f.name);
const limit = Math.min(Math.max(parseInt(q.limit, 10) || 100, 1), limits.maxPageSize);
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
const includeDeleted = q.includeDeleted === '1' || q.includeDeleted === 'true';
const eq = Object.entries(q)
.filter(([k]) => k.startsWith('eq.'))
.map(([k, v]) => [pick(k.slice(3), 'eq')[0], fold(v).trim()]);
const text = q.q ? fold(q.q).trim() : '';
const qFields = text ? (q.qFields ? pick(q.qFields, 'qFields') : fields) : [];
const matches = data.records.filter((r) => {
if (!includeDeleted && r._deleted) return false;
for (const [f, v] of eq) if (r[f] === null || fold(r[f]).trim() !== v) return false;
if (text && !qFields.some((f) => r[f] !== null && fold(r[f]).includes(text))) return false;
return true;
});
const rows = matches.slice(offset, offset + limit).map((r) => {
const o = { _recno: r._recno };
if (includeDeleted) o._deleted = r._deleted;
for (const f of fields) o[f] = r[f];
return o;
});
return { table: ref.table, version: data.version, modifiedAt: data.modifiedAt, total: matches.length, offset, limit, fields, rows };
}
module.exports = { listTables, getSchema, readRows, HttpError };
+157
View File
@@ -0,0 +1,157 @@
'use strict';
// Conector FoxPro — API de solo lectura (contrato v1, ver ../README.md).
const fs = require('fs');
const http = require('http');
const https = require('https');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const config = require('./config');
const dbf = require('./dbf');
const pkg = require('../package.json');
const cfg = config.load();
const { HttpError } = dbf;
// --- Log de acceso (sin tokens ni valores de búsqueda: pueden ser datos personales) ---
fs.mkdirSync(cfg.logDir, { recursive: true });
function log(line) {
const day = new Date().toISOString().slice(0, 10);
fs.appendFile(path.join(cfg.logDir, `acceso-${day}.log`), `${new Date().toISOString()} ${line}\n`, () => {});
}
// --- IPs permitidas (IPv4 exacta o CIDR) ---
function ipToInt(ip) {
const p = ip.split('.').map(Number);
if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
return ((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0;
}
const allow = cfg.allowIps.map((rule) => {
const [base, bits = '32'] = rule.split('/');
const b = ipToInt(base);
const n = Number(bits);
if (b === null || !(n >= 0 && n <= 32)) throw new Error(`config.json: allowIps inválida: ${rule}`);
const mask = n === 0 ? 0 : (0xffffffff << (32 - n)) >>> 0;
return { net: (b & mask) >>> 0, mask };
});
function ipAllowed(ip) {
const v = ipToInt(ip);
return v !== null && allow.some((r) => ((v & r.mask) >>> 0) === r.net);
}
// --- Token Bearer (se guarda solo su SHA-256) ---
const hashes = cfg.tokenHashes.map((h) => Buffer.from(h, 'hex'));
function tokenOk(req) {
const m = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization || '');
if (!m) return false;
const h = crypto.createHash('sha256').update(m[1]).digest();
return hashes.some((x) => crypto.timingSafeEqual(x, h));
}
// --- Límite de peticiones por IP y de lecturas simultáneas (no cargar el servidor de SAGA) ---
const hits = new Map();
function rateOk(ip) {
const now = Date.now();
const w = hits.get(ip) || { start: now, n: 0 };
if (now - w.start >= 60000) {
w.start = now;
w.n = 0;
}
w.n += 1;
hits.set(ip, w);
return w.n <= cfg.limits.requestsPerMinute;
}
let active = 0;
const queue = [];
async function withReadSlot(fn) {
if (active >= cfg.limits.maxConcurrentReads) {
if (queue.length >= cfg.limits.maxQueuedReads) throw new HttpError(503, 'Conector ocupado; reintente');
await new Promise((resolve) => queue.push(resolve));
}
active += 1;
try {
return await fn();
} finally {
active -= 1;
const next = queue.shift();
if (next) next();
}
}
// --- Rutas ---
function findSource(id) {
const s = cfg.sources.find((x) => x.id === id);
if (!s) throw new HttpError(404, 'Fuente no encontrada');
return s;
}
async function route(parts, query) {
// parts: ['v1', ...]
if (parts[0] !== 'v1') throw new HttpError(404, 'No encontrado');
const [, a, sourceId, b, table, c] = parts;
if (a === 'health' && parts.length === 2) {
return { ok: true, connector: 'foxpro', contract: 'v1', version: pkg.version, time: new Date().toISOString() };
}
if (a !== 'sources') throw new HttpError(404, 'No encontrado');
if (parts.length === 2) {
return { sources: cfg.sources.map((s) => ({ id: s.id, name: s.name })) };
}
const source = findSource(sourceId);
if (parts.length === 4 && b === 'tables') return { source: source.id, tables: await withReadSlot(() => dbf.listTables(source)) };
if (parts.length === 6 && b === 'tables' && c === 'schema') return { source: source.id, ...(await withReadSlot(() => dbf.getSchema(source, table))) };
if (parts.length === 6 && b === 'tables' && c === 'rows') return { source: source.id, ...(await withReadSlot(() => dbf.readRows(source, table, query, cfg.limits))) };
throw new HttpError(404, 'No encontrado');
}
function send(res, status, body) {
const data = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(data),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
res.end(data);
}
async function handler(req, res) {
const t0 = Date.now();
const ip = String(req.socket.remoteAddress || '').replace(/^::ffff:/, '');
let status = 500;
let pathname = '-';
try {
if (!ipAllowed(ip)) throw new HttpError(403, 'Origen no permitido');
if (!rateOk(ip)) throw new HttpError(429, 'Demasiadas peticiones');
if (!tokenOk(req)) throw new HttpError(401, 'Token inválido');
if (req.method !== 'GET') throw new HttpError(405, 'Solo lectura (GET)');
const url = new URL(req.url, 'http://x');
pathname = url.pathname;
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
const body = await route(parts, Object.fromEntries(url.searchParams));
status = 200;
send(res, status, body);
} catch (err) {
status = err instanceof HttpError ? err.status : 500;
if (status === 500) log(`ERROR ${ip} ${pathname} ${err.stack || err}`);
send(res, status, { error: status === 500 ? 'Error interno del conector' : err.message });
} finally {
log(`${ip} ${req.method} ${pathname} ${status} ${Date.now() - t0}ms`);
}
}
const server = cfg.tls
? https.createServer({ pfx: fs.readFileSync(cfg.tls.pfx), passphrase: process.env[cfg.tls.passphraseEnv] }, handler)
: http.createServer(handler);
server.requestTimeout = cfg.limits.requestTimeoutMs;
server.headersTimeout = 10000;
server.listen(cfg.port, cfg.host, () => {
const msg = `conector foxpro ${pkg.version} escuchando en ${cfg.tls ? 'https' : 'http'}://${cfg.host}:${cfg.port}`;
log(msg);
console.log(msg);
});