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:
@@ -0,0 +1,4 @@
|
||||
config.json
|
||||
logs/
|
||||
node_modules/
|
||||
node/
|
||||
@@ -0,0 +1,44 @@
|
||||
# Conector FoxPro 1.0.0
|
||||
|
||||
API de solo lectura para tablas FoxPro/Visual FoxPro (`.dbf` con memo `.fpt`), según el contrato
|
||||
v1 de [`../README.md`](../README.md). Node.js sin servidor web adicional; única dependencia: `dbffile`.
|
||||
|
||||
- No abre los originales para leer registros: copia `DBF`+`FPT` a una carpeta temporal y lee la
|
||||
copia, así no choca con los bloqueos de SAGA. Si FoxPro tiene la tabla en uso exclusivo → `423`.
|
||||
- El índice `.cdx` no se usa (se recorre la tabla). Las tablas se guardan en caché y la caché se
|
||||
renueva sola cuando cambia el archivo.
|
||||
- Tipos soportados: C, N, F, Y, I, L, D, T, B, M. Otros (V, Q, W…) se omiten.
|
||||
|
||||
## Instalación en el Windows (`C:\api_project\foxpro`)
|
||||
|
||||
1. Copiar esta carpeta a `C:\api_project\foxpro` **con** `node_modules` (no requiere internet en el
|
||||
server). Sin `config.json` ni `logs` de otro equipo.
|
||||
2. Node: copiar `node.exe` portable (Node 20 LTS o superior, Windows x64) a
|
||||
`C:\api_project\foxpro\node\node.exe`. Así no hay que instalar Node en el servidor.
|
||||
3. Token: `node\node.exe scripts\generar-token.js`. El **TOKEN** se carga en el sistema; el
|
||||
**tokenSha256** va en `config.json`.
|
||||
4. `config.json` (a partir de `config.example.json`):
|
||||
- `sources`: una entrada por carpeta (`nomina`, `clientes`…). Usar `path` con doble `\\`.
|
||||
- `tables`: `"*"` (todas) o solo las tablas que se quieren exponer, con `fields` si se quiere
|
||||
limitar columnas. `denyFields` oculta columnas sensibles (sueldo, claves).
|
||||
- `encoding`: `cp1252` normalmente; si salen mal las tildes/eñes, probar `cp850`.
|
||||
- `allowIps`: IP del backend de producción (`192.168.222.3`) y la de desarrollo si aplica.
|
||||
5. Probar a mano: `node\node.exe src\server.js` y, desde el backend,
|
||||
`curl -H "Authorization: Bearer <TOKEN>" http://192.168.0.253:8455/v1/health`.
|
||||
6. Dejarlo como servicio (PowerShell como administrador):
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\instalar-servicio.ps1`
|
||||
- Crea la tarea `LinkedCorp-Conector-FoxPro` (arranque con Windows, reinicio si cae) y la regla
|
||||
de firewall del puerto 8455 solo para las IPs permitidas.
|
||||
- Recomendado: `-Usuario 'DOMINIO\svc_conector' -Clave '...'` con una cuenta que tenga **solo
|
||||
lectura** sobre las carpetas de datos (por defecto corre como SYSTEM).
|
||||
7. Desinstalar: `scripts\desinstalar-servicio.ps1`.
|
||||
|
||||
TLS opcional: en `config.json` → `tls.pfx` con la ruta del `.pfx` y la clave en la variable de
|
||||
entorno indicada en `tls.passphraseEnv`. Por la VPN WireGuard el tráfico ya va cifrado.
|
||||
|
||||
Logs: `logs\acceso-AAAA-MM-DD.log` (sin tokens ni valores buscados).
|
||||
|
||||
## Desarrollo
|
||||
|
||||
- `npm test`: crea una tabla DBF temporal, levanta el conector y valida el contrato (16 pruebas).
|
||||
- `npm run token`: genera token + hash.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"host": "0.0.0.0",
|
||||
"port": 8455,
|
||||
"tls": { "pfx": "", "passphraseEnv": "CONECTOR_TLS_PASS" },
|
||||
"tokenSha256": "PEGAR_AQUI_EL_SHA256_DE_npm_run_token",
|
||||
"allowIps": ["192.168.222.3", "192.168.222.40"],
|
||||
"sources": [
|
||||
{
|
||||
"id": "nomina",
|
||||
"name": "Nómina",
|
||||
"path": "C:\\Sistemas\\nomina",
|
||||
"encoding": "cp1252",
|
||||
"tables": "*",
|
||||
"denyFields": ["SUELDO", "SALARIO", "CLAVE", "PASSWORD"]
|
||||
},
|
||||
{
|
||||
"id": "clientes",
|
||||
"name": "Clientes SAGA",
|
||||
"path": "C:\\Sistemas\\saga",
|
||||
"encoding": "cp1252",
|
||||
"tables": { "CLIENTES": { "fields": ["CODIGO", "CEDULA", "NOMBRE", "TELEFONO", "EMAIL", "FECNAC"] } }
|
||||
}
|
||||
],
|
||||
"limits": {
|
||||
"maxPageSize": 1000,
|
||||
"requestsPerMinute": 120,
|
||||
"maxConcurrentReads": 2,
|
||||
"maxQueuedReads": 10,
|
||||
"cacheMaxRecordsPerTable": 100000,
|
||||
"cacheMaxTables": 10,
|
||||
"requestTimeoutMs": 30000
|
||||
},
|
||||
"logDir": "logs"
|
||||
}
|
||||
Generated
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "linkedcorp-conector-foxpro",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "linkedcorp-conector-foxpro",
|
||||
"version": "1.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"dbffile": "1.14.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dbffile": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/dbffile/-/dbffile-1.14.1.tgz",
|
||||
"integrity": "sha512-X5+w+H5J3hVsKPu1/WAWhe9JMoqe/9RojCRGIV1+4tyGkXv1p2rQ7L+vw8jtQWppWqGc7XLaz5O8sCSAeAfjaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "linkedcorp-conector-foxpro",
|
||||
"version": "1.0.0",
|
||||
"description": "Conector de solo lectura para tablas FoxPro (DBF/FPT) - contrato v1",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"token": "node scripts/generar-token.js",
|
||||
"test": "node scripts/prueba-local.js"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"dbffile": "1.14.1"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Detiene y quita la tarea y la regla de firewall del conector. No borra archivos.
|
||||
$Tarea = 'LinkedCorp-Conector-FoxPro'
|
||||
Stop-ScheduledTask -TaskName $Tarea -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $Tarea -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Get-NetFirewallRule -DisplayName 'LinkedCorp-Conector-FoxPro' -ErrorAction SilentlyContinue | Remove-NetFirewallRule
|
||||
Write-Host 'Conector desinstalado.'
|
||||
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
// Genera un token nuevo. El TOKEN se carga en el sistema (Configuración → Conexiones externas);
|
||||
// en config.json del conector va SOLO el SHA-256.
|
||||
const crypto = require('crypto');
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
console.log(`TOKEN (para el sistema, no se guarda aquí): ${token}`);
|
||||
console.log(`tokenSha256 (para config.json): ${hash}`);
|
||||
@@ -0,0 +1,42 @@
|
||||
# Instala el conector FoxPro como tarea programada que arranca con Windows y se reinicia si cae.
|
||||
# Ejecutar en PowerShell como administrador, desde C:\api_project\foxpro:
|
||||
# powershell -ExecutionPolicy Bypass -File .\scripts\instalar-servicio.ps1
|
||||
# Opcional: -Usuario 'DOMINIO\svc_conector' (cuenta con SOLO lectura sobre las carpetas de datos).
|
||||
param(
|
||||
[string]$Usuario = 'SYSTEM',
|
||||
[string]$Clave = '',
|
||||
[int]$Puerto = 8455,
|
||||
[string[]]$IpsPermitidas = @('192.168.222.3', '192.168.222.40')
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$App = Split-Path -Parent $PSScriptRoot
|
||||
$Tarea = 'LinkedCorp-Conector-FoxPro'
|
||||
|
||||
# node.exe portable dentro de la carpeta (no requiere instalar Node en el servidor); si no, el del sistema.
|
||||
$Node = Join-Path $App 'node\node.exe'
|
||||
if (-not (Test-Path $Node)) { $Node = (Get-Command node.exe -ErrorAction Stop).Source }
|
||||
if (-not (Test-Path (Join-Path $App 'config.json'))) { throw 'Falta config.json (copie config.example.json y complételo).' }
|
||||
|
||||
$accion = New-ScheduledTaskAction -Execute $Node -Argument "`"$App\src\server.js`"" -WorkingDirectory $App
|
||||
$disparador = New-ScheduledTaskTrigger -AtStartup
|
||||
$ajustes = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
|
||||
if (Get-ScheduledTask -TaskName $Tarea -ErrorAction SilentlyContinue) {
|
||||
Stop-ScheduledTask -TaskName $Tarea -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $Tarea -Confirm:$false
|
||||
}
|
||||
if ($Usuario -eq 'SYSTEM') {
|
||||
Register-ScheduledTask -TaskName $Tarea -Action $accion -Trigger $disparador -Settings $ajustes -User 'SYSTEM' -RunLevel Highest | Out-Null
|
||||
} else {
|
||||
Register-ScheduledTask -TaskName $Tarea -Action $accion -Trigger $disparador -Settings $ajustes -User $Usuario -Password $Clave | Out-Null
|
||||
}
|
||||
|
||||
# Firewall: el puerto solo acepta a las IPs del backend (además del filtro allowIps del propio conector).
|
||||
$Regla = 'LinkedCorp-Conector-FoxPro'
|
||||
Get-NetFirewallRule -DisplayName $Regla -ErrorAction SilentlyContinue | Remove-NetFirewallRule
|
||||
New-NetFirewallRule -DisplayName $Regla -Direction Inbound -Protocol TCP -LocalPort $Puerto `
|
||||
-RemoteAddress $IpsPermitidas -Action Allow | Out-Null
|
||||
|
||||
Start-ScheduledTask -TaskName $Tarea
|
||||
Write-Host "Conector instalado. Tarea: $Tarea Puerto: $Puerto IPs: $($IpsPermitidas -join ', ')"
|
||||
@@ -0,0 +1,124 @@
|
||||
'use strict';
|
||||
// Prueba de humo: crea una tabla DBF temporal, levanta el conector y valida el contrato v1.
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { DBFFile } = require('dbffile');
|
||||
|
||||
const PORT = 18455;
|
||||
const TOKEN = 'token-de-prueba';
|
||||
|
||||
async function main() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prueba-fox-'));
|
||||
const data = path.join(dir, 'nomina');
|
||||
fs.mkdirSync(data);
|
||||
|
||||
const dbf = await DBFFile.create(path.join(data, 'EMPLEADO.DBF'), [
|
||||
{ name: 'CEDULA', type: 'C', size: 13 },
|
||||
{ name: 'NOMBRE', type: 'C', size: 60 },
|
||||
{ name: 'CARGO', type: 'C', size: 40 },
|
||||
{ name: 'SUELDO', type: 'N', size: 10, decimalPlaces: 2 },
|
||||
{ name: 'INGRESO', type: 'D', size: 8 },
|
||||
{ name: 'ACTIVO', type: 'L', size: 1 },
|
||||
], { encoding: 'cp1252' });
|
||||
await dbf.appendRecords([
|
||||
{ CEDULA: '0102030405', NOMBRE: 'MUÑOZ PEÑA JOSÉ', CARGO: 'Contador', SUELDO: 1200.5, INGRESO: new Date('2020-03-15'), ACTIVO: true },
|
||||
{ CEDULA: '0911122233', NOMBRE: 'ANDRADE LUISA', CARGO: 'Recepción', SUELDO: 600, INGRESO: new Date('2023-01-02'), ACTIVO: true },
|
||||
{ CEDULA: '1700000001', NOMBRE: 'BORRADO TEST', CARGO: 'X', SUELDO: 1, INGRESO: null, ACTIVO: false },
|
||||
]);
|
||||
// Marcar el 3.er registro como borrado (byte '*' al inicio del registro), como hace FoxPro con DELETE.
|
||||
const buf = fs.readFileSync(path.join(data, 'EMPLEADO.DBF'));
|
||||
const headerLen = buf.readUInt16LE(8);
|
||||
const recLen = buf.readUInt16LE(10);
|
||||
buf[headerLen + recLen * 2] = 0x2a;
|
||||
fs.writeFileSync(path.join(data, 'EMPLEADO.DBF'), buf);
|
||||
fs.writeFileSync(path.join(data, 'OCULTA.DBF'), buf);
|
||||
|
||||
const cfgPath = path.join(dir, 'config.json');
|
||||
fs.writeFileSync(cfgPath, JSON.stringify({
|
||||
host: '127.0.0.1',
|
||||
port: PORT,
|
||||
tokenSha256: crypto.createHash('sha256').update(TOKEN).digest('hex'),
|
||||
allowIps: ['127.0.0.1'],
|
||||
sources: [{ id: 'nomina', path: data, encoding: 'cp1252', tables: { EMPLEADO: {} }, denyFields: ['SUELDO'] }],
|
||||
logDir: path.join(dir, 'logs'),
|
||||
}));
|
||||
|
||||
const srv = spawn(process.execPath, [path.join(__dirname, '..', 'src', 'server.js')], { env: { ...process.env, CONECTOR_CONFIG: cfgPath }, stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
await new Promise((resolve, reject) => {
|
||||
srv.stdout.once('data', resolve);
|
||||
srv.once('exit', (c) => reject(new Error(`el servidor salió con ${c}`)));
|
||||
});
|
||||
|
||||
const get = async (p, token = TOKEN, method = 'GET') => {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT}${p}`, { method, headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
return { status: r.status, body: await r.json() };
|
||||
};
|
||||
|
||||
let ok = 0;
|
||||
const check = async (name, fn) => {
|
||||
await fn();
|
||||
ok += 1;
|
||||
console.log(`OK ${name}`);
|
||||
};
|
||||
|
||||
try {
|
||||
await check('sin token → 401', async () => assert.strictEqual((await get('/v1/health', null)).status, 401));
|
||||
await check('token errado → 401', async () => assert.strictEqual((await get('/v1/health', 'otro')).status, 401));
|
||||
await check('POST → 405', async () => assert.strictEqual((await get('/v1/health', TOKEN, 'POST')).status, 405));
|
||||
await check('health', async () => assert.strictEqual((await get('/v1/health')).body.contract, 'v1'));
|
||||
await check('sources', async () => assert.deepStrictEqual((await get('/v1/sources')).body.sources.map((s) => s.id), ['nomina']));
|
||||
await check('tables solo permitidas', async () => {
|
||||
const t = (await get('/v1/sources/nomina/tables')).body.tables;
|
||||
assert.deepStrictEqual(t.map((x) => x.name), ['EMPLEADO']);
|
||||
});
|
||||
await check('tabla no permitida → 404', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/OCULTA/schema')).status, 404));
|
||||
await check('traversal → 400', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/..%2F..%2Fx/rows')).status, 400));
|
||||
await check('schema sin campo denegado', async () => {
|
||||
const s = (await get('/v1/sources/nomina/tables/empleado/schema')).body;
|
||||
assert.deepStrictEqual(s.fields.map((f) => f.name), ['CEDULA', 'NOMBRE', 'CARGO', 'INGRESO', 'ACTIVO']);
|
||||
});
|
||||
await check('rows: tildes, fecha, sin borrados', async () => {
|
||||
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows')).body;
|
||||
assert.strictEqual(r.total, 2);
|
||||
assert.strictEqual(r.rows[0].NOMBRE, 'MUÑOZ PEÑA JOSÉ');
|
||||
assert.strictEqual(r.rows[0].INGRESO, '2020-03-15');
|
||||
assert.strictEqual(r.rows[0].SUELDO, undefined);
|
||||
});
|
||||
await check('rows includeDeleted', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/EMPLEADO/rows?includeDeleted=1')).body.total, 3));
|
||||
await check('búsqueda sin tildes', async () => {
|
||||
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?q=pena%20jose&qFields=NOMBRE')).body;
|
||||
assert.strictEqual(r.total, 1);
|
||||
});
|
||||
await check('eq + fields', async () => {
|
||||
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?eq.CEDULA=0911122233&fields=CEDULA,CARGO')).body;
|
||||
assert.deepStrictEqual(r.rows, [{ _recno: 2, CEDULA: '0911122233', CARGO: 'Recepción' }]);
|
||||
});
|
||||
await check('pedir campo denegado → 400', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/EMPLEADO/rows?fields=SUELDO')).status, 400));
|
||||
await check('paginación', async () => {
|
||||
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?limit=1&offset=1')).body;
|
||||
assert.strictEqual(r.rows.length, 1);
|
||||
assert.strictEqual(r.rows[0]._recno, 2);
|
||||
});
|
||||
await check('versión cambia al modificar la tabla', async () => {
|
||||
const v1 = (await get('/v1/sources/nomina/tables/EMPLEADO/schema')).body.version;
|
||||
const f = await DBFFile.open(path.join(data, 'EMPLEADO.DBF'), { encoding: 'cp1252' });
|
||||
await f.appendRecords([{ CEDULA: '0000000001', NOMBRE: 'NUEVO', CARGO: 'Y', SUELDO: 2, INGRESO: null, ACTIVO: true }]);
|
||||
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows')).body;
|
||||
assert.notStrictEqual(r.version, v1);
|
||||
assert.strictEqual(r.total, 3);
|
||||
});
|
||||
console.log(`\n${ok} pruebas OK`);
|
||||
} finally {
|
||||
srv.kill();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FALLÓ:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user