diff --git a/AGENTS.md b/AGENTS.md index ff5951a..3b1bb00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,8 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - Notificaciones in-app - Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`) - Configuración (catálogos: estados, prioridades, tipos de proyecto) +- Conexiones externas (en curso): datos de otros sistemas vía conectores de solo lectura en el + servidor origen (`data/conectores/`, primero FoxPro) — ver `docs/CONEXIONES-EXTERNAS.md` ## Restricciones tecnicas @@ -169,7 +171,8 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - `PRODUCCION.md`, `DEPLOY_WEB.md` (topología y flujo de deploy — arquetipo B, en producción) - `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md`, `docs/ARQUITECTURA-INVENTARIO.md` - `docs/SESION-Y-BORRADORES.md` (vencimiento de sesión, retorno al contexto, borradores y avisos al salir; usar `useDraftForm` en todo formulario nuevo) -- `ESTADO.md` (handoff — leer siempre al retomar el proyecto) +- `docs/CONEXIONES-EXTERNAS.md` (requisitos y diseño de datos externos) y `data/conectores/README.md` (contrato v1 de conectores) +- `ESTADO.md` (handoff — leer siempre al retomar el proyecto) y `DECISIONES.md` (decisiones acordadas) ## Pendientes para quedar listo diff --git a/CHANGELOG.md b/CHANGELOG.md index a3fe06e..2bf049c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog - project.linkedcorp.net +## [4.10.0] - 2026-10-29 +### Added +- Conectores de datos externos (contrato v1, `data/conectores/README.md`): API HTTP de solo lectura + que se instala en el servidor origen; el sistema no guarda credenciales del origen. +- Conector FoxPro 1.0.0 (`data/conectores/foxpro/`): lee DBF/FPT desde una copia temporal (no + bloquea a SAGA), token SHA-256, IPs permitidas, tablas/campos permitidos y denegados, límites de + carga, scripts de instalación como tarea de Windows (`C:\api_project\foxpro`). 16 pruebas locales. +- Documentación: `docs/CONEXIONES-EXTERNAS.md` (requisitos y diseño) y `DECISIONES.md`. + Sin cambios en la aplicación ni migración. + ## [4.9.1] - 2026-10-29 ### Fixed - Agenda médica: ahora usa el mismo calendario que Calendario (barra Hoy/anterior/siguiente, vistas diff --git a/DECISIONES.md b/DECISIONES.md new file mode 100644 index 0000000..8e83738 --- /dev/null +++ b/DECISIONES.md @@ -0,0 +1,12 @@ +# DECISIONES - project.linkedcorp.net + +> Registro de decisiones acordadas (qué, por qué, alternativas descartadas). Se agrega una entrada cada vez +> que se acuerda algo que cambia cómo se construye el proyecto. No se borran: si cambia, se agrega una +> nueva que la reemplaza. Se versiona en Git. + +| # | Fecha | Decisión | Por qué | Descartado | Estado | +|---|---|---|---|---|---| +| 1 | 2026-09-03 | Identidad UI Windows/Fluent (`docs/UI-UX-WINDOWS.md`) en vez de la identidad web LinkedCorp | Decisión explícita del usuario al arrancar el proyecto | `UI-UX-LINKEDCORP.md` | vigente | +| 2 | 2026-10-29 | Datos de sistemas externos vía **conectores** (API HTTP de solo lectura instalada en el servidor origen, contrato v1 en `data/conectores/README.md`); el primero es FoxPro | Más seguro (el sistema no guarda credenciales del origen; el origen decide qué tablas/campos salen) y escalable (otra tecnología = otro conector, mismo contrato) | Conexión directa desde el backend por SMB/SFTP a los DBF o con usuario de BD; ODBC de FoxPro | vigente | +| 3 | 2026-10-29 | Conector FoxPro en Node con `dbffile`, `node.exe` portable en `C:\api_project\foxpro`, tarea programada al arranque; lee una copia temporal de DBF+FPT | Mismo stack del proyecto, sin instalar nada en el server, no choca con los bloqueos de SAGA | Servicio .NET/Go; abrir los DBF originales; usar índices CDX | vigente | +| 4 | 2026-10-29 | Por fuente, el superadmin elige **importar** o **consulta en vivo**; empleados de nómina siempre se importan con botón "Actualizar empleados" (admin y superadmin) | Pedido del usuario (ver `docs/CONEXIONES-EXTERNAS.md`) | — | vigente | diff --git a/ESTADO.md b/ESTADO.md index fce507f..13b04cb 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -1,9 +1,18 @@ # ESTADO — project.linkedcorp.net -Ultima actualizacion: 2026-10-28 (v4.7.0 en archivos, sin commit ni desplegar) +Ultima actualizacion: 2026-10-29 (v4.10.0 commit local, sin push ni desplegar) Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo + autorregistro/aprobacion/SMTP. +## Conexiones externas (2026-10-29, en curso, sin commit) + +- Diseño y requisitos acordados en `docs/CONEXIONES-EXTERNAS.md`; decisiones #2–#4 en `DECISIONES.md`. +- Hecho: conector FoxPro 1.0.0 en `data/conectores/foxpro/` (API solo lectura, contrato v1 en + `data/conectores/README.md`). `npm test` 16/16 OK con DBF temporal. Sin probar con tablas reales + de SAGA ni los `.ps1` en Windows. +- Siguiente: con permiso del usuario, revisar por SSH `192.168.0.253` las carpetas de nómina y + clientes; instalar en `C:\api_project\foxpro`; luego el módulo del sistema. + ## Agenda médica (v4.7.0, sin desplegar) - v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre diff --git a/VERSION b/VERSION index 5b341fd..2da4316 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.9.1 +4.10.0 diff --git a/backend/package-lock.json b/backend/package-lock.json index 0f54f2d..c77dcbe 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-backend", - "version": "4.9.1", + "version": "4.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-backend", - "version": "4.9.1", + "version": "4.10.0", "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 1479d9e..cc558d7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.9.1", + "version": "4.10.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/data/conectores/README.md b/data/conectores/README.md new file mode 100644 index 0000000..d3a7677 --- /dev/null +++ b/data/conectores/README.md @@ -0,0 +1,50 @@ +# Conectores de datos externos — contrato v1 + +El sistema **no se conecta directo** a las bases de datos de los clientes. En cada servidor origen +se instala un **conector**: una API HTTP de solo lectura que expone tablas elegidas. El sistema +habla siempre el mismo contrato, sin importar la tecnología de origen (FoxPro, SQL Server, Excel…). + +| Conector | Carpeta | Estado | +|---|---|---| +| FoxPro (DBF/FPT/CDX) | `foxpro/` | 1.0.0 | + +## Reglas que todo conector debe cumplir + +1. **Solo lectura.** Únicamente `GET`; cualquier otro método → `405`. Nunca escribe en el origen. +2. **Token Bearer** (`Authorization: Bearer `). El conector guarda solo el SHA-256; el + sistema guarda el token cifrado (AES-256-GCM). Se admiten varios hashes para rotar sin cortar. +3. **Lista de IPs permitidas** (backend del sistema), en el conector y en el firewall del servidor. +4. **El administrador del origen decide qué sale:** tablas permitidas, campos permitidos y campos + denegados (sueldos, claves…). Lo no permitido no existe para el sistema (`404`/`400`). +5. **Sin SQL ni rutas libres:** tablas y campos se validan contra la lista real del origen. +6. **Protege al origen:** límite de peticiones por minuto, lecturas simultáneas, tamaño de página + y tiempo máximo por petición. Responde `429`/`503` en vez de cargar el servidor. +7. **Log de acceso** sin tokens ni valores buscados (pueden ser datos personales). + +## Endpoints + +| Método y ruta | Respuesta | +|---|---| +| `GET /v1/health` | `{ ok, connector, contract: "v1", version, time }` | +| `GET /v1/sources` | `{ sources: [{ id, name }] }` — carpetas/bases configuradas | +| `GET /v1/sources/:source/tables` | `{ tables: [{ name, records, fields, version, modifiedAt }] }` | +| `GET /v1/sources/:source/tables/:table/schema` | `{ table, records, version, modifiedAt, fields: [{ name, type, size, decimals }] }` | +| `GET /v1/sources/:source/tables/:table/rows` | `{ table, version, modifiedAt, total, offset, limit, fields, rows }` | + +Parámetros de `rows`: + +- `fields=A,B` columnas a devolver (por defecto todas las permitidas). +- `limit` (1…máximo del conector, def. 100) y `offset` para paginar. +- `q=texto` y `qFields=A,B`: contiene, sin distinguir mayúsculas ni tildes. +- `eq.CAMPO=valor`: igualdad exacta (sin distinguir mayúsculas). Se pueden combinar varios. +- `includeDeleted=1`: incluye los registros marcados como borrados (`_deleted`). + +Cada fila trae `_recno` (posición en el origen). `version` cambia cuando el origen se modifica: +el sistema la compara para saber si hace falta volver a sincronizar. + +Tipos de dato en JSON: texto recortado (vacío → `null`), números, booleanos, fecha `YYYY-MM-DD`, +fecha-hora ISO 8601. + +Errores: `{ error: "mensaje" }` con `400` parámetro inválido, `401` token, `403` IP, `404` +fuente/tabla, `405` método, `423` tabla bloqueada en el origen, `429` exceso de peticiones, +`503` conector ocupado, `500` error interno (detalle solo en el log del conector). diff --git a/data/conectores/foxpro/.gitignore b/data/conectores/foxpro/.gitignore new file mode 100644 index 0000000..9411296 --- /dev/null +++ b/data/conectores/foxpro/.gitignore @@ -0,0 +1,4 @@ +config.json +logs/ +node_modules/ +node/ diff --git a/data/conectores/foxpro/README.md b/data/conectores/foxpro/README.md new file mode 100644 index 0000000..c71acae --- /dev/null +++ b/data/conectores/foxpro/README.md @@ -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 " 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. diff --git a/data/conectores/foxpro/config.example.json b/data/conectores/foxpro/config.example.json new file mode 100644 index 0000000..31b21a3 --- /dev/null +++ b/data/conectores/foxpro/config.example.json @@ -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" +} diff --git a/data/conectores/foxpro/package-lock.json b/data/conectores/foxpro/package-lock.json new file mode 100644 index 0000000..a3be08a --- /dev/null +++ b/data/conectores/foxpro/package-lock.json @@ -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" + } + } +} diff --git a/data/conectores/foxpro/package.json b/data/conectores/foxpro/package.json new file mode 100644 index 0000000..34d3256 --- /dev/null +++ b/data/conectores/foxpro/package.json @@ -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 +} diff --git a/data/conectores/foxpro/scripts/desinstalar-servicio.ps1 b/data/conectores/foxpro/scripts/desinstalar-servicio.ps1 new file mode 100644 index 0000000..969a9a4 --- /dev/null +++ b/data/conectores/foxpro/scripts/desinstalar-servicio.ps1 @@ -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.' diff --git a/data/conectores/foxpro/scripts/generar-token.js b/data/conectores/foxpro/scripts/generar-token.js new file mode 100644 index 0000000..523f1b3 --- /dev/null +++ b/data/conectores/foxpro/scripts/generar-token.js @@ -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}`); diff --git a/data/conectores/foxpro/scripts/instalar-servicio.ps1 b/data/conectores/foxpro/scripts/instalar-servicio.ps1 new file mode 100644 index 0000000..bfcbdc0 --- /dev/null +++ b/data/conectores/foxpro/scripts/instalar-servicio.ps1 @@ -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 ', ')" diff --git a/data/conectores/foxpro/scripts/prueba-local.js b/data/conectores/foxpro/scripts/prueba-local.js new file mode 100644 index 0000000..bddaf91 --- /dev/null +++ b/data/conectores/foxpro/scripts/prueba-local.js @@ -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); +}); diff --git a/data/conectores/foxpro/src/config.js b/data/conectores/foxpro/src/config.js new file mode 100644 index 0000000..0931946 --- /dev/null +++ b/data/conectores/foxpro/src/config.js @@ -0,0 +1,81 @@ +'use strict'; +// Carga y valida config.json. Ruta: variable CONECTOR_CONFIG o /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 }; diff --git a/data/conectores/foxpro/src/dbf.js b/data/conectores/foxpro/src/dbf.js new file mode 100644 index 0000000..1019c76 --- /dev/null +++ b/data/conectores/foxpro/src/dbf.js @@ -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 }; diff --git a/data/conectores/foxpro/src/server.js b/data/conectores/foxpro/src/server.js new file mode 100644 index 0000000..8ae7e3a --- /dev/null +++ b/data/conectores/foxpro/src/server.js @@ -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); +}); diff --git a/docs/CONEXIONES-EXTERNAS.md b/docs/CONEXIONES-EXTERNAS.md new file mode 100644 index 0000000..8eff221 --- /dev/null +++ b/docs/CONEXIONES-EXTERNAS.md @@ -0,0 +1,61 @@ +# Conexiones externas (fuentes de datos de otros sistemas) + +> Leer cuando: se trabaje en traer/consultar datos de sistemas externos (pacientes, empleados de +> nómina, FoxPro u otra tecnología). No leer si: la tarea no toca datos externos. +> Contrato técnico de los conectores: [`data/conectores/README.md`](../data/conectores/README.md). + +Estado (2026-10-29): **conector FoxPro 1.0.0 hecho** (`data/conectores/foxpro/`, 16 pruebas locales +OK, sin probar con tablas reales ni en Windows). **Módulo del sistema: pendiente** (solo diseño). + +## Arquitectura + +El sistema **no se conecta directo** a las bases de origen. En cada servidor origen se instala un +**conector** (API HTTP de solo lectura) y el sistema habla con él por un contrato único (v1). +Para otra tecnología (SQL Server, Excel, etc.) se desarrolla otro conector con el mismo contrato; +el sistema no cambia. Ver decisión #2 en `DECISIONES.md`. + +- Primer caso: Exilaser, Windows Server `192.168.0.253` (SAGA/SistViso en FoxPro), alcanzado por + la VPN WireGuard. Conector en `C:\api_project\foxpro`, puerto `8455`. +- Fuentes previstas: `Sistemas\nomina` (empleados) y la tabla de clientes de SAGA (pacientes de la + Agenda médica). Nombres reales de tablas y campos: **por confirmar** en el servidor. + +## Requisitos acordados con el usuario + +1. **Dos modos por fuente** (lo elige el superadmin): **importar** (copiar a nuestra BD) o + **consulta en vivo** (leer siempre del origen). En consulta en vivo, al agendar se guarda una + copia mínima del paciente porque las citas necesitan el registro local. +2. **Formas de conexión que no comprometan al origen** (superadmin): todo pasa por el conector de + solo lectura; el sistema no guarda credenciales del origen, solo el token del conector (cifrado + AES-256-GCM, nunca vuelve al navegador). +3. **Mapeo en dos pasos** (superadmin): 1) conexión al servidor (URL del conector + token, botón + Probar); 2) qué fuente/tabla usar y a qué destino (Pacientes, Empleados). +4. **Importación al configurar o solo consultas**: al terminar la configuración se ofrece + "Importar ahora" o dejarla solo para consultas. +5. **Diseño por drivers/conectores** con la misma interfaz (aprobado). +6. **Empleados de nómina**: se importan a una tabla local de empleados (clave: cédula; los que + desaparecen del origen quedan inactivos, no se borran). Botón **"Actualizar empleados"** a + pedido, visible para **admin y superadmin**. Debe funcionar con cualquier tipo de origen. +7. **Vista previa**: el superadmin ve las columnas y filas reales de la tabla y elige qué + columnas usar en el sistema. +8. **Crear usuario desde nómina**: en Organización → Usuarios → Nuevo, opción de elegir un empleado + de la lista importada; precarga nombre, correo, cargo y departamento si están mapeados, y deja + el usuario vinculado. No se ofrecen empleados que ya tienen usuario. + +## Seguridad (resumen) + +- En el conector: solo `GET`, token (se guarda solo el SHA-256), IPs permitidas en el conector y en + el firewall de Windows, tablas/campos permitidos y denegados (sueldos, claves), sin rutas ni SQL + libres, límites de peticiones/concurrencia/página/tiempo, log sin tokens ni valores buscados. +- FoxPro: se lee una **copia temporal** de DBF+FPT, nunca el original (no bloquea a SAGA). +- En el sistema: configuración solo superadmin; "Actualizar empleados" admin/superadmin; auditoría + de pruebas de conexión, importaciones y consultas. + +## Pendientes + +- [ ] Revisar en el server (SSH, solo lectura, con permiso del usuario) las carpetas de nómina y + clientes: tablas, campos, versión de los DBF y codificación (cp1252/cp850). +- [ ] Instalar el conector en `C:\api_project\foxpro` (node.exe portable + `config.json` + token + + `instalar-servicio.ps1`) y probar desde el backend. +- [ ] Módulo del sistema: migración (conexiones, fuentes/mapeos, empleados, vínculo usuario↔empleado), + backend `modules/integrations`, pantalla en Configuración, botón en Organización, alta de + usuario desde nómina, búsqueda de pacientes externa en la Agenda. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bddff64..e6aa2c8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-frontend", - "version": "4.9.1", + "version": "4.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-frontend", - "version": "4.9.1", + "version": "4.10.0", "dependencies": { "dompurify": "^3.4.16", "highlight.js": "^11.12.0", diff --git a/frontend/package.json b/frontend/package.json index 3f318d6..9f7d89d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.9.1", + "version": "4.10.0", "type": "module", "scripts": { "dev": "vite",