diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ed5067e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash(xargs ls:*)", + "Bash(npm install:*)", + "Bash(node -e \"require\\(''''c:/CPANEL/PANEL_BASES_ANEXO24/node_modules/exceljs''''\\)\")", + "Bash(docker-compose up:*)", + "Bash(npx svelte-check *)", + "Bash(grep -rnE \"SERVER=|Server=|,{port}|:{port}|adjust_server_for_docker|conn_str|connection_string|pyodbc.connect|DRIVER=\" backend/api/v1/modules/scaii/contribuyente_legacy/service.py)", + "Bash(node_modules/.bin/vitest run *)", + "Bash(npm run *)", + "Bash(npx vitest *)", + "Bash(xargs -I{} sh -c 'echo \"--- {} ---\"; head -25 {}')", + "Bash(node -e \"require\\('archiver'\\)\")", + "Bash(node -e \"console.log\\(require\\('@types/archiver/package.json'\\).version\\)\")", + "Bash(grep -vE \"checkOrigin|deprecated|^$|svelte-kit sync|> \")", + "Bash(grep -vE \"checkOrigin|deprecated|^$|svelte-kit sync|^> \")" + ] + } +} diff --git a/.env.example b/.env.example index 0019580..7ce9eb9 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,25 @@ -# SQL Server - Servidor Primary (.202) -DB_PRIMARY_HOST=104.197.7.202 -DB_PRIMARY_USER=sa -DB_PRIMARY_PASS=Clave.2025 -DB_PRIMARY_DB=master +# Credenciales SQL Server (mismo usuario en todos los nodos salvo sql_password en database_nodes). +# Si no defines PANEL_MSSQL_*, se usan DB_PRIMARY_* / DB_SECONDARY_* como respaldo. +PANEL_MSSQL_USER=sa +PANEL_MSSQL_PASSWORD=Clave.2025 +# En contenedor Docker: reescribe localhost en server_name → host.docker.internal +# PANEL_MSSQL_DOCKER=true +# Depuración de bases duplicadas: tolerancia de tamaño (fracción 0–1) para marcar una base como +# segura para borrar del servidor viejo. El nuevo debe pesar >= (1 - tolerancia) del viejo. Default 0.2. +# PANEL_DEDUP_SIZE_TOLERANCE=0.2 +# "Mandar al nuevo": carpeta donde el SQL viejo escribe el .bak (default: data_folder del servidor). +# PANEL_DEDUP_BACKUP_FOLDER= +# Ventana de verificación tras enviar (ms) antes de dar la base por "en tránsito". Default 180000. +# PANEL_DEDUP_MOVE_VERIFY_TIMEOUT_MS=180000 +# Intervalo de sondeo de la verificación (ms). Default 5000. +# PANEL_DEDUP_MOVE_POLL_MS=5000 +# requestTimeout de SQL Server para BACKUP/DROP (el default de node-mssql, 15 s, no alcanza para +# bases reales). Default 3600000 (1 h). +# PANEL_DEDUP_DDL_TIMEOUT_MS=3600000 -# SQL Server - Servidor Secondary (.152) -DB_SECONDARY_HOST=104.192.7.152 -DB_SECONDARY_USER=sa -DB_SECONDARY_PASS=Clave.2025 -DB_SECONDARY_DB=master - -# SQL Server - Azure/CONTROLDESK -DB_AZURE_HOST=104.192.7.152 -DB_AZURE_USER=sa -DB_AZURE_PASS=Clave.2025 -DB_AZURE_DB=CONTROLDESK - -# PostgreSQL - Base de datos de usuarios (Docker local) +# PostgreSQL - Usuarios del panel + catálogo ControlDesk (tablas a24c.* las crea otra app) DB_POSTGRES_HOST=localhost -DB_POSTGRES_PORT=5432 +DB_POSTGRES_PORT=5434 DB_POSTGRES_USER=postgres DB_POSTGRES_PASS=Control. DB_POSTGRES_DB=CONTROLDESK @@ -26,5 +27,38 @@ DB_POSTGRES_DB=CONTROLDESK # JWT Secret (cambiar en producción) JWT_SECRET=change-this-secret-in-production-please-use-a-long-random-string -# Ruta de backups +# Ruta de respaldos (LEGACY / fallback): carpeta única leída por la vista "Respaldos +# Almacenados" y por /backup?file=... cuando no se especifica restaurador. Las vistas nuevas +# "Respaldos Restaurados" y "Restores Fallidos" resuelven la carpeta de CADA restaurador de +# forma relativa (derivada de la Entrada que reporta cada uno), sin usar esta variable. +# Apúntala a una carpeta de procesados accesible por filesystem (local o share) si usas el modo legacy. BACKUP_PATH=D:/BackupSFTP/ + +# SMTP para envío de avisos de alertas críticas +# Si SMTP_HOST está vacío los botones "Enviar avisos" no hacen nada (sin error). +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +SMTP_USE_TLS=true + +# Integración CloudRestoreAS +# Token Bearer que CloudRestoreAS envía en Authorization: Bearer +# para /api/restore/target-for, /api/restore/job-result y /api/restore/instance-config. +# DEBE ser idéntico al valor "Token API del PANEL" en CloudRestoreAS (pestaña Config). +# Generar uno largo y aleatorio, p. ej.: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +CLOUDRESTORE_API_TOKEN= + +# Clave compartida con a24c para cifrar la contraseña SQL de los nodos en reposo. +# El panel cifra en Fernet (AES-128-CBC + HMAC) derivando la clave como sha256(SECRET_KEY), +# EXACTAMENTE igual que a24c, para que a24c pueda descifrar database_nodes.sql_password. +# ⚠️ DEBE ser idéntica a la SECRET_KEY del backend de a24c, o a24c no podrá conectar. +SECRET_KEY= + +# [LEGADO] Clave AES-256 (32 bytes) del formato antiguo `gcm:` del panel. Solo se usa para +# LEER credenciales cifradas antes de migrar a Fernet; las nuevas se escriben con SECRET_KEY. +# Puede quedar vacía en instalaciones nuevas. Generar (si aplica) con: +# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +ENCRYPTION_KEY= diff --git a/.gitignore b/.gitignore index cc90b69..8d4b112 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,14 @@ build # Docker docker-compose.override.yml + +# Respaldos sintéticos para pruebas locales +local-backups/ + +# Certificados y llaves privadas — nunca en git +certs/ +*.key +*.pem +*.crt +*.p12 +*.pfx diff --git a/Dockerfile b/Dockerfile index 5018da0..51b20a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,11 +27,13 @@ WORKDIR /app COPY --from=builder /app/build build/ COPY --from=builder /app/node_modules node_modules/ COPY --from=builder /app/server.js . +COPY --from=builder /app/scripts scripts/ COPY --from=builder /app/certs certs/ COPY package.json . +RUN chmod +x /app/scripts/docker-entrypoint.sh + # Expose HTTPS port EXPOSE 3000 -# Start the application with HTTPS -CMD ["node", "server.js"] +ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..b28469e --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,258 @@ +pipeline { + agent any + options { + timestamps() + disableConcurrentBuilds() + } + + parameters { + choice(name: 'ENVIRONMENT', choices: ['development', 'main'], description: 'Rama a ejecutar') + string(name: 'VERSION_MAYOR', defaultValue: '1', description: 'Componente mayor de versión') + } + + environment { + REGISTRY = 'dev.aduanasoft.com:8443' + IMAGE_NAMESPACE = 'panel-bases-anexo24' + } + + stages { + stage('Preflight tools') { + steps { + sh ''' + set -euo pipefail + echo "Node: $(hostname)" + if ! command -v docker >/dev/null 2>&1; then + echo "ERROR: este agente no tiene docker instalado." + echo "Usa un nodo Jenkins con label 'docker' y acceso al daemon." + exit 1 + fi + docker --version + ''' + } + } + + stage('Checkout') { + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.ENVIRONMENT}"]], + userRemoteConfigs: [[ + url: 'https://git.aduanasoft.com/ADUANASOFT/PANEL_BASES_ANEXO24.git', + credentialsId: 'gitea_acazares' + ]], + extensions: [[$class: 'CloneOption', depth: 0, shallow: false]] + ]) + } + } + + stage('Test') { + steps { + sh ''' + set -euxo pipefail + echo "== Test (svelte-check + tsc) ==" + + FE_CONTAINER="panel-bases-test-${BUILD_NUMBER}" + + cleanup() { + docker rm -f "$FE_CONTAINER" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + docker rm -f "$FE_CONTAINER" >/dev/null 2>&1 || true + docker run -d --name "$FE_CONTAINER" -w /workspace node:20-alpine sleep infinity + docker exec "$FE_CONTAINER" mkdir -p /workspace + docker cp "$WORKSPACE/." "$FE_CONTAINER:/workspace" + + docker exec "$FE_CONTAINER" sh -lc ' + set -euxo pipefail + node --version + npm config set strict-ssl false + npm ci --legacy-peer-deps + npm run check + ' + ''' + } + } + + stage('Generate version') { + steps { + script { + def year = sh(returnStdout: true, script: 'date +%y').trim() + def month = sh(returnStdout: true, script: 'date +%m').trim() + + if (params.ENVIRONMENT == 'development') { + def shortHash = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim() + env.APP_VERSION = "${year}.${month}.${params.VERSION_MAYOR}.${shortHash}" + } else { + def lastTag = sh(returnStdout: true, script: 'git describe --tags --abbrev=0 2>/dev/null || true').trim() + def commitCount = lastTag + ? sh(returnStdout: true, script: "git rev-list ${lastTag}..HEAD --count").trim() + : sh(returnStdout: true, script: 'git rev-list --count HEAD').trim() + + if (commitCount == '0') { + commitCount = '1' + } + env.APP_VERSION = "${year}.${month}.${params.VERSION_MAYOR}.${commitCount}" + } + + echo "VERSION: ${env.APP_VERSION}" + } + } + } + + stage('Docker login') { + steps { + withCredentials([usernamePassword(credentialsId: 'harbor-credentials', usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD')]) { + sh ''' + set -euo pipefail + echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" -u "$HARBOR_USERNAME" --password-stdin + ''' + } + } + } + + stage('Build + push') { + steps { + script { + retry(3) { + sh ''' + set -euo pipefail + export DOCKER_BUILDKIT=1 + docker build \ + -t "${REGISTRY}/${IMAGE_NAMESPACE}/app:${APP_VERSION}" \ + -t "${REGISTRY}/${IMAGE_NAMESPACE}/app:latest" \ + -f ./Dockerfile \ + . + + docker push "${REGISTRY}/${IMAGE_NAMESPACE}/app:${APP_VERSION}" + docker push "${REGISTRY}/${IMAGE_NAMESPACE}/app:latest" + ''' + } + } + } + } + + stage('Tag release (main)') { + when { + expression { params.ENVIRONMENT == 'main' } + } + steps { + withCredentials([usernamePassword(credentialsId: 'gitea_acazares', usernameVariable: 'GIT_USERNAME', passwordVariable: 'GIT_PASSWORD')]) { + sh ''' + set -euo pipefail + git -c user.name='Jenkins' -c user.email='jenkins@gitea.local' \ + tag -a "v${APP_VERSION}" -m "Release version ${APP_VERSION}" + git remote set-url origin "https://${GIT_USERNAME}:${GIT_PASSWORD}@git.aduanasoft.com/ADUANASOFT/PANEL_BASES_ANEXO24.git" + git push origin "v${APP_VERSION}" + ''' + } + } + } + + stage('Deploy development') { + when { + expression { params.ENVIRONMENT == 'development' } + } + steps { + withCredentials([ + usernamePassword(credentialsId: 'harbor-credentials', usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD'), + string(credentialsId: 'panel-server-user', variable: 'PANEL_SERVER_USER'), + string(credentialsId: 'panel-server-host', variable: 'PANEL_SERVER_HOST'), + sshUserPrivateKey( + credentialsId: 'panel-server-ssh', + keyFileVariable: 'PANEL_SERVER_KEY', + usernameVariable: 'PANEL_SERVER_SSH_USER', + passphraseVariable: 'PANEL_SERVER_KEY_PASSPHRASE' + ) + ]) { + /* + * Destino: Windows con OpenSSH Server. El agente Jenkins es Linux (bash). + * En bash, C:\rutas\ con \ dentro de "..." se corrompen → usar siempre C:/ruta (válido en PowerShell). + * DOCKER_CONFIG local sin wincred para evitar errores de "logon session". + * ssh-agent + ssh-add para clave con passphrase. + * StrictHostKeyChecking=accept-new con known_hosts en workspace para idempotencia. + */ + sh ''' + set -euo pipefail + WIN_DOCKER_CFG='C:/Aduanasoft/docker-jenkins-ci' + WIN_APP_PROD='C:/Aduanasoft/panel_bases_prod' + chmod 600 "${PANEL_SERVER_KEY}" || true + eval "$(ssh-agent -s)" + trap 'kill "${SSH_AGENT_PID}" 2>/dev/null || true' EXIT + if [ -n "${PANEL_SERVER_KEY_PASSPHRASE}" ]; then + printf '%s\n' "${PANEL_SERVER_KEY_PASSPHRASE}" | ssh-add "${PANEL_SERVER_KEY}" + else + ssh-add "${PANEL_SERVER_KEY}" + fi + KNOWN="${WORKSPACE}/.jenkins-panel-known_hosts" + : > "${KNOWN}" + SSHOPTS="-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${KNOWN}" + SSH_U="${PANEL_SERVER_USER}" + if [ -z "${SSH_U}" ]; then SSH_U="${PANEL_SERVER_SSH_USER}"; fi + + # Paso 1: inicializar DOCKER_CONFIG limpio (sin credsStore/wincred) + DOCKER_CFG_PS_TEMPLATE="$(cat <<'EOF' +$ErrorActionPreference = "Stop" +$cfg = "_CFG_" +New-Item -ItemType Directory -Force -Path $cfg | Out-Null +Remove-Item -Path (Join-Path $cfg "config.json") -Force -ErrorAction SilentlyContinue +Set-Content -Path (Join-Path $cfg "config.json") -Value '{"auths":{}}' -Encoding Ascii -NoNewline +EOF +)" + DOCKER_CFG_PS_CMD="$(printf '%s' "${DOCKER_CFG_PS_TEMPLATE}" | sed "s|_CFG_|${WIN_DOCKER_CFG}|g")" + DOCKER_CFG_PS_B64="$(printf '%s' "${DOCKER_CFG_PS_CMD}" | iconv -f UTF-8 -t UTF-16LE | base64 -w 0)" + ssh ${SSHOPTS} "${SSH_U}@${PANEL_SERVER_HOST}" \ + powershell.exe -NoProfile -NonInteractive -EncodedCommand "${DOCKER_CFG_PS_B64}" + + # Paso 2: escribir auth de Harbor directo en config.json (evita docker login + wincred) + if [ -z "${HARBOR_PASSWORD:-}" ]; then + echo "ERROR: HARBOR_PASSWORD vacío en Jenkins credentials" + exit 1 + fi + set +x + HARBOR_PASSWORD_B64="$(printf '%s' "${HARBOR_PASSWORD}" | base64 -w 0)" + DOCKER_AUTH_PS_TEMPLATE="$(cat <<'EOF' +$ErrorActionPreference = "Stop" +$cfg = "_CFG_" +$usr = "_USER_" +$reg = "_REG_" +$pwdB64 = "_PWD_B64_" +$pwd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($pwdB64)) +if ([string]::IsNullOrWhiteSpace($pwd)) { throw "HARBOR_PASSWORD vacio o invalido." } +$auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${usr}:$pwd")) +$json = "{""auths"":{""$reg"":{""auth"":""$auth""}}}" +Set-Content -Path (Join-Path $cfg "config.json") -Value $json -Encoding Ascii -NoNewline +EOF +)" + DOCKER_AUTH_PS_CMD="$(printf '%s' "${DOCKER_AUTH_PS_TEMPLATE}" \ + | sed "s|_CFG_|${WIN_DOCKER_CFG}|g" \ + | sed "s|_USER_|${HARBOR_USERNAME}|g" \ + | sed "s|_REG_|${REGISTRY}|g" \ + | sed "s|_PWD_B64_|${HARBOR_PASSWORD_B64}|g")" + AUTH_PS_B64="$(printf '%s' "${DOCKER_AUTH_PS_CMD}" | iconv -f UTF-8 -t UTF-16LE | base64 -w 0)" + ssh ${SSHOPTS} "${SSH_U}@${PANEL_SERVER_HOST}" \ + powershell.exe -NoProfile -NonInteractive -EncodedCommand "${AUTH_PS_B64}" + + # Paso 3: pull + up + prune + DOCKER_COMPOSE_PS_TEMPLATE="$(cat <<'EOF' +$ErrorActionPreference = "Stop" +$env:DOCKER_CONFIG = "_CFG_" +Set-Location "_APP_PROD_" +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d +docker image prune -f +EOF +)" + DOCKER_COMPOSE_PS_CMD="$(printf '%s' "${DOCKER_COMPOSE_PS_TEMPLATE}" \ + | sed "s|_CFG_|${WIN_DOCKER_CFG}|g" \ + | sed "s|_APP_PROD_|${WIN_APP_PROD}|g")" + COMPOSE_PS_B64="$(printf '%s' "${DOCKER_COMPOSE_PS_CMD}" | iconv -f UTF-8 -t UTF-16LE | base64 -w 0)" + set -x + ssh ${SSHOPTS} "${SSH_U}@${PANEL_SERVER_HOST}" \ + powershell.exe -NoProfile -NonInteractive -EncodedCommand "${COMPOSE_PS_B64}" + ''' + } + } + } + } +} diff --git a/README.md b/README.md index 2a3ebcc..fe3c0c6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,20 @@ Este proyecto es una migración moderna del panel de control legacy `TransmitirA - **Node.js**: v18 o superior. - **SQL Server**: Acceso a las instancias de base de datos definidas en `.env`. +```bash +docker build \ + -t dev.aduanasoft.com:8443/databases_a24c/frontend:latest \ + -f ./Dockerfile \ + ./ +``` + +Subirlas a harbor + +``` +docker login dev.aduanasoft.com:8443 +docker push dev.aduanasoft.com:8443/databases_a24c/frontend:latest +``` + ## Configuración 1. **Instalar dependencias**: @@ -47,7 +61,13 @@ Este proyecto es una migración moderna del panel de control legacy `TransmitirA - `src/lib/components/`: Componentes reutilizables (`Navbar`, `Sidebar`). - `src/app.css`: Estilos globales y tema Glass. +## Integración CloudRestoreAS + +- Endpoints servicio-a-servicio bajo `/api/restore/` (token `CLOUDRESTORE_API_TOKEN` en `.env`). +- **Servidores de Restauración** (`/servidores-restauracion`): admin de Alfa/Omega/Gamma y carpeta de entrada reportada por CloudRestoreAS (solo lectura). +- Contrato y prueba local: ver `INTEGRACION_PANEL.md` en el repo CloudRecoveryAS (mismo token en `CLOUDRESTORE_API_TOKEN` y en la config PANEL de CloudRestoreAS). + ## Notas de Migración - **DataTables**: Se inicializan en el cliente dentro de `onMount` para mantener la compatibilidad con las tablas interactivas originales. -- **Sistema de Archivos**: La lectura de backups busca en `D:/BackupSFTP/` en el servidor donde corre Node.js. +- **Sistema de Archivos**: La lectura de backups busca en `BACKUP_PATH` en el servidor donde corre Node.js (independiente de la carpeta que vigila CloudRestoreAS). diff --git a/certs/certificado.crt b/certs/certificado.crt new file mode 100755 index 0000000..dd52f45 --- /dev/null +++ b/certs/certificado.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFLTCCBBWgAwIBAgISBtjN/qgjIclT27aaNqaj062eMA0GCSqGSIb3DQEBCwUA +MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD +EwNSMTIwHhcNMjYwNTE5MjAyMjEzWhcNMjYwODE3MjAyMjEyWjAoMSYwJAYDVQQD +Ex13d3cuY3BhbmVsLWEyNC5hZHVhbmFzb2Z0LmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBALTzsLcHI3lXD8CXdscXPfww+HVlAP2LCqbfAqUAv/Lq +r3Xy4cpNqSjqr0aUiJf1JNJdc9UCBPxr+8RW8WszNPLxN8/Cms3vxBcwgD8opxin +s5rO05LPCAktWEGLx2UUetBskgnkclbMOBXfh4X3/LnFlFS7dE4j2aKgxOJnkCiQ +Zgr+cecnvKvdwy/ldWTDVsVAPInLdbC6AnfxspJ4fK52zQb76rWTjD0Wte3MMsLV +xsuHmP2hNd3MtiSMbOBW7DQaUe7ApPJVi7ldMJy7e3QNBOk7sRv8g8tvN4blEuvH +qdz4l/1kLxbcFbbVjMMJopvqDsGO5mYoaLL1ldv7FPcCAwEAAaOCAkQwggJAMA4G +A1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAA +MB0GA1UdDgQWBBS8jxleK96JZ8SW8+pDE/NlQcIxuzAfBgNVHSMEGDAWgBQAtSny +LY5vMeibTK14Pvrc6QzR0jAzBggrBgEFBQcBAQQnMCUwIwYIKwYBBQUHMAKGF2h0 +dHA6Ly9yMTIuaS5sZW5jci5vcmcvMEMGA1UdEQQ8MDqCGWNwYW5lbC1hMjQuYWR1 +YW5hc29mdC5jb22CHXd3dy5jcGFuZWwtYTI0LmFkdWFuYXNvZnQuY29tMBMGA1Ud +IAQMMAowCAYGZ4EMAQIBMC4GA1UdHwQnMCUwI6AhoB+GHWh0dHA6Ly9yMTIuYy5s +ZW5jci5vcmcvMzEuY3JsMIIBCgYKKwYBBAHWeQIEAgSB+wSB+AD2AHUAlE5Dh/rs +we+B8xkkJqgYZQHH0184AgE/cmd9VTcuGdgAAAGeQhzKuwAABAMARjBEAiAqXs8l +vt9p3R+2cyKNBvElDliwuOgfrsZGtCDG0omKMwIgXxqdtBKG/if91tcXKteWS70e +POIZuQLFte826DOZKrQAfQAm42RuWGkhI7w0P0ckNZs3ks0kWojYFdOTM/2ZGKtH +IwAAAZ5CHMqPAAgAAAUAEw5drQQDAEYwRAIgEAXmV+vp6u4Of8K8siRNcnt//sV2 +LBYOpMBeV4GOLqQCIBPOzICxVXbalgisFyBVK0aU/zl/G7HFiSQy6i/7rYNzMA0G +CSqGSIb3DQEBCwUAA4IBAQB776L0inSFaseeOICEx8jrm3iIg8JXlvqt1qdR29dq +E1qVRtyE3O23vQJ8sIXR9/9OUdH+Us5A98Nk4iNvoQF2HAW3hRZ0yo9NYdVk0ihp +2PXnMiRTILOUvwJH6IhcI1EkFgKitTJOydogMoaa5LRiaV9sD0NzDAhT5dlCtq/4 +1++qE5fycpPGhByXVOnKVyLsTb1VbgRAX5r8UI1aY+tkT/9SiBM7G0Y6urPlsZ+L +1FpUK6STeAYB3Lq0MeCpa8vUWf1b3S5aF4rRz5U40wFE4nKN4KZ8gQ/np9PCkolD +EgBqmhb/xITc+yuZDJfobCw3q2U0AivVp1K66Zyjzv+o +-----END CERTIFICATE----- \ No newline at end of file diff --git a/certs/certificado_completo.pem b/certs/certificado_completo.pem index ba530df..297bd65 100644 --- a/certs/certificado_completo.pem +++ b/certs/certificado_completo.pem @@ -1,88 +1,59 @@ ------BEGIN CERTIFICATE----- -MIIFLzCCBBegAwIBAgISBSlK1w8yv74gA1MpoRWxuScSMA0GCSqGSIb3DQEBCwUA -MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD -EwNSMTMwHhcNMjYwMjE3MjAzNTIwWhcNMjYwNTE4MjAzNTE5WjAoMSYwJAYDVQQD -Ex13d3cuY3BhbmVsLWEyNC5hZHVhbmFzb2Z0LmNvbTCCASIwDQYJKoZIhvcNAQEB -BQADggEPADCCAQoCggEBAL/Eab9LDyi1w37BYZpF32XufVXOP/5LWz38tnUTVAc+ -7XNgzJJESA7ZDL8ObPZfqNLd0wx8k/4l6UC7hI0+VGEEnpQARAMnK7X/E5YRUgcY -+X5SnufbrO0raDiJJyU6KjypZ6ie8/O595vG3ZgNYRacI2AxR3WOhx4mZJcTd5mo -uBPOfn7pVYu6O/iNCSwVmt+01abR6znAgX54ri9J5LR+Y36V8pDS6kSPfS/+pblt -hLYjxtE0XyA2FoJ/anVCyxSE4v0A4Hf9zuUjDIDFGavL6sn375NnUphCm/+U63XX -hITAguZxiOi/ObrZhLMivWtTsDCxuEbIpOFAZu///QMCAwEAAaOCAkYwggJCMA4G -A1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAA -MB0GA1UdDgQWBBT5Xeqix1yWnv2zUvlW2WZq54MP5zAfBgNVHSMEGDAWgBTnq58P -LDOgU9NeT3jIsoQOO9aSMzAzBggrBgEFBQcBAQQnMCUwIwYIKwYBBQUHMAKGF2h0 -dHA6Ly9yMTMuaS5sZW5jci5vcmcvMEMGA1UdEQQ8MDqCGWNwYW5lbC1hMjQuYWR1 -YW5hc29mdC5jb22CHXd3dy5jcGFuZWwtYTI0LmFkdWFuYXNvZnQuY29tMBMGA1Ud -IAQMMAowCAYGZ4EMAQIBMC4GA1UdHwQnMCUwI6AhoB+GHWh0dHA6Ly9yMTMuYy5s -ZW5jci5vcmcvMTkuY3JsMIIBDAYKKwYBBAHWeQIEAgSB/QSB+gD4AHYAyzj3FYl8 -hKFEX1vB3fvJbvKaWc1HCmkFhbDLFMMUWOcAAAGcbYYZgQAABAMARzBFAiEA3lrX -okh5ZBsHXkrM3V5vQiliDs43mwOpXvS94hpPrOkCIGW5eJmyEHzu2HsJUXeew3It -D8/9ZrcwMWLJSzBltKjeAH4AGoudaUpXmMiZoMqIvfSPwLRWYMzDYA0fcfRp/8fR -rKMAAAGcbYYc/AAIAAAFAEnJns8EAwBHMEUCIEFhMKBwxFIGTbR8JNX9hF9UYvYd -YtIS3Ej0Dd6Gf5RwAiEA9eXfJKJDK8XO48qubNrUSrZVyO7iPWhVAT4Zbe3lszkw -DQYJKoZIhvcNAQELBQADggEBAJX7OOSpmtVV2rYqweE9D5ndlqtJqqMPI0IPVHnO -zuuIBuAL3QpPkM6ydKjNXfgKGDowJEmcAHOT44Rm2Og+7ODJ08xUkj1PovFgErUs -cjZns+eiBzyMhIdQu5ACu0wv6jqzPiYtCbHvdaSBNNCjc08DyE4r0c2f7JiknkCF -XMd1tBnjd8njB4RiSbFYJP3MQL2hx9GX0d18gQvBXfhqTQI4ETCdIEmffLzvmiEO -HrBFy/z52b7HzfL0TajnoHWPpdDfvwOuEt18MklB+1970sVuxhh3HpS/X1Gd17Gc -6gBzxyzpA7oYP9unVuYppE1WwmgtfU8dFhFx0+acgDRxYlA= +-----BEGIN CERTIFICATE----- +MIIFLTCCBBWgAwIBAgISBtjN/qgjIclT27aaNqaj062eMA0GCSqGSIb3DQEBCwUA +MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD +EwNSMTIwHhcNMjYwNTE5MjAyMjEzWhcNMjYwODE3MjAyMjEyWjAoMSYwJAYDVQQD +Ex13d3cuY3BhbmVsLWEyNC5hZHVhbmFzb2Z0LmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBALTzsLcHI3lXD8CXdscXPfww+HVlAP2LCqbfAqUAv/Lq +r3Xy4cpNqSjqr0aUiJf1JNJdc9UCBPxr+8RW8WszNPLxN8/Cms3vxBcwgD8opxin +s5rO05LPCAktWEGLx2UUetBskgnkclbMOBXfh4X3/LnFlFS7dE4j2aKgxOJnkCiQ +Zgr+cecnvKvdwy/ldWTDVsVAPInLdbC6AnfxspJ4fK52zQb76rWTjD0Wte3MMsLV +xsuHmP2hNd3MtiSMbOBW7DQaUe7ApPJVi7ldMJy7e3QNBOk7sRv8g8tvN4blEuvH +qdz4l/1kLxbcFbbVjMMJopvqDsGO5mYoaLL1ldv7FPcCAwEAAaOCAkQwggJAMA4G +A1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAA +MB0GA1UdDgQWBBS8jxleK96JZ8SW8+pDE/NlQcIxuzAfBgNVHSMEGDAWgBQAtSny +LY5vMeibTK14Pvrc6QzR0jAzBggrBgEFBQcBAQQnMCUwIwYIKwYBBQUHMAKGF2h0 +dHA6Ly9yMTIuaS5sZW5jci5vcmcvMEMGA1UdEQQ8MDqCGWNwYW5lbC1hMjQuYWR1 +YW5hc29mdC5jb22CHXd3dy5jcGFuZWwtYTI0LmFkdWFuYXNvZnQuY29tMBMGA1Ud +IAQMMAowCAYGZ4EMAQIBMC4GA1UdHwQnMCUwI6AhoB+GHWh0dHA6Ly9yMTIuYy5s +ZW5jci5vcmcvMzEuY3JsMIIBCgYKKwYBBAHWeQIEAgSB+wSB+AD2AHUAlE5Dh/rs +we+B8xkkJqgYZQHH0184AgE/cmd9VTcuGdgAAAGeQhzKuwAABAMARjBEAiAqXs8l +vt9p3R+2cyKNBvElDliwuOgfrsZGtCDG0omKMwIgXxqdtBKG/if91tcXKteWS70e +POIZuQLFte826DOZKrQAfQAm42RuWGkhI7w0P0ckNZs3ks0kWojYFdOTM/2ZGKtH +IwAAAZ5CHMqPAAgAAAUAEw5drQQDAEYwRAIgEAXmV+vp6u4Of8K8siRNcnt//sV2 +LBYOpMBeV4GOLqQCIBPOzICxVXbalgisFyBVK0aU/zl/G7HFiSQy6i/7rYNzMA0G +CSqGSIb3DQEBCwUAA4IBAQB776L0inSFaseeOICEx8jrm3iIg8JXlvqt1qdR29dq +E1qVRtyE3O23vQJ8sIXR9/9OUdH+Us5A98Nk4iNvoQF2HAW3hRZ0yo9NYdVk0ihp +2PXnMiRTILOUvwJH6IhcI1EkFgKitTJOydogMoaa5LRiaV9sD0NzDAhT5dlCtq/4 +1++qE5fycpPGhByXVOnKVyLsTb1VbgRAX5r8UI1aY+tkT/9SiBM7G0Y6urPlsZ+L +1FpUK6STeAYB3Lq0MeCpa8vUWf1b3S5aF4rRz5U40wFE4nKN4KZ8gQ/np9PCkolD +EgBqmhb/xITc+yuZDJfobCw3q2U0AivVp1K66Zyjzv+o -----END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIFBTCCAu2gAwIBAgIQWgDyEtjUtIDzkkFX6imDBTANBgkqhkiG9w0BAQsFADBP -MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy -Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa -Fw0yNzAzMTIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF -bmNyeXB0MQwwCgYDVQQDEwNSMTMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK -AoIBAQClZ3CN0FaBZBUXYc25BtStGZCMJlA3mBZjklTb2cyEBZPs0+wIG6BgUUNI -fSvHSJaetC3ancgnO1ehn6vw1g7UDjDKb5ux0daknTI+WE41b0VYaHEX/D7YXYKg -L7JRbLAaXbhZzjVlyIuhrxA3/+OcXcJJFzT/jCuLjfC8cSyTDB0FxLrHzarJXnzR -yQH3nAP2/Apd9Np75tt2QnDr9E0i2gB3b9bJXxf92nUupVcM9upctuBzpWjPoXTi -dYJ+EJ/B9aLrAek4sQpEzNPCifVJNYIKNLMc6YjCR06CDgo28EdPivEpBHXazeGa -XP9enZiVuppD0EqiFwUBBDDTMrOPAgMBAAGjgfgwgfUwDgYDVR0PAQH/BAQDAgGG -MB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATASBgNVHRMBAf8ECDAGAQH/ -AgEAMB0GA1UdDgQWBBTnq58PLDOgU9NeT3jIsoQOO9aSMzAfBgNVHSMEGDAWgBR5 -tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKG -Fmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0gBAwwCjAIBgZngQwBAgEwJwYD -VR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVuY3Iub3JnLzANBgkqhkiG9w0B -AQsFAAOCAgEAUTdYUqEimzW7TbrOypLqCfL7VOwYf/Q79OH5cHLCZeggfQhDconl -k7Kgh8b0vi+/XuWu7CN8n/UPeg1vo3G+taXirrytthQinAHGwc/UdbOygJa9zuBc -VyqoH3CXTXDInT+8a+c3aEVMJ2St+pSn4ed+WkDp8ijsijvEyFwE47hulW0Ltzjg -9fOV5Pmrg/zxWbRuL+k0DBDHEJennCsAen7c35Pmx7jpmJ/HtgRhcnz0yjSBvyIw -6L1QIupkCv2SBODT/xDD3gfQQyKv6roV4G2EhfEyAsWpmojxjCUCGiyg97FvDtm/ -NK2LSc9lybKxB73I2+P2G3CaWpvvpAiHCVu30jW8GCxKdfhsXtnIy2imskQqVZ2m -0Pmxobb28Tucr7xBK7CtwvPrb79os7u2XP3O5f9b/H66GNyRrglRXlrYjI1oGYL/ -f4I1n/Sgusda6WvA6C190kxjU15Y12mHU4+BxyR9cx2hhGS9fAjMZKJss28qxvz6 -Axu4CaDmRNZpK/pQrXF17yXCXkmEWgvSOEZy6Z9pcbLIVEGckV/iVeq0AOo2pkg9 -p4QRIy0tK2diRENLSF2KysFwbY6B26BFeFs3v1sYVRhFW9nLkOrQVporCS0KyZmf -wVD89qSTlnctLcZnIavjKsKUu1nA1iU0yYMdYepKR7lWbnwhdx3ewok= ------END CERTIFICATE----- - ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC/xGm/Sw8otcN+ -wWGaRd9l7n1Vzj/+S1s9/LZ1E1QHPu1zYMySREgO2Qy/Dmz2X6jS3dMMfJP+JelA -u4SNPlRhBJ6UAEQDJyu1/xOWEVIHGPl+Up7n26ztK2g4iSclOio8qWeonvPzufeb -xt2YDWEWnCNgMUd1joceJmSXE3eZqLgTzn5+6VWLujv4jQksFZrftNWm0es5wIF+ -eK4vSeS0fmN+lfKQ0upEj30v/qW5bYS2I8bRNF8gNhaCf2p1QssUhOL9AOB3/c7l -IwyAxRmry+rJ9++TZ1KYQpv/lOt114SEwILmcYjovzm62YSzIr1rU7AwsbhGyKTh -QGbv//0DAgMBAAECggEADuxN/mDs8RIUAI0z9zlwxRHmFwNR34wlsZE08a9rXpT7 -DXt+7/L4WI9zenq46ANRs/cnMVWQfrqciDkPe55aGqKtZH8Qy94EfMk40PjQFVVQ -NRBg5VInBt3V4nBp52+7UbUtJ+YWveS+7BRxsh94arYD2rx6/x70TNhYA5m0OPTR -qWLNJabPxxi2Dm2zkEo2+pxzw1R+s3g8Sd6026LxiiDiZrUgVEwGnpIAOuHNGQ1u -8K5XWzjezrl8OUSwRSFAJyqSTcF5Ktj+dVCtMkUKg2WpK0qotVIZmm6YJ5cH13CP -/32myqHZRKkV6KslHAFlOaxZ0cvIAb3mXksSwO4bIQKBgQDu1ME75gaLbarV6FrE -vEG5vikS/nzRRjS7/96ecWF5iC1O9C+Sp0ViVlng3qQlW3h9OgUHhJYg+JvNMIT3 -s9LR7rqvSf0RWbebsK7aOWh8poZaGGP3lk7BU6X3OhYHa4i9H/9BpeovQSuML/cP -A7g+4TQ8KA3lB1oolCRE3+NchwKBgQDNjYkEVNJ0SWevl/BPYUgrlJ+4OkooNXTk -6XgxWyHIr06k7M7UsoUnBauH/odHABBWFg6uwan8uCcWyP9kAdAVo1gVKePwO0eK -Ori9EthB169ZoYYdVecHGgkjW1JYXwS3us7+k9Jy09UBLniOoAfY8HGZeP+xo+3G -+7hKa6BWpQKBgDc2qmGdoR+0v8zqBan717oIM1i3ysVa1LAtzBqXHbDKAaeCHklq -MEk2q1qoPgyA5A8XTbhaN5bxwpsiP2tAgFmWNkR193J+akstAo9ivDwtB1xmzR2c -+yIIo5LPM+6NNrR6ZDmaENTR3S5wnE41TvACgl/x5pxvIbHF5ciidd6bAoGBALfw -jcoYdXOmNGjHmD4QmdqV1w+u6altnLszyWCxYlwJtGCVoMhpMAopYsQBmuCISBYh -CuaLWyhRSnlzSEcf8CtMzoexws243l3uCl73vBm/fqTAjBk4Q9LxE3hWQivea4RW -YOEjOtGSiivSgANxZVRWZfGme3llbmP/4XyHLyYZAoGBAMu5Amt9Dg7CAe4CLKmV -BIt2PmU1YoNxTXwlDwRYu8Fy20IdH5U93fS7sGifeg7oLa2t3YN2S3DE9XOri03D -3XTOUHV3/uGbkzJtd5QBGWyC9IAznvVDhpZlowKyk4SVdPxn/zxoVNgMHLtJHPYd -7Yf7vY2YofoFlsVKRqQme9Sw ------END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIFBjCCAu6gAwIBAgIRAMISMktwqbSRcdxA9+KFJjwwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw +WhcNMjcwMzEyMjM1OTU5WjAzMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg +RW5jcnlwdDEMMAoGA1UEAxMDUjEyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA2pgodK2+lP474B7i5Ut1qywSf+2nAzJ+Npfs6DGPpRONC5kuHs0BUT1M +5ShuCVUxqqUiXXL0LQfCTUA83wEjuXg39RplMjTmhnGdBO+ECFu9AhqZ66YBAJpz +kG2Pogeg0JfT2kVhgTU9FPnEwF9q3AuWGrCf4yrqvSrWmMebcas7dA8827JgvlpL +Thjp2ypzXIlhZZ7+7Tymy05v5J75AEaz/xlNKmOzjmbGGIVwx1Blbzt05UiDDwhY +XS0jnV6j/ujbAKHS9OMZTfLuevYnnuXNnC2i8n+cF63vEzc50bTILEHWhsDp7CH4 +WRt/uTp8n1wBnWIEwii9Cq08yhDsGwIDAQABo4H4MIH1MA4GA1UdDwEB/wQEAwIB +hjAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwEgYDVR0TAQH/BAgwBgEB +/wIBADAdBgNVHQ4EFgQUALUp8i2ObzHom0yteD763OkM0dIwHwYDVR0jBBgwFoAU +ebRZ5nu25eQBc4AIiMgaWPbpm24wMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAC +hhZodHRwOi8veDEuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcG +A1UdHwQgMB4wHKAaoBiGFmh0dHA6Ly94MS5jLmxlbmNyLm9yZy8wDQYJKoZIhvcN +AQELBQADggIBAI910AnPanZIZTKS3rVEyIV29BWEjAK/duuz8eL5boSoVpHhkkv3 +4eoAeEiPdZLj5EZ7G2ArIK+gzhTlRQ1q4FKGpPPaFBSpqV/xbUb5UlAXQOnkHn3m +FVj+qYv87/WeY+Bm4sN3Ox8BhyaU7UAQ3LeZ7N1X01xxQe4wIAAE3JVLUCiHmZL+ +qoCUtgYIFPgcg350QMUIWgxPXNGEncT921ne7nluI02V8pLUmClqXOsCwULw+PVO +ZCB7qOMxxMBoCUeL2Ll4oMpOSr5pJCpLN3tRA2s6P1KLs9TSrVhOk+7LX28NMUlI +usQ/nxLJID0RhAeFtPjyOCOscQBA53+NRjSCak7P4A5jX7ppmkcJECL+S0i3kXVU +y5Me5BbrU8973jZNv/ax6+ZK6TM8jWmimL6of6OrX7ZU6E2WqazzsFrLG3o2kySb +zlhSgJ81Cl4tv3SbYiYXnJExKQvzf83DYotox3f0fwv7xln1A2ZLplCb0O+l/AK0 +YE0DS2FPxSAHi0iwMfW2nNHJrXcY3LLHD77gRgje4Eveubi2xxa+Nmk/hmhLdIET +iVDFanoCrMVIpQ59XWHkzdFmoHXHBV7oibVjGSO7ULSQ7MJ1Nz51phuDJSgAIU7A +0zrLnOrAj/dfrlEWRhCvAgbuwLZX1A2sjNjXoPOHbsPiy+lO1KF8/XY7 +-----END CERTIFICATE----- \ No newline at end of file diff --git a/certs/certificado_completo.pem.bak.20260522 b/certs/certificado_completo.pem.bak.20260522 new file mode 100644 index 0000000..2aeed69 --- /dev/null +++ b/certs/certificado_completo.pem.bak.20260522 @@ -0,0 +1,88 @@ +-----BEGIN CERTIFICATE----- +MIIFLzCCBBegAwIBAgISBSlK1w8yv74gA1MpoRWxuScSMA0GCSqGSIb3DQEBCwUA +MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD +EwNSMTMwHhcNMjYwMjE3MjAzNTIwWhcNMjYwNTE4MjAzNTE5WjAoMSYwJAYDVQQD +Ex13d3cuY3BhbmVsLWEyNC5hZHVhbmFzb2Z0LmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAL/Eab9LDyi1w37BYZpF32XufVXOP/5LWz38tnUTVAc+ +7XNgzJJESA7ZDL8ObPZfqNLd0wx8k/4l6UC7hI0+VGEEnpQARAMnK7X/E5YRUgcY ++X5SnufbrO0raDiJJyU6KjypZ6ie8/O595vG3ZgNYRacI2AxR3WOhx4mZJcTd5mo +uBPOfn7pVYu6O/iNCSwVmt+01abR6znAgX54ri9J5LR+Y36V8pDS6kSPfS/+pblt +hLYjxtE0XyA2FoJ/anVCyxSE4v0A4Hf9zuUjDIDFGavL6sn375NnUphCm/+U63XX +hITAguZxiOi/ObrZhLMivWtTsDCxuEbIpOFAZu///QMCAwEAAaOCAkYwggJCMA4G +A1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAA +MB0GA1UdDgQWBBT5Xeqix1yWnv2zUvlW2WZq54MP5zAfBgNVHSMEGDAWgBTnq58P +LDOgU9NeT3jIsoQOO9aSMzAzBggrBgEFBQcBAQQnMCUwIwYIKwYBBQUHMAKGF2h0 +dHA6Ly9yMTMuaS5sZW5jci5vcmcvMEMGA1UdEQQ8MDqCGWNwYW5lbC1hMjQuYWR1 +YW5hc29mdC5jb22CHXd3dy5jcGFuZWwtYTI0LmFkdWFuYXNvZnQuY29tMBMGA1Ud +IAQMMAowCAYGZ4EMAQIBMC4GA1UdHwQnMCUwI6AhoB+GHWh0dHA6Ly9yMTMuYy5s +ZW5jci5vcmcvMTkuY3JsMIIBDAYKKwYBBAHWeQIEAgSB/QSB+gD4AHYAyzj3FYl8 +hKFEX1vB3fvJbvKaWc1HCmkFhbDLFMMUWOcAAAGcbYYZgQAABAMARzBFAiEA3lrX +okh5ZBsHXkrM3V5vQiliDs43mwOpXvS94hpPrOkCIGW5eJmyEHzu2HsJUXeew3It +D8/9ZrcwMWLJSzBltKjeAH4AGoudaUpXmMiZoMqIvfSPwLRWYMzDYA0fcfRp/8fR +rKMAAAGcbYYc/AAIAAAFAEnJns8EAwBHMEUCIEFhMKBwxFIGTbR8JNX9hF9UYvYd +YtIS3Ej0Dd6Gf5RwAiEA9eXfJKJDK8XO48qubNrUSrZVyO7iPWhVAT4Zbe3lszkw +DQYJKoZIhvcNAQELBQADggEBAJX7OOSpmtVV2rYqweE9D5ndlqtJqqMPI0IPVHnO +zuuIBuAL3QpPkM6ydKjNXfgKGDowJEmcAHOT44Rm2Og+7ODJ08xUkj1PovFgErUs +cjZns+eiBzyMhIdQu5ACu0wv6jqzPiYtCbHvdaSBNNCjc08DyE4r0c2f7JiknkCF +XMd1tBnjd8njB4RiSbFYJP3MQL2hx9GX0d18gQvBXfhqTQI4ETCdIEmffLzvmiEO +HrBFy/z52b7HzfL0TajnoHWPpdDfvwOuEt18MklB+1970sVuxhh3HpS/X1Gd17Gc +6gBzxyzpA7oYP9unVuYppE1WwmgtfU8dFhFx0+acgDRxYlA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFBTCCAu2gAwIBAgIQWgDyEtjUtIDzkkFX6imDBTANBgkqhkiG9w0BAQsFADBP +MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy +Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa +Fw0yNzAzMTIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF +bmNyeXB0MQwwCgYDVQQDEwNSMTMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQClZ3CN0FaBZBUXYc25BtStGZCMJlA3mBZjklTb2cyEBZPs0+wIG6BgUUNI +fSvHSJaetC3ancgnO1ehn6vw1g7UDjDKb5ux0daknTI+WE41b0VYaHEX/D7YXYKg +L7JRbLAaXbhZzjVlyIuhrxA3/+OcXcJJFzT/jCuLjfC8cSyTDB0FxLrHzarJXnzR +yQH3nAP2/Apd9Np75tt2QnDr9E0i2gB3b9bJXxf92nUupVcM9upctuBzpWjPoXTi +dYJ+EJ/B9aLrAek4sQpEzNPCifVJNYIKNLMc6YjCR06CDgo28EdPivEpBHXazeGa +XP9enZiVuppD0EqiFwUBBDDTMrOPAgMBAAGjgfgwgfUwDgYDVR0PAQH/BAQDAgGG +MB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBTnq58PLDOgU9NeT3jIsoQOO9aSMzAfBgNVHSMEGDAWgBR5 +tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKG +Fmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0gBAwwCjAIBgZngQwBAgEwJwYD +VR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVuY3Iub3JnLzANBgkqhkiG9w0B +AQsFAAOCAgEAUTdYUqEimzW7TbrOypLqCfL7VOwYf/Q79OH5cHLCZeggfQhDconl +k7Kgh8b0vi+/XuWu7CN8n/UPeg1vo3G+taXirrytthQinAHGwc/UdbOygJa9zuBc +VyqoH3CXTXDInT+8a+c3aEVMJ2St+pSn4ed+WkDp8ijsijvEyFwE47hulW0Ltzjg +9fOV5Pmrg/zxWbRuL+k0DBDHEJennCsAen7c35Pmx7jpmJ/HtgRhcnz0yjSBvyIw +6L1QIupkCv2SBODT/xDD3gfQQyKv6roV4G2EhfEyAsWpmojxjCUCGiyg97FvDtm/ +NK2LSc9lybKxB73I2+P2G3CaWpvvpAiHCVu30jW8GCxKdfhsXtnIy2imskQqVZ2m +0Pmxobb28Tucr7xBK7CtwvPrb79os7u2XP3O5f9b/H66GNyRrglRXlrYjI1oGYL/ +f4I1n/Sgusda6WvA6C190kxjU15Y12mHU4+BxyR9cx2hhGS9fAjMZKJss28qxvz6 +Axu4CaDmRNZpK/pQrXF17yXCXkmEWgvSOEZy6Z9pcbLIVEGckV/iVeq0AOo2pkg9 +p4QRIy0tK2diRENLSF2KysFwbY6B26BFeFs3v1sYVRhFW9nLkOrQVporCS0KyZmf +wVD89qSTlnctLcZnIavjKsKUu1nA1iU0yYMdYepKR7lWbnwhdx3ewok= +-----END CERTIFICATE----- + +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC/xGm/Sw8otcN+ +wWGaRd9l7n1Vzj/+S1s9/LZ1E1QHPu1zYMySREgO2Qy/Dmz2X6jS3dMMfJP+JelA +u4SNPlRhBJ6UAEQDJyu1/xOWEVIHGPl+Up7n26ztK2g4iSclOio8qWeonvPzufeb +xt2YDWEWnCNgMUd1joceJmSXE3eZqLgTzn5+6VWLujv4jQksFZrftNWm0es5wIF+ +eK4vSeS0fmN+lfKQ0upEj30v/qW5bYS2I8bRNF8gNhaCf2p1QssUhOL9AOB3/c7l +IwyAxRmry+rJ9++TZ1KYQpv/lOt114SEwILmcYjovzm62YSzIr1rU7AwsbhGyKTh +QGbv//0DAgMBAAECggEADuxN/mDs8RIUAI0z9zlwxRHmFwNR34wlsZE08a9rXpT7 +DXt+7/L4WI9zenq46ANRs/cnMVWQfrqciDkPe55aGqKtZH8Qy94EfMk40PjQFVVQ +NRBg5VInBt3V4nBp52+7UbUtJ+YWveS+7BRxsh94arYD2rx6/x70TNhYA5m0OPTR +qWLNJabPxxi2Dm2zkEo2+pxzw1R+s3g8Sd6026LxiiDiZrUgVEwGnpIAOuHNGQ1u +8K5XWzjezrl8OUSwRSFAJyqSTcF5Ktj+dVCtMkUKg2WpK0qotVIZmm6YJ5cH13CP +/32myqHZRKkV6KslHAFlOaxZ0cvIAb3mXksSwO4bIQKBgQDu1ME75gaLbarV6FrE +vEG5vikS/nzRRjS7/96ecWF5iC1O9C+Sp0ViVlng3qQlW3h9OgUHhJYg+JvNMIT3 +s9LR7rqvSf0RWbebsK7aOWh8poZaGGP3lk7BU6X3OhYHa4i9H/9BpeovQSuML/cP +A7g+4TQ8KA3lB1oolCRE3+NchwKBgQDNjYkEVNJ0SWevl/BPYUgrlJ+4OkooNXTk +6XgxWyHIr06k7M7UsoUnBauH/odHABBWFg6uwan8uCcWyP9kAdAVo1gVKePwO0eK +Ori9EthB169ZoYYdVecHGgkjW1JYXwS3us7+k9Jy09UBLniOoAfY8HGZeP+xo+3G ++7hKa6BWpQKBgDc2qmGdoR+0v8zqBan717oIM1i3ysVa1LAtzBqXHbDKAaeCHklq +MEk2q1qoPgyA5A8XTbhaN5bxwpsiP2tAgFmWNkR193J+akstAo9ivDwtB1xmzR2c ++yIIo5LPM+6NNrR6ZDmaENTR3S5wnE41TvACgl/x5pxvIbHF5ciidd6bAoGBALfw +jcoYdXOmNGjHmD4QmdqV1w+u6altnLszyWCxYlwJtGCVoMhpMAopYsQBmuCISBYh +CuaLWyhRSnlzSEcf8CtMzoexws243l3uCl73vBm/fqTAjBk4Q9LxE3hWQivea4RW +YOEjOtGSiivSgANxZVRWZfGme3llbmP/4XyHLyYZAoGBAMu5Amt9Dg7CAe4CLKmV +BIt2PmU1YoNxTXwlDwRYu8Fy20IdH5U93fS7sGifeg7oLa2t3YN2S3DE9XOri03D +3XTOUHV3/uGbkzJtd5QBGWyC9IAznvVDhpZlowKyk4SVdPxn/zxoVNgMHLtJHPYd +7Yf7vY2YofoFlsVKRqQme9Sw +-----END PRIVATE KEY----- diff --git a/certs/certificado_intermediario.crt b/certs/certificado_intermediario.crt new file mode 100755 index 0000000..ab39567 --- /dev/null +++ b/certs/certificado_intermediario.crt @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIFBjCCAu6gAwIBAgIRAMISMktwqbSRcdxA9+KFJjwwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw +WhcNMjcwMzEyMjM1OTU5WjAzMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg +RW5jcnlwdDEMMAoGA1UEAxMDUjEyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA2pgodK2+lP474B7i5Ut1qywSf+2nAzJ+Npfs6DGPpRONC5kuHs0BUT1M +5ShuCVUxqqUiXXL0LQfCTUA83wEjuXg39RplMjTmhnGdBO+ECFu9AhqZ66YBAJpz +kG2Pogeg0JfT2kVhgTU9FPnEwF9q3AuWGrCf4yrqvSrWmMebcas7dA8827JgvlpL +Thjp2ypzXIlhZZ7+7Tymy05v5J75AEaz/xlNKmOzjmbGGIVwx1Blbzt05UiDDwhY +XS0jnV6j/ujbAKHS9OMZTfLuevYnnuXNnC2i8n+cF63vEzc50bTILEHWhsDp7CH4 +WRt/uTp8n1wBnWIEwii9Cq08yhDsGwIDAQABo4H4MIH1MA4GA1UdDwEB/wQEAwIB +hjAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwEgYDVR0TAQH/BAgwBgEB +/wIBADAdBgNVHQ4EFgQUALUp8i2ObzHom0yteD763OkM0dIwHwYDVR0jBBgwFoAU +ebRZ5nu25eQBc4AIiMgaWPbpm24wMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAC +hhZodHRwOi8veDEuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcG +A1UdHwQgMB4wHKAaoBiGFmh0dHA6Ly94MS5jLmxlbmNyLm9yZy8wDQYJKoZIhvcN +AQELBQADggIBAI910AnPanZIZTKS3rVEyIV29BWEjAK/duuz8eL5boSoVpHhkkv3 +4eoAeEiPdZLj5EZ7G2ArIK+gzhTlRQ1q4FKGpPPaFBSpqV/xbUb5UlAXQOnkHn3m +FVj+qYv87/WeY+Bm4sN3Ox8BhyaU7UAQ3LeZ7N1X01xxQe4wIAAE3JVLUCiHmZL+ +qoCUtgYIFPgcg350QMUIWgxPXNGEncT921ne7nluI02V8pLUmClqXOsCwULw+PVO +ZCB7qOMxxMBoCUeL2Ll4oMpOSr5pJCpLN3tRA2s6P1KLs9TSrVhOk+7LX28NMUlI +usQ/nxLJID0RhAeFtPjyOCOscQBA53+NRjSCak7P4A5jX7ppmkcJECL+S0i3kXVU +y5Me5BbrU8973jZNv/ax6+ZK6TM8jWmimL6of6OrX7ZU6E2WqazzsFrLG3o2kySb +zlhSgJ81Cl4tv3SbYiYXnJExKQvzf83DYotox3f0fwv7xln1A2ZLplCb0O+l/AK0 +YE0DS2FPxSAHi0iwMfW2nNHJrXcY3LLHD77gRgje4Eveubi2xxa+Nmk/hmhLdIET +iVDFanoCrMVIpQ59XWHkzdFmoHXHBV7oibVjGSO7ULSQ7MJ1Nz51phuDJSgAIU7A +0zrLnOrAj/dfrlEWRhCvAgbuwLZX1A2sjNjXoPOHbsPiy+lO1KF8/XY7 +-----END CERTIFICATE----- \ No newline at end of file diff --git a/certs/llave_privada.key b/certs/llave_privada.key index e33087c..548a573 100644 --- a/certs/llave_privada.key +++ b/certs/llave_privada.key @@ -1,28 +1,28 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC/xGm/Sw8otcN+ -wWGaRd9l7n1Vzj/+S1s9/LZ1E1QHPu1zYMySREgO2Qy/Dmz2X6jS3dMMfJP+JelA -u4SNPlRhBJ6UAEQDJyu1/xOWEVIHGPl+Up7n26ztK2g4iSclOio8qWeonvPzufeb -xt2YDWEWnCNgMUd1joceJmSXE3eZqLgTzn5+6VWLujv4jQksFZrftNWm0es5wIF+ -eK4vSeS0fmN+lfKQ0upEj30v/qW5bYS2I8bRNF8gNhaCf2p1QssUhOL9AOB3/c7l -IwyAxRmry+rJ9++TZ1KYQpv/lOt114SEwILmcYjovzm62YSzIr1rU7AwsbhGyKTh -QGbv//0DAgMBAAECggEADuxN/mDs8RIUAI0z9zlwxRHmFwNR34wlsZE08a9rXpT7 -DXt+7/L4WI9zenq46ANRs/cnMVWQfrqciDkPe55aGqKtZH8Qy94EfMk40PjQFVVQ -NRBg5VInBt3V4nBp52+7UbUtJ+YWveS+7BRxsh94arYD2rx6/x70TNhYA5m0OPTR -qWLNJabPxxi2Dm2zkEo2+pxzw1R+s3g8Sd6026LxiiDiZrUgVEwGnpIAOuHNGQ1u -8K5XWzjezrl8OUSwRSFAJyqSTcF5Ktj+dVCtMkUKg2WpK0qotVIZmm6YJ5cH13CP -/32myqHZRKkV6KslHAFlOaxZ0cvIAb3mXksSwO4bIQKBgQDu1ME75gaLbarV6FrE -vEG5vikS/nzRRjS7/96ecWF5iC1O9C+Sp0ViVlng3qQlW3h9OgUHhJYg+JvNMIT3 -s9LR7rqvSf0RWbebsK7aOWh8poZaGGP3lk7BU6X3OhYHa4i9H/9BpeovQSuML/cP -A7g+4TQ8KA3lB1oolCRE3+NchwKBgQDNjYkEVNJ0SWevl/BPYUgrlJ+4OkooNXTk -6XgxWyHIr06k7M7UsoUnBauH/odHABBWFg6uwan8uCcWyP9kAdAVo1gVKePwO0eK -Ori9EthB169ZoYYdVecHGgkjW1JYXwS3us7+k9Jy09UBLniOoAfY8HGZeP+xo+3G -+7hKa6BWpQKBgDc2qmGdoR+0v8zqBan717oIM1i3ysVa1LAtzBqXHbDKAaeCHklq -MEk2q1qoPgyA5A8XTbhaN5bxwpsiP2tAgFmWNkR193J+akstAo9ivDwtB1xmzR2c -+yIIo5LPM+6NNrR6ZDmaENTR3S5wnE41TvACgl/x5pxvIbHF5ciidd6bAoGBALfw -jcoYdXOmNGjHmD4QmdqV1w+u6altnLszyWCxYlwJtGCVoMhpMAopYsQBmuCISBYh -CuaLWyhRSnlzSEcf8CtMzoexws243l3uCl73vBm/fqTAjBk4Q9LxE3hWQivea4RW -YOEjOtGSiivSgANxZVRWZfGme3llbmP/4XyHLyYZAoGBAMu5Amt9Dg7CAe4CLKmV -BIt2PmU1YoNxTXwlDwRYu8Fy20IdH5U93fS7sGifeg7oLa2t3YN2S3DE9XOri03D -3XTOUHV3/uGbkzJtd5QBGWyC9IAznvVDhpZlowKyk4SVdPxn/zxoVNgMHLtJHPYd -7Yf7vY2YofoFlsVKRqQme9Sw +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC087C3ByN5Vw/A +l3bHFz38MPh1ZQD9iwqm3wKlAL/y6q918uHKTako6q9GlIiX9STSXXPVAgT8a/vE +VvFrMzTy8TfPwprN78QXMIA/KKcYp7OaztOSzwgJLVhBi8dlFHrQbJIJ5HJWzDgV +34eF9/y5xZRUu3ROI9mioMTiZ5AokGYK/nHnJ7yr3cMv5XVkw1bFQDyJy3WwugJ3 +8bKSeHyuds0G++q1k4w9FrXtzDLC1cbLh5j9oTXdzLYkjGzgVuw0GlHuwKTyVYu5 +XTCcu3t0DQTpO7Eb/IPLbzeG5RLrx6nc+Jf9ZC8W3BW21YzDCaKb6g7BjuZmKGiy +9ZXb+xT3AgMBAAECggEAAegRZBRIrOYcbeCKqVVW21nevEnopUM6RQDTg86KtzH+ +0ETTWTDRR/OVDi7Rc5xrG2ZNqqBfUhOHLw3ldEYxWB/vgRphTCsrCz2BFGlGvW1C +6K4mL4VL5eDa7bHd03RQSPLj5QPJqp1zN9PHl0NCH+jzg6MH+izJhbug52vE6pB6 +Ya77eknNm3msjQDNgLIwg3hgpR9HHNJwjkJ9ecsQIpOMtPiv+kS2CB93tCoLMVqG +0B/hEX1tZ0B5UMme1IIthg576olMKVz3gicsFW6g11vnAWpFqvP+RvG2JLlkNWe7 +tJqjK2+Pjc03tGdFixPIdK5AtWk8cnNrRGlR6qaU6QKBgQDYRtfxAZjhvX+d18FV +oYPhV4bIwCo8M7PnBpc8MLQSgJsPaJMSojOu97llANMW4QzSs7y3QhIFIHPoE1zO +oJFmhdug2oU/Xfgj5g8NlqTgi00YPsEjF+1vRExNmrp/Pd/hrYXFYsz2XKCgF3OX +7GjYpqAO/949BuSKFEajBr/B7wKBgQDWL+cL03kFF8l3lY3I0r95QyEIdPFnbIGc +Y27zlsT/WkeF0FBkykSA1NfpcE+LsyzOxuYPYgacMIJNsS/CiM45COOvCEn3x3rt +b1J/fHaG5MQ9AASgeDpfQaAxgBS6T/FhXRvIAq9S6oORMsNMBQkkP4vY8/JWEgOm +VFEHPB1FeQKBgQDPLa4WlO0a8iiZz+DIYtyutOXM0SikWvLvUIT3h8A4KTJg5FBe +/Tp9VZknhE6yEAv0m3EgLA6PErN1kXbKCU6/42KtCCe0uBPIb83jundfEpJbs2HY +eEde2xItFReqZF9fFJacqzSkm77THSQCWNlnENcrBzihRUUQcLPp38E5yQKBgE+D +1RiU15bGb+rPQKXPZ3oTK85B6+TjaXKvj18rF9NcprTM0yu305qoacemBEHusLHL +MVmAoMeXUqiZIQvtUfHmlPBD+YHdMou3Cj9961rEzv1+ZjlUqQb4DAqUbB2G1Cu7 +LzcfmAsGOPXMjKhKLkygssBGQC8n3OcA4lv3Oz6xAoGAN38J2yBhzZFaN6MtToOo +VXjXXv9JSt6IVmefOcDbmRu9FpI84Q1qyu/w05Wg5hMQKnW1mS8DLhhMt5DEQH2O +rNVguQf0CfRDbJyiWH2sGz0GT0gr/8WEvT+vWK/Oz9/cGyWLHn8aHqmka4lEY7lu +hKyK3XObVPB7ht3sNoQUCPE= -----END PRIVATE KEY----- \ No newline at end of file diff --git a/database/schema.sql b/database/schema.sql index cd414e7..0a37f84 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -1,55 +1,58 @@ --- Schema para sistema de usuarios y permisos --- Ejecutar en PostgreSQL (base de datos CONTROLDESK) +-- Panel Transmitiras: usuarios, permisos y sesiones (mismo modelo que +-- ~/dev/a24c/backend/api/v1/modules/dashboard — tablas en inglés, esquema a24c). +-- Ejecutar en PostgreSQL (misma base donde está el catálogo ControlDesk, p. ej. CONTROLDESK). --- Tabla de usuarios -CREATE TABLE IF NOT EXISTS usuarios ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - email VARCHAR(100) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - nombre_completo VARCHAR(100), - activo BOOLEAN DEFAULT true, - es_admin BOOLEAN DEFAULT false, - fecha_creacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - ultimo_acceso TIMESTAMP, - CONSTRAINT chk_username_length CHECK (char_length(username) >= 3) +CREATE SCHEMA IF NOT EXISTS a24c; + +CREATE TABLE IF NOT EXISTS a24c.dashboard_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT true, + is_admin BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_access_at TIMESTAMPTZ, + CONSTRAINT chk_dashboard_users_username_length CHECK (char_length(username) >= 3) ); --- Tabla de permisos de usuario a bases de datos -CREATE TABLE IF NOT EXISTS usuario_base_datos ( - id SERIAL PRIMARY KEY, - usuario_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, - base_datos_nombre VARCHAR(255) NOT NULL, -- Nombre de la base de datos (visible_name) - puede_ver BOOLEAN DEFAULT true, - puede_descargar_backup BOOLEAN DEFAULT false, - puede_restaurar BOOLEAN DEFAULT false, - fecha_asignacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(usuario_id, base_datos_nombre) +CREATE TABLE IF NOT EXISTS a24c.dashboard_user_database_permissions ( + id SERIAL PRIMARY KEY, + dashboard_user_id INTEGER NOT NULL REFERENCES a24c.dashboard_users (id) ON DELETE CASCADE, + database_name VARCHAR(255) NOT NULL, + can_view BOOLEAN NOT NULL DEFAULT true, + can_download_backup BOOLEAN NOT NULL DEFAULT false, + can_restore BOOLEAN NOT NULL DEFAULT false, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (dashboard_user_id, database_name) ); --- Tabla de sesiones -CREATE TABLE IF NOT EXISTS sesiones ( - id SERIAL PRIMARY KEY, - usuario_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, - token VARCHAR(255) UNIQUE NOT NULL, - ip_address VARCHAR(50), - user_agent TEXT, - fecha_creacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - fecha_expiracion TIMESTAMP NOT NULL, - activa BOOLEAN DEFAULT true +CREATE TABLE IF NOT EXISTS a24c.dashboard_sessions ( + id SERIAL PRIMARY KEY, + dashboard_user_id INTEGER NOT NULL REFERENCES a24c.dashboard_users (id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + ip_address VARCHAR(50), + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true ); --- Índices para mejor rendimiento -CREATE INDEX IF NOT EXISTS idx_usuario_base_datos_usuario ON usuario_base_datos(usuario_id); -CREATE INDEX IF NOT EXISTS idx_usuario_base_datos_nombre ON usuario_base_datos(base_datos_nombre); -CREATE INDEX IF NOT EXISTS idx_usuarios_activo ON usuarios(activo); -CREATE INDEX IF NOT EXISTS idx_sesiones_token ON sesiones(token); -CREATE INDEX IF NOT EXISTS idx_sesiones_usuario ON sesiones(usuario_id); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_user_db_perm_user + ON a24c.dashboard_user_database_permissions (dashboard_user_id); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_user_db_perm_dbname + ON a24c.dashboard_user_database_permissions (database_name); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_users_active ON a24c.dashboard_users (is_active); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_sessions_token ON a24c.dashboard_sessions (token); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_sessions_user ON a24c.dashboard_sessions (dashboard_user_id); --- Insertar usuario admin por defecto --- Password: Admin123! --- Nota: Este hash es temporal, debes cambiarlo después del primer inicio de sesión -INSERT INTO usuarios (username, email, password_hash, nombre_completo, es_admin, activo) +COMMENT ON TABLE a24c.dashboard_users IS 'Operadores del panel Transmitiras (JWT/bcrypt). Paridad api/v1/modules/dashboard/users.'; +COMMENT ON TABLE a24c.dashboard_user_database_permissions IS 'Permisos por base de datos. Paridad dashboard/user_database_permissions.'; +COMMENT ON TABLE a24c.dashboard_sessions IS 'Sesiones persistidas (opcional; el panel usa JWT en cookie). Paridad dashboard/sessions.'; + +-- Usuario admin por defecto (cambiar password_hash tras primer arranque; ver scripts/generate-password-hash.js) +INSERT INTO a24c.dashboard_users (username, email, password_hash, full_name, is_admin, is_active) VALUES ( 'admin', 'admin@aduanasoft.com', @@ -60,7 +63,106 @@ VALUES ( ) ON CONFLICT (username) DO NOTHING; --- Comentarios en tablas -COMMENT ON TABLE usuarios IS 'Usuarios del sistema de gestión de bases de datos'; -COMMENT ON TABLE usuario_base_datos IS 'Permisos de acceso de usuarios a bases de datos específicas'; -COMMENT ON TABLE sesiones IS 'Sesiones activas de usuarios'; +-- ============================================================================ +-- Integración CloudRestoreAS: servidores de restauración y bitácora de jobs. +-- Equivalente versionado en database/migrations/001_restore_targets.{up,down}.sql +-- ============================================================================ + +-- Servidores SQL Server destino de restauración (Alfa, Omega, Gamma). Son máquinas +-- EXTERNAS independientes: el .bak se transfiere por SFTP/SSH al servidor y el SQL Server +-- restaura desde su disco local. Cada base (a24c.database_nodes.restore_target_id) se asigna +-- a uno de ellos. Las contraseñas (SQL y SSH) se guardan cifradas con AES-256-GCM, nunca en plano. +CREATE TABLE IF NOT EXISTS a24c.restore_targets ( + id SERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL UNIQUE, -- Alfa | Omega | Gamma + server_ip VARCHAR(255), -- SQL Server: IP/hostname; acepta "ip,puerto" + sql_username VARCHAR(128), + sql_password_encrypted TEXT, -- sobre gcm:iv:tag:ciphertext + data_folder VARCHAR(500), -- ruta .mdf/.ldf EN el server remoto (C:\SQLData) + ssh_host VARCHAR(255), -- host SSH del servidor (suele ser el mismo equipo) + ssh_port INTEGER DEFAULT 22, + ssh_username VARCHAR(128), + ssh_password_encrypted TEXT, -- credencial SSH cifrada (AES-256-GCM) + remote_inbox_path VARCHAR(500), -- ruta en el server donde se sube el .bak y se restaura (C:\RestoreInbox) + notes TEXT, + -- Características de hardware (opcionales, capturadas a mano). Se usan para distribuir bases + -- por capacidad; el disco es la capacidad que manda. NULL = sin capturar. + os VARCHAR(50), -- Sistema operativo (Linux/Windows/…) + ram_gb INTEGER, -- RAM en GB + disk_gb INTEGER, -- Disco en GB + location VARCHAR(255), -- Ubicación (ej: Kansas City, United States) + size_category VARCHAR(20), -- Etiqueta: Chico | Mediano | Grande + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Tres servidores fijos de partida (editables). Datos de conexión vacíos hasta configurarse. +INSERT INTO a24c.restore_targets (name) VALUES ('Alfa'), ('Omega'), ('Gamma') +ON CONFLICT (name) DO NOTHING; + +-- Asignación servidor de restauración ↔ base de datos (la tabla database_nodes la aprovisiona +-- el backend a24c; aquí solo se agrega la columna de asignación). +ALTER TABLE a24c.database_nodes + ADD COLUMN IF NOT EXISTS restore_target_id INTEGER + REFERENCES a24c.restore_targets (id) ON DELETE SET NULL; + +-- Backfill único: la credencial SQL del nodo (database_nodes.sql_password, columna de a24c) se +-- copia del restaurador asignado como sobre cifrado gcm: (el panel lo descifra al conectar). +-- Idempotente: solo rellena nodos ya asignados sin contraseña; no pisa valores existentes. +-- Guardado por si a24c aún no aprovisionó la columna sql_password (evita romper el init). +-- Rollback: UPDATE a24c.database_nodes SET sql_password = NULL; +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'a24c' + AND table_name = 'database_nodes' + AND column_name = 'sql_password' + ) THEN + UPDATE a24c.database_nodes n + SET sql_password = rt.sql_password_encrypted + FROM a24c.restore_targets rt + WHERE n.restore_target_id = rt.id + AND rt.sql_password_encrypted IS NOT NULL + AND (n.sql_password IS NULL OR n.sql_password = ''); + END IF; +END $$; + +-- Bitácora de restauraciones reportadas por CloudRestoreAS. +CREATE TABLE IF NOT EXISTS a24c.restore_job_logs ( + id SERIAL PRIMARY KEY, + filename VARCHAR(500) NOT NULL, + restore_target_id INTEGER REFERENCES a24c.restore_targets (id) ON DELETE SET NULL, + db_name VARCHAR(255), + status VARCHAR(20) NOT NULL CHECK (status IN ('completed', 'failed', 'forwarded')), + duration_ms INTEGER, + error_message TEXT, + restored_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_target + ON a24c.restore_job_logs (restore_target_id); +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_restored_at + ON a24c.restore_job_logs (restored_at); +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_status + ON a24c.restore_job_logs (status); +-- Bitácora por servidor en el panel: últimas N restauraciones de un target. +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_target_date + ON a24c.restore_job_logs (restore_target_id, restored_at DESC); + +COMMENT ON TABLE a24c.restore_targets IS 'Servidores SQL Server destino de restauración (CloudRestoreAS). Contraseña cifrada AES-256-GCM.'; +COMMENT ON TABLE a24c.restore_job_logs IS 'Bitácora de restauraciones reportadas por CloudRestoreAS.'; + +-- Equivalente versionado en database/migrations/002_cloudrestore_status.{up,down}.sql +-- Carpeta de entrada reportada por CloudRestoreAS (solo lectura en el panel). +CREATE TABLE IF NOT EXISTS a24c.cloudrestore_status ( + id SERIAL PRIMARY KEY, + instance_key VARCHAR(120) NOT NULL UNIQUE DEFAULT 'default', + input_folder VARCHAR(500) NOT NULL, + host_name VARCHAR(255), + app_version VARCHAR(50), + reported_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE a24c.cloudrestore_status IS + 'Carpeta de entrada vigente reportada por CloudRestoreAS. Solo lectura en el panel.'; diff --git a/database/seeds/dev_cra_alfa_local.sql b/database/seeds/dev_cra_alfa_local.sql new file mode 100644 index 0000000..856f50c --- /dev/null +++ b/database/seeds/dev_cra_alfa_local.sql @@ -0,0 +1,47 @@ +-- Seed dev: CRA local en esta máquina (instancia Alfa). +-- Aplica sobre a24c en Postgres. Idempotente donde es posible. +-- Contraseñas cifradas con ENCRYPTION_KEY del .env del panel (Soluciones01). + +SET search_path TO a24c, public; + +-- Bitácora: permitir status forwarded (reenvío SFTP). +ALTER TABLE a24c.restore_job_logs DROP CONSTRAINT IF EXISTS restore_job_logs_status_check; +ALTER TABLE a24c.restore_job_logs ADD CONSTRAINT restore_job_logs_status_check + CHECK (status IN ('completed', 'failed', 'forwarded')); + +-- Servidor Alfa: SQL local + SSH local (misma máquina Windows). +UPDATE a24c.restore_targets +SET + server_ip = 'localhost', + sql_username = 'sa', + sql_password_encrypted = 'gcm:hp9C+T5Zvdxqc/CS:S+56zEHeJFszt3MptzjS9A==:1UTe60xRrMSOW0/+', + data_folder = 'C:\Program Files\Microsoft SQL Server\MSSQL16.MSSQLSERVER\MSSQL\DATA', + ssh_host = 'localhost', + ssh_port = 22, + ssh_username = 'hugo_reyes', + ssh_password_encrypted = 'gcm:iJgYS7AJ1osrrwHj:8D4TiN9xOEr207+HBIxt8w==:/wt/4ZwPY4Xg4CLA', + remote_inbox_path = 'C:\CloudRestore\Entrada', + notes = 'CRA dev local — esta máquina', + updated_at = now() +WHERE name = 'Alfa'; + +-- Carpeta de entrada reportada (hasta que el CRA guarde config real). +INSERT INTO a24c.cloudrestore_status (instance_key, input_folder, host_name, app_version, reported_at) +VALUES ('Alfa', 'C:\CloudRestore\Entrada', 'DEV-LOCAL', '1.0.0-dev', now()) +ON CONFLICT (instance_key) DO UPDATE SET + input_folder = EXCLUDED.input_folder, + host_name = EXCLUDED.host_name, + app_version = EXCLUDED.app_version, + reported_at = now(); + +-- Nodos de prueba asignados a Alfa con SQL en localhost. +UPDATE a24c.database_nodes +SET + server_name = 'localhost', + is_active = 1 +WHERE restore_target_id = (SELECT id FROM a24c.restore_targets WHERE name = 'Alfa'); + +-- Asegurar nodo de prueba principal para restore_local. +UPDATE a24c.database_nodes +SET restore_target_id = (SELECT id FROM a24c.restore_targets WHERE name = 'Alfa') +WHERE node_subnode_key = 'GENERICA-TEST'; diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml deleted file mode 100644 index 82a2fe3..0000000 --- a/docker-compose.postgres.yml +++ /dev/null @@ -1,52 +0,0 @@ -version: '3.8' - -services: - postgres: - image: postgres:15-alpine - container_name: aduanasoft-postgres - restart: unless-stopped - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: Control. - POSTGRES_DB: CONTROLDESK - PGDATA: /var/lib/postgresql/data/pgdata - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - aduanasoft-network - - # Tu aplicación Node.js (opcional, comentado por ahora) - # app: - # build: . - # container_name: aduanasoft-app - # restart: unless-stopped - # ports: - # - "3000:3000" - # environment: - # - NODE_ENV=production - # - DB_POSTGRES_HOST=postgres - # - DB_POSTGRES_PORT=5432 - # - DB_POSTGRES_USER=postgres - # - DB_POSTGRES_PASS=Control. - # - DB_POSTGRES_DB=CONTROLDESK - # depends_on: - # postgres: - # condition: service_healthy - # networks: - # - aduanasoft-network - -volumes: - postgres_data: - driver: local - -networks: - aduanasoft-network: - driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..369cb8f --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,53 @@ +services: + app: + image: dev.aduanasoft.com:8443/panel-bases-anexo24/app:latest + restart: unless-stopped + ports: + - "3000:3000" + environment: + # SQL Server: credenciales globales (server_name/database_name vienen de a24c.database_nodes en Postgres) + - PANEL_MSSQL_USER=${PANEL_MSSQL_USER} + - PANEL_MSSQL_PASSWORD=${PANEL_MSSQL_PASSWORD} + - PANEL_MSSQL_DOCKER=true + - IN_DOCKER=true + + # PostgreSQL: BD externa (ControlDesk), accesible desde el host + - DB_POSTGRES_HOST=host.docker.internal + - DB_POSTGRES_PORT=${DB_POSTGRES_PORT:-5432} + - DB_POSTGRES_USER=${DB_POSTGRES_USER} + - DB_POSTGRES_PASS=${DB_POSTGRES_PASS} + - DB_POSTGRES_DB=${DB_POSTGRES_DB} + + # JWT + - JWT_SECRET=${JWT_SECRET} + + # Clave Fernet compartida con a24c para cifrar la contraseña SQL de los nodos. + # DEBE ser idéntica a la SECRET_KEY del backend de a24c. + - SECRET_KEY=${SECRET_KEY} + # [LEGADO] Solo si existen credenciales en el formato antiguo `gcm:` por migrar. + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} + + # Ruta de respaldos montada desde el host + - BACKUP_PATH=/data/backups + + - PORT=3000 + - ORIGIN=${ORIGIN} + - NODE_ENV=production + + # SMTP para avisos (opcional — vacío deshabilita el envío sin errores) + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM=${SMTP_FROM:-} + - SMTP_USE_TLS=${SMTP_USE_TLS:-true} + volumes: + - ${BACKUP_HOST_PATH}:/data/backups + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - aduanasoft-network + +networks: + aduanasoft-network: + external: true diff --git a/docker-compose.yml b/docker-compose.yml index ded764b..0ed1ca0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,27 +5,18 @@ services: - "3000:3000" environment: # Use host.docker.internal to access resources on the host machine - - DB_PRIMARY_HOST=host.docker.internal - - DB_PRIMARY_USER=${DB_PRIMARY_USER} - - DB_PRIMARY_PASS=${DB_PRIMARY_PASS} - - DB_PRIMARY_DB=${DB_PRIMARY_DB} + # SQL Server: credenciales globales (server_name / database_name vienen de a24c.database_nodes en Postgres) + - PANEL_MSSQL_USER=${PANEL_MSSQL_USER} + - PANEL_MSSQL_PASSWORD=${PANEL_MSSQL_PASSWORD} + - PANEL_MSSQL_DOCKER=${PANEL_MSSQL_DOCKER:-true} + - IN_DOCKER=true - - DB_SECONDARY_HOST=host.docker.internal - - DB_SECONDARY_USER=${DB_SECONDARY_USER} - - DB_SECONDARY_PASS=${DB_SECONDARY_PASS} - - DB_SECONDARY_DB=${DB_SECONDARY_DB} - - - DB_AZURE_HOST=${DB_AZURE_HOST} - - DB_AZURE_USER=${DB_AZURE_USER} - - DB_AZURE_PASS=${DB_AZURE_PASS} - - DB_AZURE_DB=${DB_AZURE_DB} - - # PostgreSQL connection (connect to postgres service) - - DB_POSTGRES_HOST=postgres + # PostgreSQL: apunta a la a24c local (contenedor a24c-postgres publicado en el host :5432) + - DB_POSTGRES_HOST=host.docker.internal - DB_POSTGRES_PORT=5432 - DB_POSTGRES_USER=${DB_POSTGRES_USER} - DB_POSTGRES_PASS=${DB_POSTGRES_PASS} - - DB_POSTGRES_DB=${DB_POSTGRES_DB} + - DB_POSTGRES_DB=${DB_POSTGRES_DB} # JWT Secret - JWT_SECRET=${JWT_SECRET} @@ -36,10 +27,24 @@ services: - PORT=3000 - ORIGIN=https://localhost:3000 - NODE_ENV=production + + # SMTP para envío de avisos (opcional — vacío deshabilita el envío real sin errores) + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM=${SMTP_FROM:-} + - SMTP_USE_TLS=${SMTP_USE_TLS:-true} + + # Integración CloudRestoreAS + - CLOUDRESTORE_API_TOKEN=${CLOUDRESTORE_API_TOKEN} + # Clave Fernet compartida con a24c (debe coincidir con la SECRET_KEY de a24c) + - SECRET_KEY=${SECRET_KEY} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} volumes: # Map the actual backup folder from host to the container's backup path - # Windows path D:/BackupSFTP/ mapped to /data/backups inside container (read-only) - - D:/BackupSFTP:/data/backups:ro + # Local dev: carpeta escribible del repo con respaldos sintéticos (read-only en el contenedor) + - ./local-backups:/data/backups:ro extra_hosts: - "host.docker.internal:host-gateway" depends_on: @@ -57,7 +62,7 @@ services: POSTGRES_DB: ${DB_POSTGRES_DB} PGDATA: /var/lib/postgresql/data/pgdata ports: - - "5432:5432" + - "5434:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql diff --git a/package-lock.json b/package-lock.json index a6c4a60..6e99a0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,24 +8,31 @@ "name": "transmitiras-dashboard", "version": "0.0.1", "dependencies": { + "archiver": "^5.3.2", "bcrypt": "^6.0.0", "bootstrap": "^5.3.3", "dotenv": "^16.4.5", + "exceljs": "^4.4.0", "express": "^4.18.2", "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", - "pg": "^8.18.0" + "nodemailer": "^8.0.9", + "pg": "^8.18.0", + "ssh2-sftp-client": "^12.1.1" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.3.1", "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0", + "@types/archiver": "^5.3.4", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/mssql": "^9.1.5", "@types/node": "^22.0.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.16.0", + "@types/ssh2-sftp-client": "^9.0.6", "autoprefixer": "^10.4.24", "postcss": "^8.5.6", "svelte": "^5.0.0-next.1", @@ -33,7 +40,8 @@ "tailwindcss": "^3.4.19", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^2.1.9" } }, "node_modules/@alloc/quick-lru": { @@ -313,6 +321,380 @@ "node": ">=16" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/win32-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", @@ -330,6 +712,47 @@ "node": ">=12" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -592,6 +1015,328 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", @@ -755,6 +1500,16 @@ "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==", "license": "MIT" }, + "node_modules/@types/archiver": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.4.tgz", + "integrity": "sha512-Lj7fLBIMwYFgViVVZHEdExZC3lVYsl+QL0VmdNdIzGZH544jHveYWij6qdnBgJQDnR7pMKliN9z2cPZFEbhyPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", @@ -818,6 +1573,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", @@ -846,6 +1611,16 @@ "@types/node": "*" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -853,6 +1628,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-sftp-client": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.6.tgz", + "integrity": "sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^1.0.0" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz", @@ -867,6 +1679,129 @@ "node": ">=20.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -935,6 +1870,104 @@ "node": ">= 8" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -995,6 +2028,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -1070,7 +2128,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -1117,6 +2174,37 @@ "node": ">= 18" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1142,6 +2230,12 @@ "readable-stream": "^4.2.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -1216,7 +2310,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1310,6 +2403,38 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1335,6 +2460,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -1413,6 +2548,45 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1464,13 +2638,79 @@ "dev": true, "license": "MIT" }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compress-commons/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1508,6 +2748,65 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1572,6 +2871,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1589,6 +2894,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1752,6 +3067,51 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -1783,6 +3143,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -1891,6 +3260,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2048,6 +3424,50 @@ "node": ">=0.8.x" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -2118,6 +3538,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2238,13 +3671,49 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2351,7 +3820,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -2413,7 +3881,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-bigints": { @@ -2577,6 +4044,12 @@ ], "license": "BSD-3-Clause" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -2593,7 +4066,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -3131,6 +4603,54 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -3162,6 +4682,63 @@ "node": ">=6" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3182,6 +4759,12 @@ "dev": true, "license": "MIT" }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -3189,6 +4772,36 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3201,12 +4814,31 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -3225,12 +4857,37 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3348,7 +5005,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -3361,7 +5017,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3371,7 +5026,6 @@ "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.6" @@ -3597,6 +5251,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3664,11 +5325,19 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz", + "integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3751,7 +5420,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -3793,6 +5461,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3806,7 +5480,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3825,6 +5498,23 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pg": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", @@ -4171,6 +5861,12 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4282,6 +5978,36 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4380,7 +6106,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -4575,6 +6300,18 @@ "rimraf": "^2.5.2" } }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -4694,6 +6431,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -4772,6 +6515,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -4828,6 +6578,47 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2-sftp-client": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.1.1.tgz", + "integrity": "sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==", + "license": "Apache-2.0", + "dependencies": { + "concat-stream": "^2.0.0", + "ssh2": "^1.16.0" + }, + "engines": { + "node": ">=18.20.4" + }, + "funding": { + "type": "individual", + "url": "https://square.link/u/4g7sPflL" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4837,6 +6628,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5146,6 +6944,71 @@ "node": ">=10.13.0" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/tar-stream/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -5200,6 +7063,20 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5248,6 +7125,45 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5280,6 +7196,15 @@ "node": ">=6" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -5293,6 +7218,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -5380,6 +7311,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5427,6 +7364,60 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -5462,7 +7453,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -5552,6 +7542,29 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitefu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", @@ -5572,6 +7585,72 @@ } } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", @@ -5657,11 +7736,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/wsl-utils": { @@ -5680,6 +7775,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -5711,6 +7812,55 @@ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "dev": true, "license": "MIT" + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } } } } diff --git a/package.json b/package.json index 855de04..196266a 100644 --- a/package.json +++ b/package.json @@ -6,19 +6,24 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "start": "node server.js", - "check": "svelte-kit sync && tsc --noEmit" + "start": "node scripts/wait-for-a24c-schema.js && node server.js", + "check": "svelte-kit sync && tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.3.1", "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0", + "@types/archiver": "^5.3.4", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/mssql": "^9.1.5", "@types/node": "^22.0.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.16.0", + "@types/ssh2-sftp-client": "^9.0.6", "autoprefixer": "^10.4.24", "postcss": "^8.5.6", "svelte": "^5.0.0-next.1", @@ -26,16 +31,21 @@ "tailwindcss": "^3.4.19", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^2.1.9" }, "dependencies": { + "archiver": "^5.3.2", "bcrypt": "^6.0.0", "bootstrap": "^5.3.3", "dotenv": "^16.4.5", + "exceljs": "^4.4.0", "express": "^4.18.2", "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", - "pg": "^8.18.0" + "nodemailer": "^8.0.9", + "pg": "^8.18.0", + "ssh2-sftp-client": "^12.1.1" }, "type": "module" } diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..983b7fe --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Panel — verificando dependencias de base de datos..." +node /app/scripts/wait-for-a24c-schema.js + +echo "Panel — iniciando servidor HTTPS..." +exec node /app/server.js diff --git a/scripts/init-database.js b/scripts/init-database.js index 7d5bfd2..e48f46b 100644 --- a/scripts/init-database.js +++ b/scripts/init-database.js @@ -7,13 +7,19 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const pgPassword = process.env.DB_POSTGRES_PASS; +if (!pgPassword) { + console.error('❌ Falta variable de entorno DB_POSTGRES_PASS. Copia .env.example → .env y configura el valor.'); + process.exit(1); +} + // Configuración de PostgreSQL (Docker local) const pool = new Pool({ - host: 'localhost', - port: 5432, - database: 'CONTROLDESK', - user: 'postgres', - password: 'Control.', + host: process.env.DB_POSTGRES_HOST || 'localhost', + port: Number(process.env.DB_POSTGRES_PORT) || 5432, + database: process.env.DB_POSTGRES_DB || 'CONTROLDESK', + user: process.env.DB_POSTGRES_USER || 'postgres', + password: pgPassword, max: 1 }); @@ -41,17 +47,21 @@ async function initDatabase() { // Verificar las tablas creadas console.log('4. Verificando tablas creadas...'); const result = await client.query(` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name IN ('usuarios', 'usuario_base_datos', 'sesiones') + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'a24c' + AND table_name IN ( + 'dashboard_users', + 'dashboard_user_database_permissions', + 'dashboard_sessions' + ) ORDER BY table_name; `); - + if (result.rows.length === 3) { - console.log(' ✓ Tablas verificadas:'); + console.log(' ✓ Tablas verificadas (esquema a24c):'); result.rows.forEach(row => { - console.log(` - ${row.table_name}`); + console.log(` - a24c.${row.table_name}`); }); } else { console.log(' ⚠ No todas las tablas fueron creadas'); @@ -59,13 +69,16 @@ async function initDatabase() { // Verificar usuario admin console.log('\n5. Verificando usuario admin...'); - const userResult = await client.query('SELECT username, email, es_admin FROM usuarios WHERE username = $1', ['admin']); - + const userResult = await client.query( + 'SELECT username, email, is_admin FROM a24c.dashboard_users WHERE username = $1', + ['admin'] + ); + if (userResult.rows.length > 0) { console.log(' ✓ Usuario admin creado:'); console.log(` - Username: ${userResult.rows[0].username}`); console.log(` - Email: ${userResult.rows[0].email}`); - console.log(` - Es Admin: ${userResult.rows[0].es_admin}`); + console.log(` - Es Admin: ${userResult.rows[0].is_admin}`); } else { console.log(' ⚠ Usuario admin no encontrado'); } @@ -79,7 +92,7 @@ async function initDatabase() { console.log('1. Ejecuta: node scripts/generate-password-hash.js'); console.log('2. Copia el hash generado'); console.log('3. Ejecuta este SQL en PostgreSQL:'); - console.log(' UPDATE usuarios SET password_hash = \'TU_HASH_AQUI\' WHERE username = \'admin\';'); + console.log(' UPDATE a24c.dashboard_users SET password_hash = \'TU_HASH_AQUI\' WHERE username = \'admin\';'); console.log('\n4. Luego inicia el servidor: npm run dev'); console.log('5. Ve a: http://localhost:5173/login'); console.log(' Usuario: admin'); diff --git a/scripts/wait-for-a24c-schema.js b/scripts/wait-for-a24c-schema.js new file mode 100644 index 0000000..39abb2c --- /dev/null +++ b/scripts/wait-for-a24c-schema.js @@ -0,0 +1,98 @@ +/** + * Espera a que PostgreSQL responda y que el esquema CloudRestore (Alembic a24c) + * esté aplicado antes de arrancar el panel HTTPS. + */ +import { fileURLToPath } from 'url'; +import pg from 'pg'; + +const { Pool } = pg; + +const REQUIRED_TABLES = ['restore_targets', 'cloudrestore_status', 'restore_job_logs']; + +function envInt(name, fallback) { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function poolConfig() { + return { + host: process.env.DB_POSTGRES_HOST || 'localhost', + port: Number.parseInt(process.env.DB_POSTGRES_PORT || '5432', 10), + database: process.env.DB_POSTGRES_DB || 'a24c', + user: process.env.DB_POSTGRES_USER || 'postgres', + password: process.env.DB_POSTGRES_PASS || 'postgres', + max: 1, + connectionTimeoutMillis: 5000, + }; +} + +async function postgresReady(pool) { + await pool.query('SELECT 1'); +} + +async function tablesReady(pool) { + const r = await pool.query( + ` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'a24c' + AND table_name = ANY($1::text[]) + `, + [REQUIRED_TABLES] + ); + return r.rows.length === REQUIRED_TABLES.length; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForA24cSchema() { + const maxAttempts = envInt('A24C_SCHEMA_WAIT_ATTEMPTS', 60); + const intervalMs = envInt('A24C_SCHEMA_WAIT_INTERVAL_MS', 2000); + const pool = new Pool(poolConfig()); + + console.log('=========================================='); + console.log('Panel — esperando esquema a24c (CloudRestore)'); + console.log(` host: ${poolConfig().host}:${poolConfig().port}`); + console.log(` db: ${poolConfig().database}`); + console.log('=========================================='); + + try { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await postgresReady(pool); + if (await tablesReady(pool)) { + console.log('✓ PostgreSQL y esquema CloudRestore listos'); + return; + } + console.log( + ` intento ${attempt}/${maxAttempts}: tablas CRA pendientes (levanta a24c-backend si aún no corre)` + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log(` intento ${attempt}/${maxAttempts}: ${msg}`); + } + + if (attempt < maxAttempts) { + await sleep(intervalMs); + } + } + + throw new Error( + 'Timeout esperando esquema CloudRestore. Orden recomendado: ' + + 'a24c-postgres → a24c-backend (alembic upgrade head) → panel' + ); + } finally { + await pool.end(); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + waitForA24cSchema().catch((err) => { + console.error('❌', err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte new file mode 100644 index 0000000..1897deb --- /dev/null +++ b/src/lib/components/AppShell.svelte @@ -0,0 +1,426 @@ + + +{#snippet navLink(item: NavItem, isCollapsed: boolean)} + { + if (isCollapsed) showNavTip(item, e.currentTarget); + }} + onmouseleave={() => { + if (isCollapsed) hideNavTip(); + }} + class={`flex w-full items-center gap-2 rounded-lg py-2 text-[0.75rem] font-medium transition-colors ${ + isActive(item.href) + ? 'bg-sky-50 text-sky-800 border border-sky-100' + : 'text-slate-600 hover:bg-slate-100' + } ${isCollapsed ? 'justify-center px-2' : 'px-2.5'}`} + > + {item.icon} + {#if !isCollapsed}{item.label}{/if} + +{/snippet} + +{#snippet viewLink(view: PanelView, isCollapsed: boolean)} + handleViewClick(event, view)} + class={`flex w-full items-center gap-2 rounded-lg py-2 text-[0.75rem] font-medium transition-colors ${ + activePanelView === view.key + ? 'bg-sky-50 text-sky-800 border border-sky-100' + : 'text-slate-600 hover:bg-slate-100' + } ${isCollapsed ? 'justify-center px-2' : 'px-2.5'}`} + title={isCollapsed ? view.label : ''} + > + {view.icon} + {#if !isCollapsed}{view.label}{/if} + +{/snippet} + +{#snippet sidebarInner(isCollapsed: boolean, isMobile: boolean)} +
+ +
+
+
+ Aduanasoft +
+ {#if !isCollapsed || isMobile} +
+

Aduanasoft

+

Bases de datos

+
+ {/if} +
+ {#if isMobile} + + {:else} + + {/if} +
+ + + + + +
+ {#if !isCollapsed || isMobile} +
+
+
+ person +
+
+

{displayName}

+ {#if currentUser?.email} +

{currentUser.email}

+ {/if} +
+
+
+ +
+
Aduanasoft · v1.0.0
+
+ {:else} +
+
+ person +
+
+ +
+
+ {/if} +
+
+{/snippet} + +
+ + {#if $navigating} +
+ {/if} + + + + + + {#if collapsed && panelFlyoutOpen} + + {/if} + + + {#if collapsed && navTip} + + {/if} + + + {#if mobileOpen} + + + {/if} + + +
+ +
+
+ +

{title}

+
+
+ {#if headerActions} + {@render headerActions()} + {/if} + +
+
+ + +
+ {@render children()} +
+
+
diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte deleted file mode 100644 index 9ce79e0..0000000 --- a/src/lib/components/Navbar.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte deleted file mode 100644 index f1d5ac8..0000000 --- a/src/lib/components/Sidebar.svelte +++ /dev/null @@ -1,115 +0,0 @@ - - - diff --git a/src/lib/nav.test.ts b/src/lib/nav.test.ts new file mode 100644 index 0000000..0186e2b --- /dev/null +++ b/src/lib/nav.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { PANEL_VIEWS, isPanelView, panelViewsFor } from './nav'; + +describe('isPanelView', () => { + it('acepta todas las vistas definidas en PANEL_VIEWS', () => { + for (const view of PANEL_VIEWS) { + expect(isPanelView(view.key)).toBe(true); + } + }); + + it('rechaza valores desconocidos, null y undefined', () => { + expect(isPanelView('inexistente')).toBe(false); + expect(isPanelView('')).toBe(false); + expect(isPanelView(null)).toBe(false); + expect(isPanelView(undefined)).toBe(false); + }); + + it('rechaza variantes con mayúsculas o espacios (query params sucios)', () => { + expect(isPanelView('Dashboard')).toBe(false); + expect(isPanelView(' backups')).toBe(false); + }); +}); + +describe('panelViewsFor', () => { + it('para admin devuelve todas las vistas', () => { + expect(panelViewsFor(true)).toHaveLength(PANEL_VIEWS.length); + }); + + it('para no-admin excluye únicamente las vistas adminOnly', () => { + const views = panelViewsFor(false); + expect(views.every((view) => !view.adminOnly)).toBe(true); + const excluded = PANEL_VIEWS.filter((view) => view.adminOnly).map((view) => view.key); + expect(excluded).toEqual(['restored', 'failed', 'clients', 'databases']); + expect(views.map((view) => view.key)).toEqual( + PANEL_VIEWS.filter((view) => !view.adminOnly).map((view) => view.key) + ); + }); +}); diff --git a/src/lib/nav.ts b/src/lib/nav.ts new file mode 100644 index 0000000..1e2baef --- /dev/null +++ b/src/lib/nav.ts @@ -0,0 +1,36 @@ +/** + * Navegación centralizada del panel (fuente única de verdad). + * Consumida por AppShell (sidebar) y por la página raíz (+page.svelte). + */ +export type PanelView = { + key: string; + label: string; + icon: string; + adminOnly?: boolean; +}; + +const PANEL_VIEW_DEFS = [ + { key: 'dashboard', label: 'Resumen', icon: 'insights' }, + { key: 'backups', label: 'Respaldos Almacenados', icon: 'inventory_2' }, + { key: 'restored', label: 'Respaldos Restaurados', icon: 'cloud_done', adminOnly: true }, + { key: 'failed', label: 'Restores Fallidos', icon: 'error_outline', adminOnly: true }, + { key: 'clients', label: 'Catálogo de Clientes', icon: 'group', adminOnly: true }, + { key: 'alerts', label: 'Alertas Críticas', icon: 'warning_amber' }, + { key: 'databases', label: 'Gestión de Bases de Datos', icon: 'dns', adminOnly: true } +] as const satisfies readonly PanelView[]; + +export type PanelViewKey = (typeof PANEL_VIEW_DEFS)[number]['key']; + +export const PANEL_VIEWS: readonly PanelView[] = PANEL_VIEW_DEFS; + +const PANEL_VIEW_KEYS: readonly string[] = PANEL_VIEWS.map((view) => view.key); + +/** Valida que un valor arbitrario (ej. query param) sea una vista conocida. */ +export function isPanelView(value: string | null | undefined): value is PanelViewKey { + return typeof value === 'string' && PANEL_VIEW_KEYS.includes(value); +} + +/** Vistas visibles para el usuario según su rol. */ +export function panelViewsFor(isAdmin: boolean): readonly PanelView[] { + return isAdmin ? PANEL_VIEWS : PANEL_VIEWS.filter((view) => !view.adminOnly); +} diff --git a/src/lib/restore-suggest.test.ts b/src/lib/restore-suggest.test.ts new file mode 100644 index 0000000..eb30248 --- /dev/null +++ b/src/lib/restore-suggest.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import { + categorize, + suggestForServer, + distributeGlobal, + type Candidate +} from './restore-suggest'; + +describe('categorize', () => { + it('clasifica por límites con bordes inclusivos hacia la menor', () => { + expect(categorize(1)).toBe('Chica'); + expect(categorize(100)).toBe('Chica'); // borde chico + expect(categorize(101)).toBe('Mediana'); + expect(categorize(1024)).toBe('Mediana'); // borde mediano + expect(categorize(1025)).toBe('Grande'); + expect(categorize(500000)).toBe('Grande'); + }); + + it('respeta límites personalizados', () => { + expect(categorize(200, { chicoMb: 200, medianoMb: 2000 })).toBe('Chica'); + expect(categorize(201, { chicoMb: 200, medianoMb: 2000 })).toBe('Mediana'); + expect(categorize(2001, { chicoMb: 200, medianoMb: 2000 })).toBe('Grande'); + }); +}); + +const cand = (nodeId: number, sizeMb: number, categoria: Candidate['categoria']): Candidate => ({ + nodeId, + sizeMb, + categoria +}); + +describe('suggestForServer', () => { + it('no selecciona nada si el disco es nulo o cero', () => { + const candidates = [cand(1, 50, 'Chica')]; + expect(suggestForServer({ candidates, diskGb: 0 })).toEqual([]); + expect(suggestForServer({ candidates, diskGb: null })).toEqual([]); + }); + + it('respeta el % del disco (no excede el presupuesto)', () => { + // disco 1 GB = 1024 MB, pct 80% => 819.2 MB de presupuesto. + const candidates = [ + cand(1, 500, 'Mediana'), + cand(2, 400, 'Mediana'), + cand(3, 400, 'Mediana') + ]; + const picked = suggestForServer({ candidates, diskGb: 1, pct: 80 }); + const sum = candidates + .filter((c) => picked.includes(c.nodeId)) + .reduce((a, c) => a + c.sizeMb, 0); + expect(sum).toBeLessThanOrEqual(1024 * 0.8); + // Debe caber 500 + una de 400 (900 > 819) -> en realidad 500+400=900 excede; solo 500+... + // 500 cabe; luego 400 -> 900 > 819 no cabe; otra 400 tampoco. Selecciona solo la de 500. + expect(picked).toEqual([1]); + }); + + it('balancea entre categorías (un poco de todo) antes de agotar una sola', () => { + const candidates = [ + cand(1, 900, 'Grande'), + cand(2, 800, 'Grande'), + cand(3, 300, 'Mediana'), + cand(4, 200, 'Mediana'), + cand(5, 50, 'Chica'), + cand(6, 40, 'Chica') + ]; + // disco 10 GB, pct 100 => 10240 MB, cabe todo. + const picked = suggestForServer({ candidates, diskGb: 10, pct: 100 }); + expect(new Set(picked)).toEqual(new Set([1, 2, 3, 4, 5, 6])); + // Primera ronda toma la mayor de cada categoría: Grande(900), Mediana(300), Chica(50). + expect(picked.slice(0, 3)).toEqual([1, 3, 5]); + }); + + it('toma una base menor de la categoría si la mayor no cabe', () => { + const candidates = [cand(1, 1000, 'Grande'), cand(2, 100, 'Grande')]; + // presupuesto 819 MB: la de 1000 no cabe, la de 100 sí. + const picked = suggestForServer({ candidates, diskGb: 1, pct: 80 }); + expect(picked).toEqual([2]); + }); + + it('excluye tamaños no usables (NaN, 0, negativos)', () => { + const candidates = [ + cand(1, Number.NaN, 'Grande'), + cand(2, 0, 'Chica'), + cand(3, -50, 'Mediana'), + cand(4, 200, 'Mediana') + ]; + const picked = suggestForServer({ candidates, diskGb: 10, pct: 100 }); + expect(picked).toEqual([4]); + }); + + it('devuelve [] si el disco es inválido (NaN/Infinity)', () => { + const candidates = [cand(1, 50, 'Chica')]; + expect(suggestForServer({ candidates, diskGb: Number.NaN })).toEqual([]); + expect(suggestForServer({ candidates, diskGb: Infinity })).toEqual([]); + }); +}); + +describe('distributeGlobal', () => { + it('ignora servidores sin disco y deja todo sin asignar', () => { + const candidates = [cand(1, 100, 'Chica')]; + const res = distributeGlobal({ servers: [{ id: 1, diskGb: null }], candidates }); + expect(res).toEqual({ 1: null }); + }); + + it('nunca excede el presupuesto de cada servidor ni asigna dos veces', () => { + const candidates = [ + cand(1, 700, 'Grande'), + cand(2, 700, 'Grande'), + cand(3, 700, 'Grande'), + cand(4, 700, 'Grande') + ]; + // Dos servidores de 1 GB (819 MB @80%): cada uno solo admite una de 700. + const servers = [ + { id: 10, diskGb: 1 }, + { id: 20, diskGb: 1 } + ]; + const res = distributeGlobal({ servers, candidates, pct: 80 }); + const perServer: Record = {}; + for (const c of candidates) { + const s = res[c.nodeId]; + if (s != null) perServer[s] = (perServer[s] ?? 0) + c.sizeMb; + } + for (const total of Object.values(perServer)) { + expect(total).toBeLessThanOrEqual(1024 * 0.8); + } + // Solo caben 2 bases (una por servidor); las otras 2 quedan null. + const assigned = candidates.filter((c) => res[c.nodeId] != null); + expect(assigned.length).toBe(2); + }); + + it('reparte proporcional al disco (el más grande recibe más)', () => { + const candidates = Array.from({ length: 6 }, (_, i) => cand(i + 1, 100, 'Chica')); + // Server A tiene 3x el disco de B => debe recibir más bases. + const servers = [ + { id: 1, diskGb: 3 }, + { id: 2, diskGb: 1 } + ]; + const res = distributeGlobal({ servers, candidates, pct: 100 }); + const countA = candidates.filter((c) => res[c.nodeId] === 1).length; + const countB = candidates.filter((c) => res[c.nodeId] === 2).length; + expect(countA).toBeGreaterThan(countB); + expect(countA + countB).toBe(6); + }); + + it('nunca asigna a un servidor sin disco usable', () => { + const candidates = [cand(1, 100, 'Chica'), cand(2, 100, 'Chica')]; + const servers = [ + { id: 1, diskGb: null }, + { id: 2, diskGb: Number.NaN }, + { id: 3, diskGb: 10 } + ]; + const res = distributeGlobal({ servers, candidates, pct: 100 }); + expect(res[1]).toBe(3); + expect(res[2]).toBe(3); + // ningún nodo terminó en 1 ni 2 + expect(Object.values(res).every((v) => v === 3)).toBe(true); + }); + + it('ignora candidatos sin tamaño usable y deja el resto coherente', () => { + const candidates = [cand(1, 0, 'Chica'), cand(2, Number.NaN, 'Grande'), cand(3, 100, 'Chica')]; + const res = distributeGlobal({ servers: [{ id: 9, diskGb: 10 }], candidates, pct: 100 }); + expect(res[1]).toBeNull(); + expect(res[2]).toBeNull(); + expect(res[3]).toBe(9); + }); + + it('base más grande que cualquier disco queda sin ubicar (null)', () => { + const candidates = [cand(1, 5000, 'Grande')]; + const res = distributeGlobal({ servers: [{ id: 1, diskGb: 1 }], candidates, pct: 80 }); + expect(res[1]).toBeNull(); + }); +}); diff --git a/src/lib/restore-suggest.ts b/src/lib/restore-suggest.ts new file mode 100644 index 0000000..3836cca --- /dev/null +++ b/src/lib/restore-suggest.ts @@ -0,0 +1,165 @@ +/** + * Lógica pura de distribución de bases entre servidores de restauración por capacidad. + * Sin dependencias de SvelteKit ni de servidor: se usa en el cliente y se prueba con Vitest. + * + * Regla de tamaños (misma clasificación que el query provisto por el negocio): + * Chica <= chicoMb (default 100 MB) + * Mediana <= medianoMb (default 1024 MB) + * Grande en otro caso + * + * El disco es la capacidad que manda: cada servidor se llena hasta `pct`% de su disco. + */ + +export type SizeCategory = 'Chica' | 'Mediana' | 'Grande'; + +/** Orden de reparto para el balanceo "un poco de todo" (mayor impacto primero). */ +export const CATEGORY_ORDER: readonly SizeCategory[] = ['Grande', 'Mediana', 'Chica'] as const; + +export const DEFAULT_LIMITS = { chicoMb: 100, medianoMb: 1024 } as const; +export const DEFAULT_PCT = 80; +const MB_PER_GB = 1024; + +export interface Candidate { + nodeId: number; + sizeMb: number; + categoria: SizeCategory; +} + +export interface ServerCapacity { + id: number; + diskGb: number | null | undefined; +} + +/** Clasifica un tamaño en MB según los límites (bordes inclusivos hacia la categoría menor). */ +export function categorize( + sizeMb: number, + limits: { chicoMb?: number; medianoMb?: number } = {} +): SizeCategory { + const chicoMb = limits.chicoMb ?? DEFAULT_LIMITS.chicoMb; + const medianoMb = limits.medianoMb ?? DEFAULT_LIMITS.medianoMb; + if (sizeMb <= chicoMb) return 'Chica'; + if (sizeMb <= medianoMb) return 'Mediana'; + return 'Grande'; +} + +function budgetMbForDisk(diskGb: number, pct: number): number { + return diskGb * MB_PER_GB * (pct / 100); +} + +/** Disco utilizable: número finito y positivo. */ +export function hasUsableDisk(diskGb: number | null | undefined): diskGb is number { + return typeof diskGb === 'number' && Number.isFinite(diskGb) && diskGb > 0; +} + +/** Tamaño utilizable: número finito y positivo (excluye NaN, 0 y negativos). */ +export function isUsableSize(sizeMb: number | null | undefined): sizeMb is number { + return typeof sizeMb === 'number' && Number.isFinite(sizeMb) && sizeMb > 0; +} + +function bucketsByCategory(candidates: Candidate[]): Record { + const buckets: Record = { Grande: [], Mediana: [], Chica: [] }; + for (const c of candidates) { + // Ignora candidatos sin tamaño usable (base no medible: SQL caído, sin métrica, 0). + if (!isUsableSize(c.sizeMb)) continue; + if (buckets[c.categoria]) buckets[c.categoria].push(c); + } + // Mayor a menor dentro de cada categoría (empaca primero lo grande). + for (const cat of CATEGORY_ORDER) buckets[cat].sort((a, b) => b.sizeMb - a.sizeMb); + return buckets; +} + +/** + * Selecciona, para UN servidor, un subconjunto balanceado de `candidates` que quepa en + * `pct`% del disco. Reparte por rondas entre categorías (Grande→Mediana→Chica) tomando en + * cada ronda la mayor de cada categoría que aún quepa. Devuelve los nodeId seleccionados. + */ +export function suggestForServer(opts: { + candidates: Candidate[]; + diskGb: number | null | undefined; + pct?: number; +}): number[] { + const { candidates } = opts; + const pct = opts.pct ?? DEFAULT_PCT; + if (!hasUsableDisk(opts.diskGb)) return []; + const budgetMb = budgetMbForDisk(opts.diskGb, pct); + + const buckets = bucketsByCategory(candidates); + const selected: number[] = []; + let usedMb = 0; + let progressed = true; + while (progressed) { + progressed = false; + for (const cat of CATEGORY_ORDER) { + const bucket = buckets[cat]; + // La mayor que aún quepa (sorted desc → primera que cumpla). + const idx = bucket.findIndex((c) => usedMb + c.sizeMb <= budgetMb); + if (idx >= 0) { + const [item] = bucket.splice(idx, 1); + selected.push(item.nodeId); + usedMb += item.sizeMb; + progressed = true; + } + } + } + return selected; +} + +/** + * Reparte `candidates` entre varios `servers` de forma balanceada (un poco de cada categoría) + * y proporcional al disco (el servidor con más capacidad libre recibe primero). Respeta el + * `pct`% del disco de cada servidor y nunca asigna una base a más de un servidor. + * Devuelve un mapa nodeId → serverId (o null si no cupo en ningún servidor). + */ +export function distributeGlobal(opts: { + servers: ServerCapacity[]; + candidates: Candidate[]; + pct?: number; +}): Record { + const pct = opts.pct ?? DEFAULT_PCT; + const state = opts.servers + .filter((s) => hasUsableDisk(s.diskGb)) + .map((s) => ({ id: s.id, budgetMb: budgetMbForDisk(s.diskGb as number, pct), usedMb: 0 })); + + const assignment: Record = {}; + for (const c of opts.candidates) assignment[c.nodeId] = null; + if (state.length === 0) return assignment; + + const buckets = bucketsByCategory(opts.candidates); + let progressed = true; + while (progressed) { + progressed = false; + for (const cat of CATEGORY_ORDER) { + const bucket = buckets[cat]; + if (bucket.length === 0) continue; + // Mayor base de la categoría que quepa en algún servidor + su mejor servidor (más libre). + let pickIdx = -1; + let pickServer: (typeof state)[number] | null = null; + for (let i = 0; i < bucket.length; i++) { + let best: (typeof state)[number] | null = null; + let bestFree = -1; + for (const st of state) { + const free = st.budgetMb - st.usedMb; + if (bucket[i].sizeMb <= free && free > bestFree) { + best = st; + bestFree = free; + } + } + if (best) { + pickIdx = i; + pickServer = best; + break; + } + } + if (pickIdx >= 0 && pickServer) { + const [item] = bucket.splice(pickIdx, 1); + pickServer.usedMb += item.sizeMb; + assignment[item.nodeId] = pickServer.id; + progressed = true; + } else { + // Ninguna base de esta categoría cabe en ningún servidor: quedan sin asignar. + bucket.length = 0; + } + } + } + return assignment; +} diff --git a/src/lib/server/api-error.ts b/src/lib/server/api-error.ts new file mode 100644 index 0000000..ff8000c --- /dev/null +++ b/src/lib/server/api-error.ts @@ -0,0 +1,18 @@ +/** + * Respuestas de error con la estructura estándar Aduanasoft §5: + * { error: { code, message, trace_id } } + */ +import { json } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; + +export function newTraceId(): string { + return randomUUID(); +} + +export function errorJson( + code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 503, + message: string, + traceId: string +) { + return json({ error: { code, message, trace_id: traceId } }, { status: code }); +} diff --git a/src/lib/server/backup-files.test.ts b/src/lib/server/backup-files.test.ts new file mode 100644 index 0000000..8b9988d --- /dev/null +++ b/src/lib/server/backup-files.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { listBackupFiles, deriveSiblingFolder } from './backup-files'; + +let root: string; + +async function writeFile(rel: string, bytes: number, mtime: Date): Promise { + const full = path.join(root, rel); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, Buffer.alloc(bytes, 'x')); + await fs.utimes(full, mtime, mtime); +} + +const NEW = new Date('2026-07-01T12:00:00Z'); +const MID = new Date('2026-07-01T10:00:00Z'); +const OLD = new Date('2026-06-30T09:00:00Z'); + +beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-files-')); + // Carpeta de fecha reciente: un ZIP simple y un multipart de 3 partes. + await writeFile('2026-07-01/NEW.ZIP', 200, NEW); + await writeFile('2026-07-01/SPLIT.zip.001', 100, MID); + await writeFile('2026-07-01/SPLIT.zip.002', 100, MID); + await writeFile('2026-07-01/SPLIT.zip.003', 50, MID); + // Carpeta de fecha anterior: un ZIP y un archivo aún más profundo (nivel 3). + await writeFile('2026-06-30/OLD.ZIP', 300, OLD); + await writeFile('2026-06-30/nested/TOODEEP.ZIP', 10, OLD); +}); + +afterAll(async () => { + await fs.rm(root, { recursive: true, force: true }); +}); + +describe('listBackupFiles', () => { + it('recorre subcarpetas de fecha y devuelve relPath en formato POSIX', async () => { + const { files } = await listBackupFiles(root); + const byName = new Map(files.map((f) => [f.name, f])); + expect(byName.get('NEW.ZIP')?.relPath).toBe('2026-07-01/NEW.ZIP'); + expect(byName.get('OLD.ZIP')?.relPath).toBe('2026-06-30/OLD.ZIP'); + }); + + it('colapsa multipart en una sola entrada sumando tamaños y apuntando a la parte .001', async () => { + const { files } = await listBackupFiles(root); + const split = files.find((f) => f.name === 'SPLIT.zip'); + expect(split).toBeDefined(); + expect(split?.parts).toBe(3); + expect(split?.sizeBytes).toBe(250); + expect(split?.relPath).toBe('2026-07-01/SPLIT.zip.001'); + }); + + it('ordena por fecha de modificación descendente (más reciente primero)', async () => { + const { files } = await listBackupFiles(root); + const order = files.map((f) => f.name); + // NEW (12:00) > SPLIT (10:00) > OLD (09:00). TOODEEP excluido por profundidad. + expect(order).toEqual(['NEW.ZIP', 'SPLIT.zip', 'OLD.ZIP']); + }); + + it('respeta maxDepth: excluye archivos más profundos que la subcarpeta de fecha', async () => { + const shallow = await listBackupFiles(root, { maxDepth: 2 }); + expect(shallow.files.some((f) => f.name === 'TOODEEP.ZIP')).toBe(false); + + const deep = await listBackupFiles(root, { maxDepth: 3 }); + expect(deep.files.some((f) => f.name === 'TOODEEP.ZIP')).toBe(true); + }); + + it('recorta con maxFiles conservando la carpeta de fecha más reciente y marca truncated', async () => { + const { files, truncated } = await listBackupFiles(root, { maxFiles: 1 }); + expect(truncated).toBe(true); + expect(files.length).toBeGreaterThanOrEqual(1); + expect(files.every((f) => f.relPath.startsWith('2026-07-01/'))).toBe(true); + }); + + it('no recorta cuando el tope es holgado', async () => { + const { truncated } = await listBackupFiles(root, { maxFiles: 1000 }); + expect(truncated).toBe(false); + }); + + it('lanza si la carpeta raíz no existe', async () => { + await expect(listBackupFiles(path.join(root, 'no-existe'))).rejects.toThrow(); + }); +}); + +describe('deriveSiblingFolder', () => { + it('deriva la hermana en rutas Windows (backslash)', () => { + expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada', 'Procesados')).toBe( + 'D:\\CloudRestore\\Procesados' + ); + expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada', 'Fallados')).toBe( + 'D:\\CloudRestore\\Fallados' + ); + }); + + it('deriva la hermana en rutas POSIX', () => { + expect(deriveSiblingFolder('/mnt/x/Entrada', 'Procesados')).toBe('/mnt/x/Procesados'); + }); + + it('tolera separador final', () => { + expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada\\', 'Procesados')).toBe( + 'D:\\CloudRestore\\Procesados' + ); + expect(deriveSiblingFolder('/mnt/x/Entrada/', 'Fallados')).toBe('/mnt/x/Fallados'); + }); + + it('funciona con rutas en la raíz del disco y UNC', () => { + expect(deriveSiblingFolder('D:\\sftp', 'Procesados')).toBe('D:\\Procesados'); + expect(deriveSiblingFolder('\\\\srv\\share\\Entrada', 'Procesados')).toBe( + '\\\\srv\\share\\Procesados' + ); + }); + + it('sin separador devuelve el nombre hermano como fallback', () => { + expect(deriveSiblingFolder('Entrada', 'Procesados')).toBe('Procesados'); + expect(deriveSiblingFolder('', 'Procesados')).toBe('Procesados'); + }); +}); diff --git a/src/lib/server/backup-files.ts b/src/lib/server/backup-files.ts new file mode 100644 index 0000000..09e622c Binary files /dev/null and b/src/lib/server/backup-files.ts differ diff --git a/src/lib/server/controldesk-pg.test.ts b/src/lib/server/controldesk-pg.test.ts new file mode 100644 index 0000000..1ab1ba8 --- /dev/null +++ b/src/lib/server/controldesk-pg.test.ts @@ -0,0 +1,32 @@ +/** + * Pruebas de matchNodeRowFromBackupStem (resolución de nodo desde nombre de archivo). + */ +import { describe, it, expect } from 'vitest'; +import { matchNodeRowFromBackupStem } from './controldesk-pg'; + +const NODES = [ + { + NodoSubNodo: 'GENERICA-TEST', + BDName: 'GENERICA-TEST' + }, + { + NodoSubNodo: '08037NATM001', + BDName: 'CLIENTE_DB' + } +]; + +describe('matchNodeRowFromBackupStem', () => { + it('coincide por stem exacto', () => { + const row = matchNodeRowFromBackupStem('GENERICA-TEST', NODES); + expect(row?.NodoSubNodo).toBe('GENERICA-TEST'); + }); + + it('coincide por prefijo con sufijo', () => { + const row = matchNodeRowFromBackupStem('08037NATM001.KNOWNWORLD.000', NODES); + expect(row?.NodoSubNodo).toBe('08037NATM001'); + }); + + it('devuelve null si no hay match', () => { + expect(matchNodeRowFromBackupStem('DESCONOCIDO', NODES)).toBeNull(); + }); +}); diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts new file mode 100644 index 0000000..3d6c258 --- /dev/null +++ b/src/lib/server/controldesk-pg.ts @@ -0,0 +1,1376 @@ +/** + * Catálogo ControlDesk en PostgreSQL (esquema a24c; DDL lo aprovisiona otra aplicación). + * Devuelve columnas con alias en español para compatibilidad con la UI existente. + */ +import path from 'node:path'; +import { env } from '$env/dynamic/private'; +import { pgPool } from './db'; +import { encryptSecret, decryptSecret } from './crypto'; + +function schemaName(): string { + const s = 'a24c'; + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) return 'a24c'; + return s; +} + +function qNodes(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."database_nodes"`; +} + +function qUsers(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."portal_users"`; +} + +function qRestoreTargets(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."restore_targets"`; +} + +function qRestoreJobLogs(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."restore_job_logs"`; +} + +function qCloudRestoreStatus(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."cloudrestore_status"`; +} + +function qNodeLastRestore(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."node_last_restore"`; +} + +function isPgUndefinedTable(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42P01'; +} + +function isPgUndefinedColumn(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42703'; +} + +/** Crea cloudrestore_status si la BD existía antes de la migración 002 (idempotente). */ +async function ensureCloudRestoreStatusTable(): Promise { + await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c'); + await pgPool.query(` + CREATE TABLE IF NOT EXISTS ${qCloudRestoreStatus()} ( + id SERIAL PRIMARY KEY, + instance_key VARCHAR(120) NOT NULL UNIQUE DEFAULT 'default', + input_folder VARCHAR(500) NOT NULL, + host_name VARCHAR(255), + app_version VARCHAR(50), + reported_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + // Carpeta de procesados reportada por el restaurador (opcional). Si no la reporta, el + // panel la deriva como hermana de input_folder (deriveSiblingFolder). + await pgPool.query( + `ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS processed_folder VARCHAR(500)` + ); +} + +/** Añade columnas de tamaño/ruta relativa a restore_job_logs si faltan (idempotente). */ +async function ensureRestoreJobLogColumns(): Promise { + for (const stmt of [ + `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS size_bytes BIGINT`, + `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS rel_path VARCHAR(600)` + ]) { + try { + await pgPool.query(stmt); + } catch (e) { + if (!isPgUndefinedTable(e)) throw e; // la tabla la crea a24c; si no existe aún, se ignora + } + } +} + +/** + * Estado "último respaldo por nodo": una fila por nodo, con el restaurador donde vive el + * archivo. Llaveada por nodo (NO por la asignación actual) para que el tracking del último + * respaldo persista aunque el nodo se reasigne a otro restaurador. Idempotente. + */ +async function ensureNodeLastRestoreTable(): Promise { + await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c'); + await pgPool.query(` + CREATE TABLE IF NOT EXISTS ${qNodeLastRestore()} ( + database_node_id INTEGER PRIMARY KEY, + restore_target_id INTEGER, + db_name VARCHAR(255), + node_key VARCHAR(255), + filename VARCHAR(500) NOT NULL, + rel_path VARCHAR(600), + size_bytes BIGINT, + restored_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + await pgPool.query( + `CREATE INDEX IF NOT EXISTS idx_a24c_node_last_restore_target + ON ${qNodeLastRestore()} (restore_target_id)` + ); +} + +/** Crea restore_targets y filas Alfa/Omega/Gamma si faltan (migración 001, idempotente). */ +async function ensureRestoreTargetsSchema(): Promise { + await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c'); + await pgPool.query(` + CREATE TABLE IF NOT EXISTS ${qRestoreTargets()} ( + id SERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL UNIQUE, + server_ip VARCHAR(255), + sql_username VARCHAR(128), + sql_password_encrypted TEXT, + data_folder VARCHAR(500), + ssh_host VARCHAR(255), + ssh_port INTEGER DEFAULT 22, + ssh_username VARCHAR(128), + ssh_password_encrypted TEXT, + remote_inbox_path VARCHAR(500), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + await pgPool.query(` + INSERT INTO ${qRestoreTargets()} (name) VALUES ('Alfa'), ('Omega'), ('Gamma') + ON CONFLICT (name) DO NOTHING + `); + for (const stmt of [ + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_host VARCHAR(255)`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_port INTEGER DEFAULT 22`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_username VARCHAR(128)`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_password_encrypted TEXT`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS remote_inbox_path VARCHAR(500)`, + // Características de hardware opcionales (para distribuir bases por capacidad). + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS os VARCHAR(50)`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ram_gb INTEGER`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS disk_gb INTEGER`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS location VARCHAR(255)`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS size_category VARCHAR(20)` + ]) { + await pgPool.query(stmt); + } + try { + await pgPool.query(` + ALTER TABLE ${qNodes()} + ADD COLUMN IF NOT EXISTS restore_target_id INTEGER + REFERENCES ${qRestoreTargets()} (id) ON DELETE SET NULL + `); + } catch { + /* database_nodes puede no existir aún en algunos entornos */ + } +} + +const ROW_DATABASE_NODE = ` + id AS "ID", + node_subnode_key AS "NodoSubNodo", + is_active AS "Activo", + rfc AS "RFC", + legal_name AS "Nombre", + branch_name AS "Sucursal", + notification_email AS "CorreoNotificacion", + server_name AS "ServerName", + database_name AS "BDName", + restore_target_id AS "RestoreTargetId", + anexo24c_aviso_fecha AS "Anexo24CAvisoFecha" +`; + +const ROW_PORTAL_USER = ` + id AS "ID", + database_node_id AS "IDNodoSubNodo", + is_authority_client AS "ClienteAutoridad", + full_name AS "Nombre", + username AS "Usuario", + bd_shelter AS "BD_Shelter" +`; + +export async function listDatabaseNodes(): Promise { + const sql = `SELECT ${ROW_DATABASE_NODE} FROM ${qNodes()}`; + const r = await pgPool.query(sql); + return r.rows; +} + +export async function listDatabaseNodesActive(): Promise { + const sql = ` + SELECT ${ROW_DATABASE_NODE} + FROM ${qNodes()} + WHERE is_active = 1 + AND database_name IS NOT NULL + AND TRIM(database_name) <> '' + ORDER BY node_subnode_key + `; + const r = await pgPool.query(sql); + return r.rows; +} + +/** Nodos activos con `sql_password` para conectar a SQL Server (no exponer al cliente). */ +export async function listDatabaseNodesForMssql(): Promise { + const where = ` + WHERE is_active = 1 + AND database_name IS NOT NULL + AND TRIM(database_name) <> '' + ORDER BY node_subnode_key + `; + try { + const r = await pgPool.query( + `SELECT ${ROW_DATABASE_NODE}, sql_password AS "sql_password" FROM ${qNodes()} ${where}` + ); + return r.rows; + } catch (e: any) { + if (e?.code === '42703') { + const r = await pgPool.query(`SELECT ${ROW_DATABASE_NODE} FROM ${qNodes()} ${where}`); + return r.rows.map((row: any) => ({ ...row, sql_password: null })); + } + throw e; + } +} + +export async function listClientsCatalog(): Promise { + const sql = ` + SELECT + id AS "ID", + legal_name AS "Nombre", + node_subnode_key AS "NodoSubNodo", + notification_email AS "CorreoNotificacion", + is_active AS "Activo", + database_name AS "BDName" + FROM ${qNodes()} + `; + const r = await pgPool.query(sql); + return r.rows; +} + +export async function listPortalUsers(): Promise { + const sql = `SELECT ${ROW_PORTAL_USER} FROM ${qUsers()} ORDER BY id`; + const r = await pgPool.query(sql); + return r.rows; +} + +/** + * Usuarios del portal (cliente/autoridad) con los datos de su nodo, para el + * reporte de asesores. NO incluye contraseñas: `portal_users.password_hash` + * es un hash bcrypt irreversible y nunca se expone. + */ +export async function listPortalUsersWithNode(): Promise { + const sql = ` + SELECT + pu.id AS "ID", + pu.is_authority_client AS "ClienteAutoridad", + pu.full_name AS "Nombre", + pu.username AS "Usuario", + pu.bd_shelter AS "BD_Shelter", + dn.node_subnode_key AS "NodoSubNodo", + dn.legal_name AS "Cliente", + dn.rfc AS "RFC", + dn.database_name AS "BDName", + dn.is_active AS "NodoActivo" + FROM ${qUsers()} pu + LEFT JOIN ${qNodes()} dn ON pu.database_node_id = dn.id + ORDER BY dn.node_subnode_key, pu.is_authority_client, pu.username + `; + const r = await pgPool.query(sql); + return r.rows; +} + +export async function lookupNodeByNodoOrBdName(nodoName: string): Promise { + const sql = ` + SELECT + legal_name AS "Nombre", + node_subnode_key AS "NodoSubNodo", + rfc AS "RFC" + FROM ${qNodes()} + WHERE LOWER(TRIM(node_subnode_key)) = LOWER(TRIM($1::text)) + OR LOWER(TRIM(database_name)) = LOWER(TRIM($1::text)) + LIMIT 1 + `; + const r = await pgPool.query(sql, [nodoName]); + return r.rows[0] ?? null; +} + +/** + * Asocia el nombre de archivo de respaldo (sin extensión) a una fila de `database_nodes`. + * Cubre: + * - igualdad sin mayúsculas; + * - el primer segmento separado por punto como token de nodo + * (ej. `08037natm001.KNOWNWORLD.000` → nodo `08037NATM001`; el sufijo + * `.KNOWNWORLD` = nombre lógico de la BD origen y `.000` = secuencia se ignoran); + * - prefijos con separadores `.`, `_` o `-` (ej. `NODO001_full_20240414` → `NODO001`). + */ +export function matchNodeRowFromBackupStem(stem: string, nodes: any[]): any | null { + const raw = String(stem ?? '').trim(); + if (!raw || !nodes?.length) return null; + const key = raw.toLowerCase(); + + // Candidatos para igualdad exacta: el stem completo y su primer segmento (token de nodo). + const head = key.split('.')[0]; + const exactKeys = head && head !== key ? [key, head] : [key]; + + const nodoOf = (row: any) => String(row.NodoSubNodo ?? '').trim().toLowerCase(); + const bdOf = (row: any) => String(row.BDName ?? '').trim().toLowerCase(); + + for (const row of nodes) { + const n = nodoOf(row); + const b = bdOf(row); + for (const k of exactKeys) { + if (n && n === k) return row; + if (b && b === k) return row; + } + } + + // Coincidencia por prefijo: el nodo/BD seguido de un separador (`.`, `_` o `-`). + const startsWithToken = (token: string) => + key.startsWith(`${token}.`) || key.startsWith(`${token}_`) || key.startsWith(`${token}-`); + + type Cand = { row: any; len: number }; + const cands: Cand[] = []; + for (const row of nodes) { + const n = nodoOf(row); + const b = bdOf(row); + if (n && (key === n || startsWithToken(n))) { + cands.push({ row, len: n.length }); + } + if (b && b !== n && (key === b || startsWithToken(b))) { + cands.push({ row, len: b.length }); + } + } + if (!cands.length) return null; + // Ganar el match más específico (token más largo) para no confundir nodos con prefijo común. + cands.sort((a, b) => b.len - a.len); + return cands[0].row; +} + +/** Datos de contacto para alertas (equivalente a la consulta previa sobre Usuarios/BasesDeDatos). */ +export async function lookupAlertClientData(nodoName: string): Promise { + // El "Cliente" de la alerta es el nombre de la tabla de bases de datos + // (database_nodes.legal_name), NO el full_name del usuario del portal. Se resuelve el nodo por + // database_name o node_subnode_key; así también funciona para bases sin usuario asociado. + const sql = ` + SELECT + dn.legal_name AS "Nombre", + dn.notification_email AS "CorreoNotificacion", + dn.node_subnode_key AS "NodoSubNodo" + FROM ${qNodes()} dn + WHERE LOWER(TRIM(dn.database_name)) = LOWER(TRIM($1::text)) + OR LOWER(TRIM(dn.node_subnode_key)) = LOWER(TRIM($1::text)) + LIMIT 1 + `; + const r = await pgPool.query(sql, [nodoName]); + return r.rows[0] ?? null; +} + +export async function updateNodeActive(id: number, activo: boolean): Promise { + await pgPool.query(`UPDATE ${qNodes()} SET is_active = $1 WHERE id = $2`, [activo ? 1 : 0, id]); +} + +export async function updateNodeLegalName(id: number, nombre: string): Promise { + await pgPool.query(`UPDATE ${qNodes()} SET legal_name = $1 WHERE id = $2`, [nombre, id]); +} + +export async function insertDatabaseNode(row: { + nodoSubNodo: string; + rfc: string; + nombre: string; + sucursal: string; + correo: string; + serverName: string; + bdName: string; + activo: number; + restoreTargetId?: number | null; + anexo24CAvisoFecha?: string | null; +}): Promise { + // server_name y sql_password se derivan del servidor de restauración asignado (su IP y su + // credencial SQL cifrada); si el target aún no tiene IP, se conserva el serverName recibido. + await pgPool.query( + ` + INSERT INTO ${qNodes()} ( + node_subnode_key, rfc, legal_name, branch_name, + notification_email, server_name, database_name, is_active, restore_target_id, + sql_password, anexo24c_aviso_fecha + ) VALUES ( + $1, $2, $3, $4, $5, + COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6), + $7, $8, $9, + (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9), + $10 + ) + `, + [ + row.nodoSubNodo, + row.rfc, + row.nombre, + row.sucursal, + row.correo, + row.serverName, + row.bdName, + row.activo, + row.restoreTargetId ?? null, + row.anexo24CAvisoFecha ?? null + ] + ); +} + +export async function updateDatabaseNode( + id: number, + row: { + nodoSubNodo: string; + rfc: string; + nombre: string; + sucursal: string; + correo: string; + serverName: string; + bdName: string; + activo: number; + restoreTargetId?: number | null; + anexo24CAvisoFecha?: string | null; + } +): Promise { + await pgPool.query( + ` + UPDATE ${qNodes()} SET + node_subnode_key = $1, + rfc = $2, + legal_name = $3, + branch_name = $4, + notification_email = $5, + server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6), + database_name = $7, + is_active = $8, + restore_target_id = $9, + sql_password = (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9), + anexo24c_aviso_fecha = $10 + WHERE id = $11 + `, + [ + row.nodoSubNodo, + row.rfc, + row.nombre, + row.sucursal, + row.correo, + row.serverName, + row.bdName, + row.activo, + row.restoreTargetId ?? null, + row.anexo24CAvisoFecha ?? null, + id + ] + ); +} + +export async function deleteDatabaseNode(id: number): Promise { + await pgPool.query(`DELETE FROM ${qNodes()} WHERE id = $1`, [id]); +} + +export async function insertPortalUser(row: { + databaseNodeId: number; + isAuthorityClient: number; + fullName: string; + username: string; + passwordHash: string; + bdShelter: string | null; +}): Promise { + await pgPool.query( + ` + INSERT INTO ${qUsers()} ( + database_node_id, is_authority_client, full_name, username, password_hash, bd_shelter + ) VALUES ($1, $2, $3, $4, $5, $6) + `, + [ + row.databaseNodeId, + row.isAuthorityClient, + row.fullName, + row.username, + row.passwordHash, + row.bdShelter + ] + ); +} + +export async function updatePortalUser( + id: number, + row: { + databaseNodeId: number; + isAuthorityClient: number; + fullName: string; + username: string; + bdShelter: string | null; + passwordHash?: string; + } +): Promise { + if (row.passwordHash !== undefined) { + await pgPool.query( + ` + UPDATE ${qUsers()} SET + database_node_id = $1, + is_authority_client = $2, + full_name = $3, + username = $4, + password_hash = $5, + bd_shelter = $6 + WHERE id = $7 + `, + [ + row.databaseNodeId, + row.isAuthorityClient, + row.fullName, + row.username, + row.passwordHash, + row.bdShelter, + id + ] + ); + } else { + await pgPool.query( + ` + UPDATE ${qUsers()} SET + database_node_id = $1, + is_authority_client = $2, + full_name = $3, + username = $4, + bd_shelter = $5 + WHERE id = $6 + `, + [row.databaseNodeId, row.isAuthorityClient, row.fullName, row.username, row.bdShelter, id] + ); + } +} + +export async function deletePortalUser(id: number): Promise { + await pgPool.query(`DELETE FROM ${qUsers()} WHERE id = $1`, [id]); +} + +// ============================================================================ +// Servidores de restauración (restore_targets) — integración CloudRestoreAS. +// La contraseña SQL se cifra con AES-256-GCM antes de persistir y solo se +// descifra al entregarla al servicio CloudRestoreAS por el endpoint con token. +// ============================================================================ + +/** Servidor de restauración sin las contraseñas (seguro para la UI). */ +export interface RestoreTarget { + id: number; + name: string; + server_ip: string; + sql_username: string; + data_folder: string; + ssh_host: string; + ssh_port: number; + ssh_username: string; + remote_inbox_path: string; + notes: string | null; + // Características de hardware opcionales (NULL = sin capturar). + os: string | null; + ram_gb: number | null; + disk_gb: number | null; + location: string | null; + size_category: string | null; +} + +/** Datos de alta/edición. Las contraseñas en texto plano; se cifran aquí. */ +export interface RestoreTargetInput { + name: string; + server_ip: string; + sql_username: string; + sql_password?: string; // opcional en edición: si se omite, no se cambia + data_folder: string; + ssh_host: string; + ssh_port: number; + ssh_username: string; + ssh_password?: string; // opcional en edición: si se omite, no se cambia + remote_inbox_path: string; + notes?: string | null; + // Características de hardware opcionales. + os?: string | null; + ram_gb?: number | null; + disk_gb?: number | null; + location?: string | null; + size_category?: string | null; +} + +const ROW_RESTORE_TARGET = ` + id, name, server_ip, sql_username, data_folder, + ssh_host, ssh_port, ssh_username, remote_inbox_path, notes, + os, ram_gb, disk_gb, location, size_category +`; + +async function queryRestoreTargets(): Promise { + const r = await pgPool.query( + `SELECT ${ROW_RESTORE_TARGET} FROM ${qRestoreTargets()} ORDER BY name` + ); + return r.rows as RestoreTarget[]; +} + +/** Lista los servidores de restauración sin exponer la contraseña (alimenta los radios). */ +export async function listRestoreTargets(): Promise { + try { + return await queryRestoreTargets(); + } catch (e) { + if (!isPgUndefinedTable(e) && !isPgUndefinedColumn(e)) throw e; + await ensureRestoreTargetsSchema(); + return await queryRestoreTargets(); + } +} + +export async function getRestoreTargetById(id: number): Promise { + const r = await pgPool.query( + `SELECT ${ROW_RESTORE_TARGET} FROM ${qRestoreTargets()} WHERE id = $1`, + [id] + ); + return (r.rows[0] as RestoreTarget) ?? null; +} + +/** + * Servidor de restauración por id con la contraseña SQL descifrada. Uso exclusivo del servidor + * (conectar a SQL Server para depurar bases duplicadas); NUNCA se expone al cliente. + */ +export async function getRestoreTargetWithPasswordById( + id: number +): Promise<(RestoreTarget & { sql_password: string }) | null> { + const r = await pgPool.query( + `SELECT ${ROW_RESTORE_TARGET}, sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $1`, + [id] + ); + const row = r.rows[0]; + if (!row) return null; + const { sql_password_encrypted, ...rest } = row; + const sql_password = sql_password_encrypted ? decryptSecret(sql_password_encrypted) : ''; + return { ...(rest as RestoreTarget), sql_password }; +} + +/** + * Servidor de restauración por id con AMBAS contraseñas descifradas (SQL para BACKUP/RESTORE, SSH + * para SFTP). Uso exclusivo del servidor (mover bases duplicadas); NUNCA se expone al cliente. + */ +export async function getRestoreTargetWithSecretsById( + id: number +): Promise<(RestoreTarget & { sql_password: string; ssh_password: string }) | null> { + const r = await pgPool.query( + `SELECT ${ROW_RESTORE_TARGET}, sql_password_encrypted, ssh_password_encrypted + FROM ${qRestoreTargets()} WHERE id = $1`, + [id] + ); + const row = r.rows[0]; + if (!row) return null; + const { sql_password_encrypted, ssh_password_encrypted, ...rest } = row; + return { + ...(rest as RestoreTarget), + sql_password: sql_password_encrypted ? decryptSecret(sql_password_encrypted) : '', + ssh_password: ssh_password_encrypted ? decryptSecret(ssh_password_encrypted) : '' + }; +} + +/** + * Devuelve el servidor de restauración ASIGNADO a una base de datos, con la contraseña + * descifrada. La base se identifica por database_name o node_subnode_key (lo que CloudRestoreAS + * resuelve del nombre de archivo). Uso exclusivo del endpoint servicio-a-servicio. + * Retorna null si la base no existe o no tiene servidor asignado. + */ +export async function getRestoreTargetForDatabase( + dbNameOrNodo: string, + instanceName?: string | null +): Promise<(RestoreTarget & { sql_password: string; ssh_password: string }) | null> { + const instanceFilter = (instanceName ?? '').trim(); + const r = await pgPool.query( + `SELECT rt.id, rt.name, rt.server_ip, rt.sql_username, rt.data_folder, + rt.ssh_host, rt.ssh_port, rt.ssh_username, rt.remote_inbox_path, rt.notes, + rt.sql_password_encrypted, rt.ssh_password_encrypted + FROM ${qNodes()} dn + JOIN ${qRestoreTargets()} rt ON rt.id = dn.restore_target_id + WHERE (LOWER(TRIM(dn.database_name)) = LOWER(TRIM($1::text)) + OR LOWER(TRIM(dn.node_subnode_key)) = LOWER(TRIM($1::text))) + AND ( + $2::text = '' + OR LOWER(TRIM(rt.name)) = LOWER(TRIM($2::text)) + ) + LIMIT 1`, + [dbNameOrNodo, instanceFilter] + ); + const row = r.rows[0]; + if (!row) return null; + const { sql_password_encrypted, ssh_password_encrypted, ...rest } = row; + // Si el servidor aún no tiene contraseñas configuradas se devuelven vacías: + // el cliente (panel_client) las rechaza por campo requerido y difiere el job. + const sql_password = sql_password_encrypted ? decryptSecret(sql_password_encrypted) : ''; + const ssh_password = ssh_password_encrypted ? decryptSecret(ssh_password_encrypted) : ''; + return { ...(rest as RestoreTarget), sql_password, ssh_password }; +} + +export type RouteAction = 'restore_local' | 'forward'; + +export type RestoreTargetWithRoute = RestoreTarget & { + sql_password: string; + ssh_password: string; + input_folder: string | null; +}; + +/** + * Servidor asignado a una base, con contraseñas descifradas y carpeta de entrada + * reportada por CloudRestoreAS (cloudrestore_status), si existe. + */ +export async function getRestoreTargetWithInputFolderForDatabase( + dbNameOrNodo: string +): Promise { + const r = await pgPool.query( + `SELECT rt.id, rt.name, rt.server_ip, rt.sql_username, rt.data_folder, + rt.ssh_host, rt.ssh_port, rt.ssh_username, rt.remote_inbox_path, rt.notes, + rt.sql_password_encrypted, rt.ssh_password_encrypted, + cs.input_folder + FROM ${qNodes()} dn + JOIN ${qRestoreTargets()} rt ON rt.id = dn.restore_target_id + LEFT JOIN ${qCloudRestoreStatus()} cs + ON LOWER(TRIM(cs.instance_key)) = LOWER(TRIM(rt.name)) + WHERE (LOWER(TRIM(dn.database_name)) = LOWER(TRIM($1::text)) + OR LOWER(TRIM(dn.node_subnode_key)) = LOWER(TRIM($1::text))) + LIMIT 1`, + [dbNameOrNodo] + ); + const row = r.rows[0]; + if (!row) return null; + const { sql_password_encrypted, ssh_password_encrypted, input_folder, ...rest } = row; + const sql_password = sql_password_encrypted ? decryptSecret(sql_password_encrypted) : ''; + const ssh_password = ssh_password_encrypted ? decryptSecret(ssh_password_encrypted) : ''; + const folder = + input_folder && String(input_folder).trim() ? String(input_folder).trim() : null; + return { ...(rest as RestoreTarget), sql_password, ssh_password, input_folder: folder }; +} + +export interface ResolveRouteResult { + action: RouteAction; + db_name: string; + node_key: string; + target: RestoreTargetWithRoute; +} + +/** + * Resuelve nodo/destino desde el nombre del archivo ZIP y determina si el CRA debe + * restaurar localmente o reenviar el ZIP por SFTP. + */ +export async function resolveRouteForFilename( + filename: string, + instanceName?: string | null +): Promise { + const stem = path.parse(filename).name; + const nodes = await listDatabaseNodesActive(); + const matched = matchNodeRowFromBackupStem(stem, nodes); + if (!matched) return null; + + const dbName = String(matched.BDName ?? matched.NodoSubNodo ?? '').trim(); + if (!dbName) return null; + + const target = await getRestoreTargetWithInputFolderForDatabase(dbName); + if (!target) return null; + + const instance = (instanceName ?? '').trim(); + let action: RouteAction; + if (!instance) { + action = 'forward'; + } else if (target.name.trim().toLowerCase() === instance.toLowerCase()) { + action = 'restore_local'; + } else { + action = 'forward'; + } + + return { + action, + db_name: dbName, + node_key: String(matched.NodoSubNodo ?? stem).trim(), + target + }; +} + +export async function createRestoreTarget(input: RestoreTargetInput): Promise { + if (!input.sql_password) { + throw new Error('La contraseña SQL es obligatoria al crear un servidor.'); + } + const cols = [ + 'name', 'server_ip', 'sql_username', 'data_folder', + 'ssh_host', 'ssh_port', 'ssh_username', 'remote_inbox_path', 'notes', + 'os', 'ram_gb', 'disk_gb', 'location', 'size_category', + 'sql_password_encrypted' + ]; + const params: unknown[] = [ + input.name, input.server_ip, input.sql_username, input.data_folder, + input.ssh_host, input.ssh_port, input.ssh_username, input.remote_inbox_path, + input.notes ?? null, + input.os ?? null, input.ram_gb ?? null, input.disk_gb ?? null, + input.location ?? null, input.size_category ?? null, + encryptSecret(input.sql_password) + ]; + if (input.ssh_password) { + cols.push('ssh_password_encrypted'); + params.push(encryptSecret(input.ssh_password)); + } + const placeholders = params.map((_, i) => `$${i + 1}`).join(', '); + const r = await pgPool.query( + `INSERT INTO ${qRestoreTargets()} (${cols.join(', ')}) VALUES (${placeholders}) RETURNING id`, + params + ); + return r.rows[0].id as number; +} + +/** Actualiza un servidor. Cada contraseña (SQL/SSH) solo se reescribe si viene definida. */ +export async function updateRestoreTarget(id: number, input: RestoreTargetInput): Promise { + const sets = [ + 'name = $1', 'server_ip = $2', 'sql_username = $3', 'data_folder = $4', + 'ssh_host = $5', 'ssh_port = $6', 'ssh_username = $7', 'remote_inbox_path = $8', + 'notes = $9', 'os = $10', 'ram_gb = $11', 'disk_gb = $12', 'location = $13', + 'size_category = $14', 'updated_at = now()' + ]; + const params: unknown[] = [ + input.name, input.server_ip, input.sql_username, input.data_folder, + input.ssh_host, input.ssh_port, input.ssh_username, input.remote_inbox_path, + input.notes ?? null, + input.os ?? null, input.ram_gb ?? null, input.disk_gb ?? null, + input.location ?? null, input.size_category ?? null + ]; + let p = params.length; + if (input.sql_password) { + params.push(encryptSecret(input.sql_password)); + sets.push(`sql_password_encrypted = $${++p}`); + } + if (input.ssh_password) { + params.push(encryptSecret(input.ssh_password)); + sets.push(`ssh_password_encrypted = $${++p}`); + } + params.push(id); + await pgPool.query( + `UPDATE ${qRestoreTargets()} SET ${sets.join(', ')} WHERE id = $${params.length}`, + params + ); +} + +export async function deleteRestoreTarget(id: number): Promise { + await pgPool.query(`DELETE FROM ${qRestoreTargets()} WHERE id = $1`, [id]); +} + +// ============================================================================ +// Asignación masiva de nodos a servidores de restauración. +// ============================================================================ + +/** Nodo con su asignación de restaurador, para el checklist/distribución (sin secretos). */ +export interface AssignmentNode { + ID: number; + NodoSubNodo: string; + Nombre: string; + BDName: string | null; + ServerName: string | null; + Activo: number; + RestoreTargetId: number | null; +} + +/** Todos los nodos con su asignación actual (base del checklist y de la distribución). */ +export async function listNodesForAssignment(): Promise { + const r = await pgPool.query( + ` + SELECT + id AS "ID", + node_subnode_key AS "NodoSubNodo", + legal_name AS "Nombre", + database_name AS "BDName", + server_name AS "ServerName", + is_active AS "Activo", + restore_target_id AS "RestoreTargetId" + FROM ${qNodes()} + ORDER BY node_subnode_key + ` + ); + return r.rows as AssignmentNode[]; +} + +/** + * Guarda el checklist manual de un restaurador: los `checkedNodeIds` quedan asignados a + * `targetId` (reasignando desde donde estuvieran y derivando server_name y sql_password del + * target); los nodos que estaban en este restaurador y ya NO vienen marcados quedan sin asignar + * (restore_target_id y sql_password en NULL). Los nodos de OTROS restauradores no marcados no se + * tocan. Transacción con prepared statements. + */ +export async function assignNodesToRestoreTarget( + targetId: number, + checkedNodeIds: number[] +): Promise { + const ids = Array.from(new Set(checkedNodeIds.filter((n) => Number.isInteger(n) && n > 0))); + const client = await pgPool.connect(); + try { + await client.query('BEGIN'); + // 1) Asignar/reasignar los marcados (server_name = IP del target; sql_password = su credencial cifrada). + await client.query( + ` + UPDATE ${qNodes()} + SET restore_target_id = $1, + server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $1), server_name), + sql_password = (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $1) + WHERE id = ANY($2::int[]) + `, + [targetId, ids] + ); + // 2) Quitar de este restaurador los desmarcados (restore_target_id y sql_password a NULL); no toca otros targets. + await client.query( + ` + UPDATE ${qNodes()} + SET restore_target_id = NULL, + sql_password = NULL + WHERE restore_target_id = $1 + AND NOT (id = ANY($2::int[])) + `, + [targetId, ids] + ); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + +/** + * Aplica un conjunto de asignaciones (distribución global o automática): cada par fija el + * restore_target_id del nodo (y deriva server_name y sql_password del restaurador si no es NULL). + * Pares con targetId NULL dejan el nodo sin asignar (conservando server_name, sql_password a NULL). + * Transacción. + */ +export async function applyNodeAssignments( + pairs: { nodeId: number; targetId: number | null }[] +): Promise { + const clean = pairs.filter((p) => Number.isInteger(p.nodeId) && p.nodeId > 0); + if (clean.length === 0) return; + const client = await pgPool.connect(); + try { + await client.query('BEGIN'); + for (const { nodeId, targetId } of clean) { + await client.query( + ` + UPDATE ${qNodes()} + SET restore_target_id = $2, + server_name = CASE + WHEN $2::int IS NULL THEN server_name + ELSE COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $2), server_name) + END, + sql_password = CASE + WHEN $2::int IS NULL THEN NULL + ELSE (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $2) + END + WHERE id = $1 + `, + [nodeId, targetId] + ); + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + +// ============================================================================ +// Estado de CloudRestoreAS (carpeta de entrada reportada por el servicio). +// ============================================================================ + +export interface CloudRestoreStatus { + id: number; + instance_key: string; + input_folder: string; + processed_folder: string | null; + host_name: string | null; + app_version: string | null; + reported_at: Date; +} + +/** Estados reportados por cada instancia CloudRestoreAS (Alfa/Omega/Gamma). */ +export async function listCloudRestoreStatuses(): Promise { + try { + await ensureCloudRestoreStatusTable(); + const r = await pgPool.query( + ` + SELECT id, instance_key, input_folder, processed_folder, host_name, app_version, reported_at + FROM ${qCloudRestoreStatus()} + ORDER BY instance_key + ` + ); + return r.rows as CloudRestoreStatus[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +/** @deprecated Usar listCloudRestoreStatuses. Mantiene compatibilidad con instancia default. */ +export async function getCloudRestoreStatus(): Promise { + const rows = await listCloudRestoreStatuses(); + return rows.find((r) => r.instance_key === 'default') ?? rows[0] ?? null; +} + +/** UPSERT del estado reportado por CloudRestoreAS (solo vía API servicio). */ +export async function upsertCloudRestoreStatus(row: { + inputFolder: string; + processedFolder?: string | null; + hostName: string | null; + appVersion: string | null; + instanceKey?: string; +}): Promise { + await ensureCloudRestoreStatusTable(); + const key = row.instanceKey ?? 'default'; + await pgPool.query( + ` + INSERT INTO ${qCloudRestoreStatus()} ( + instance_key, input_folder, processed_folder, host_name, app_version, reported_at + ) VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (instance_key) DO UPDATE SET + input_folder = EXCLUDED.input_folder, + processed_folder = EXCLUDED.processed_folder, + host_name = EXCLUDED.host_name, + app_version = EXCLUDED.app_version, + reported_at = now() + `, + [key, row.inputFolder, row.processedFolder ?? null, row.hostName, row.appVersion] + ); +} + +/** Inserta un registro de bitácora reportado por CloudRestoreAS. */ +export async function insertRestoreJobLog(row: { + filename: string; + restoreTargetId: number | null; + dbName: string | null; + status: 'completed' | 'failed' | 'forwarded'; + durationMs: number | null; + errorMessage: string | null; + sizeBytes?: number | null; + relPath?: string | null; +}): Promise { + await ensureRestoreJobLogColumns(); + await pgPool.query( + ` + INSERT INTO ${qRestoreJobLogs()} ( + filename, restore_target_id, db_name, status, duration_ms, error_message, + size_bytes, rel_path + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, + [ + row.filename, + row.restoreTargetId, + row.dbName, + row.status, + row.durationMs, + row.errorMessage, + row.sizeBytes ?? null, + row.relPath ?? null + ] + ); +} + +/** Resumen de bitácora por servidor: conteos por status (últimos 30 días) y última exitosa. */ +export interface RestoreJobLogSummary { + restore_target_id: number; + completed: number; + failed: number; + forwarded: number; + last_completed_at: Date | null; +} + +export async function listRestoreJobLogSummaries(): Promise { + try { + const r = await pgPool.query( + ` + SELECT + restore_target_id, + COUNT(*) FILTER ( + WHERE status = 'completed' AND restored_at >= now() - INTERVAL '30 days' + )::int AS completed, + COUNT(*) FILTER ( + WHERE status = 'failed' AND restored_at >= now() - INTERVAL '30 days' + )::int AS failed, + COUNT(*) FILTER ( + WHERE status = 'forwarded' AND restored_at >= now() - INTERVAL '30 days' + )::int AS forwarded, + MAX(restored_at) FILTER (WHERE status = 'completed') AS last_completed_at + FROM ${qRestoreJobLogs()} + WHERE restore_target_id IS NOT NULL + GROUP BY restore_target_id + ` + ); + return r.rows as RestoreJobLogSummary[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +/** Últimas restauraciones de un servidor (para el modal de bitácora del panel). */ +export interface RestoreJobLogRow { + id: number; + filename: string; + db_name: string | null; + status: string; + duration_ms: number | null; + error_message: string | null; + restored_at: Date; +} + +export async function listRecentRestoreJobLogs( + targetId: number, + limit = 20 +): Promise { + const capped = Math.min(Math.max(1, Math.trunc(limit)), 100); + try { + const r = await pgPool.query( + ` + SELECT id, filename, db_name, status, duration_ms, error_message, restored_at + FROM ${qRestoreJobLogs()} + WHERE restore_target_id = $1 + ORDER BY restored_at DESC + LIMIT $2 + `, + [targetId, capped] + ); + return r.rows as RestoreJobLogRow[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +// ============================================================================ +// Inventario "último respaldo por nodo" (node_last_restore) y restores fallidos. +// ============================================================================ + +export interface NodeLastRestoreRow { + database_node_id: number; + restore_target_id: number | null; + server_name: string | null; // restaurador (Alfa/Omega/Gamma) donde vive el archivo + node_key: string | null; // NodoSubNodo + client_name: string | null; // legal_name + db_name: string | null; + filename: string; + rel_path: string | null; + size_bytes: number | null; + restored_at: Date; +} + +/** Último respaldo restaurado por nodo. Sobrevive a la reasignación del restaurador. */ +export async function listNodeLastRestore(): Promise { + try { + await ensureNodeLastRestoreTable(); + const r = await pgPool.query( + ` + SELECT + nlr.database_node_id, + nlr.restore_target_id, + rt.name AS server_name, + COALESCE(nlr.node_key, dn.node_subnode_key) AS node_key, + dn.legal_name AS client_name, + nlr.db_name, + nlr.filename, + nlr.rel_path, + nlr.size_bytes, + nlr.restored_at + FROM ${qNodeLastRestore()} nlr + LEFT JOIN ${qRestoreTargets()} rt ON rt.id = nlr.restore_target_id + LEFT JOIN ${qNodes()} dn ON dn.id = nlr.database_node_id + ORDER BY nlr.restored_at DESC + ` + ); + return r.rows as NodeLastRestoreRow[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +/** + * UPSERT del último respaldo de un nodo. Solo actualiza si el nuevo `restored_at` es igual o + * más reciente, para no retroceder el tracking. Llaveada por nodo → persiste ante reasignación. + */ +export async function upsertNodeLastRestore(row: { + databaseNodeId: number; + restoreTargetId: number | null; + dbName: string | null; + nodeKey: string | null; + filename: string; + relPath: string | null; + sizeBytes: number | null; + restoredAt?: Date | null; +}): Promise { + await ensureNodeLastRestoreTable(); + await pgPool.query( + ` + INSERT INTO ${qNodeLastRestore()} AS nlr ( + database_node_id, restore_target_id, db_name, node_key, + filename, rel_path, size_bytes, restored_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, now()), now()) + ON CONFLICT (database_node_id) DO UPDATE SET + restore_target_id = EXCLUDED.restore_target_id, + db_name = EXCLUDED.db_name, + node_key = EXCLUDED.node_key, + filename = EXCLUDED.filename, + rel_path = EXCLUDED.rel_path, + size_bytes = EXCLUDED.size_bytes, + restored_at = EXCLUDED.restored_at, + updated_at = now() + WHERE EXCLUDED.restored_at >= nlr.restored_at + `, + [ + row.databaseNodeId, + row.restoreTargetId, + row.dbName, + row.nodeKey, + row.filename, + row.relPath, + row.sizeBytes, + row.restoredAt ?? null + ] + ); +} + +/** + * Resuelve el database_node_id de un respaldo por db_name o por el nombre de archivo. + * Reutiliza matchNodeRowFromBackupStem para el fallback por stem del filename. + */ +export async function findNodeIdForBackup( + filename: string, + dbName: string | null +): Promise<{ id: number; nodeKey: string | null } | null> { + if (dbName) { + const r = await pgPool.query( + `SELECT id, node_subnode_key FROM ${qNodes()} + WHERE LOWER(TRIM(database_name)) = LOWER(TRIM($1)) + OR LOWER(TRIM(node_subnode_key)) = LOWER(TRIM($1)) + LIMIT 1`, + [dbName] + ); + if (r.rows.length) { + return { id: Number(r.rows[0].id), nodeKey: r.rows[0].node_subnode_key ?? null }; + } + } + const stem = path.parse(filename).name; + const nodes = await pgPool.query( + `SELECT id AS "ID", node_subnode_key AS "NodoSubNodo", database_name AS "BDName" + FROM ${qNodes()}` + ); + const match = matchNodeRowFromBackupStem(stem, nodes.rows); + if (match && match.ID != null) { + return { id: Number(match.ID), nodeKey: (match.NodoSubNodo as string) ?? null }; + } + return null; +} + +/** Restores fallidos (todos los servidores) para el panel de fallidos. */ +export interface FailedRestoreRow { + id: number; + restore_target_id: number | null; + server_name: string | null; + filename: string; + db_name: string | null; + error_message: string | null; + rel_path: string | null; + size_bytes: number | null; + restored_at: Date; +} + +export async function listFailedRestoreJobLogs(limit = 100): Promise { + const capped = Math.min(Math.max(1, Math.trunc(limit)), 500); + try { + await ensureRestoreJobLogColumns(); + const r = await pgPool.query( + ` + SELECT + jl.id, jl.restore_target_id, rt.name AS server_name, + jl.filename, jl.db_name, jl.error_message, jl.rel_path, jl.size_bytes, jl.restored_at + FROM ${qRestoreJobLogs()} jl + LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id + WHERE jl.status = 'failed' + ORDER BY jl.restored_at DESC + LIMIT $1 + `, + [capped] + ); + return r.rows as FailedRestoreRow[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +/** Restores completados/reenviados (todos los servidores) para el panel de restaurados. */ +export interface RestoredRestoreRow { + id: number; + restore_target_id: number | null; + server_name: string | null; + node_key: string | null; + client_name: string | null; + db_name: string | null; + filename: string; + rel_path: string | null; + size_bytes: number | null; + restored_at: Date; +} + +/** + * Restores exitosos (`completed`/`forwarded`) desde restore_job_logs — lo que reporta + * CloudRestoreAS vía POST /api/restore/job-result. Resuelve node_key/client_name por nombre + * de base contra database_nodes (LATERAL … LIMIT 1 para no duplicar la fila del log si dos + * nodos comparten database_name). Cuando no hay match, ambos quedan null y la UI cae a db_name. + */ +export async function listRestoredRestoreJobLogs(limit = 200): Promise { + const capped = Math.min(Math.max(1, Math.trunc(limit)), 500); + try { + await ensureRestoreJobLogColumns(); + const r = await pgPool.query( + ` + SELECT + jl.id, jl.restore_target_id, rt.name AS server_name, + dn.node_subnode_key AS node_key, dn.legal_name AS client_name, + jl.filename, jl.db_name, jl.rel_path, jl.size_bytes, jl.restored_at + FROM ${qRestoreJobLogs()} jl + LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id + LEFT JOIN LATERAL ( + SELECT n.node_subnode_key, n.legal_name + FROM ${qNodes()} n + WHERE LOWER(TRIM(n.database_name)) = LOWER(TRIM(jl.db_name)) + LIMIT 1 + ) dn ON true + WHERE jl.status IN ('completed', 'forwarded') + ORDER BY jl.restored_at DESC + LIMIT $1 + `, + [capped] + ); + return r.rows as RestoredRestoreRow[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +/** Carpetas de un restaurador para descarga por filesystem (input/processed reportados). */ +export interface RestoreTargetDownload { + id: number; + name: string; + input_folder: string | null; + processed_folder: string | null; +} + +export async function getRestoreTargetForDownload( + targetId: number +): Promise { + await ensureRestoreTargetsSchema(); + await ensureCloudRestoreStatusTable(); + const r = await pgPool.query( + ` + SELECT rt.id, rt.name, cs.input_folder, cs.processed_folder + FROM ${qRestoreTargets()} rt + LEFT JOIN ${qCloudRestoreStatus()} cs + ON LOWER(TRIM(cs.instance_key)) = LOWER(TRIM(rt.name)) + WHERE rt.id = $1 + `, + [targetId] + ); + if (!r.rows.length) return null; + const row = r.rows[0]; + return { + id: Number(row.id), + name: row.name, + input_folder: row.input_folder ?? null, + processed_folder: row.processed_folder ?? null + }; +} diff --git a/src/lib/server/crypto-core.test.ts b/src/lib/server/crypto-core.test.ts new file mode 100644 index 0000000..387a8bc --- /dev/null +++ b/src/lib/server/crypto-core.test.ts @@ -0,0 +1,87 @@ +/** + * Pruebas del cifrado AES-256-GCM puro (crypto-core). + * Cubre: round-trip, detección de manipulación (GCM), formato inválido, + * unicidad del IV y validación de clave/longitud. + */ +import { describe, it, expect } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { + encryptWithKey, + decryptWithKey, + isEncrypted, + decodeKey, + KEY_LENGTH, + VERSION_PREFIX +} from './crypto-core'; + +const KEY = randomBytes(KEY_LENGTH); + +describe('crypto-core AES-256-GCM', () => { + it('round-trip: descifrar devuelve el texto original', () => { + const plaintext = 'Soluciones01!'; + const envelope = encryptWithKey(plaintext, KEY); + expect(decryptWithKey(envelope, KEY)).toBe(plaintext); + }); + + it('produce sobres con prefijo de versión gcm:', () => { + const envelope = encryptWithKey('secreto', KEY); + expect(envelope.startsWith(`${VERSION_PREFIX}:`)).toBe(true); + expect(isEncrypted(envelope)).toBe(true); + expect(isEncrypted('texto-plano')).toBe(false); + expect(isEncrypted(null)).toBe(false); + }); + + it('usa IV aleatorio: dos cifrados del mismo texto difieren', () => { + const a = encryptWithKey('mismo', KEY); + const b = encryptWithKey('mismo', KEY); + expect(a).not.toBe(b); + // pero ambos descifran al mismo valor + expect(decryptWithKey(a, KEY)).toBe(decryptWithKey(b, KEY)); + }); + + it('detecta manipulación del ciphertext (auth tag GCM)', () => { + const envelope = encryptWithKey('integridad', KEY); + const parts = envelope.split(':'); + // Alterar un byte del ciphertext + const tampered = Buffer.from(parts[3], 'base64'); + tampered[0] = tampered[0] ^ 0xff; + parts[3] = tampered.toString('base64'); + expect(() => decryptWithKey(parts.join(':'), KEY)).toThrow(); + }); + + it('falla al descifrar con clave incorrecta', () => { + const envelope = encryptWithKey('secreto', KEY); + const otherKey = randomBytes(KEY_LENGTH); + expect(() => decryptWithKey(envelope, otherKey)).toThrow(); + }); + + it('rechaza formato de sobre inválido', () => { + expect(() => decryptWithKey('no-es-un-sobre', KEY)).toThrow(/Formato/); + expect(() => decryptWithKey('aes:1:2:3', KEY)).toThrow(/Formato/); + }); + + it('rechaza claves con longitud incorrecta', () => { + expect(() => encryptWithKey('x', randomBytes(16))).toThrow(/32 bytes/); + }); + + describe('decodeKey', () => { + it('decodifica clave base64 de 32 bytes', () => { + const b64 = randomBytes(KEY_LENGTH).toString('base64'); + expect(decodeKey(b64).length).toBe(KEY_LENGTH); + }); + + it('decodifica clave hex de 32 bytes', () => { + const hex = randomBytes(KEY_LENGTH).toString('hex'); + expect(decodeKey(hex).length).toBe(KEY_LENGTH); + }); + + it('lanza si la clave está ausente', () => { + expect(() => decodeKey(undefined)).toThrow(/ENCRYPTION_KEY/); + expect(() => decodeKey(' ')).toThrow(/ENCRYPTION_KEY/); + }); + + it('lanza si la clave no decodifica a 32 bytes', () => { + expect(() => decodeKey('demasiado-corta')).toThrow(/32 bytes/); + }); + }); +}); diff --git a/src/lib/server/crypto-core.ts b/src/lib/server/crypto-core.ts new file mode 100644 index 0000000..102b99c --- /dev/null +++ b/src/lib/server/crypto-core.ts @@ -0,0 +1,86 @@ +/** + * Lógica pura de cifrado AES-256-GCM (estándar Aduanasoft §4). Sin dependencias de + * SvelteKit ni de entorno, para ser testeable de forma aislada. La clave de 256 bits + * se inyecta como parámetro; quien la resuelve desde el entorno es `crypto.ts`. + * + * Formato del sobre: `gcm:::`. + */ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +export const ALGORITHM = 'aes-256-gcm'; +export const IV_LENGTH = 12; // 96 bits, recomendado para GCM +export const KEY_LENGTH = 32; // 256 bits +export const VERSION_PREFIX = 'gcm'; + +/** Cifra `plaintext` con la clave dada y devuelve el sobre versionado. */ +export function encryptWithKey(plaintext: string, key: Buffer): string { + if (key.length !== KEY_LENGTH) { + throw new Error(`La clave debe ser de ${KEY_LENGTH} bytes; se recibieron ${key.length}.`); + } + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return [ + VERSION_PREFIX, + iv.toString('base64'), + authTag.toString('base64'), + ciphertext.toString('base64') + ].join(':'); +} + +/** + * Descifra un sobre producido por `encryptWithKey`. Lanza si el formato es inválido + * o si la autenticación GCM falla (dato manipulado o clave incorrecta). + */ +export function decryptWithKey(payload: string, key: Buffer): string { + if (key.length !== KEY_LENGTH) { + throw new Error(`La clave debe ser de ${KEY_LENGTH} bytes; se recibieron ${key.length}.`); + } + const parts = String(payload ?? '').split(':'); + if (parts.length !== 4 || parts[0] !== VERSION_PREFIX) { + throw new Error('Formato de texto cifrado inválido (se esperaba gcm:iv:tag:ciphertext).'); + } + + const iv = Buffer.from(parts[1], 'base64'); + const authTag = Buffer.from(parts[2], 'base64'); + const ciphertext = Buffer.from(parts[3], 'base64'); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); +} + +/** Indica si un valor ya está cifrado con este esquema (para detectar datos legados en plano). */ +export function isEncrypted(value: string | null | undefined): boolean { + return typeof value === 'string' && value.startsWith(`${VERSION_PREFIX}:`); +} + +/** + * Decodifica una clave de 32 bytes desde una cadena base64 o hex. + * Lanza con mensaje claro si no decodifica a la longitud esperada. + */ +export function decodeKey(raw: string | undefined): Buffer { + if (!raw || !raw.trim()) { + throw new Error( + 'ENCRYPTION_KEY no está configurada. Genera una con: ' + + 'node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"' + ); + } + const trimmed = raw.trim(); + + let key = Buffer.from(trimmed, 'base64'); + if (key.length !== KEY_LENGTH) { + key = Buffer.from(trimmed, 'hex'); + } + if (key.length !== KEY_LENGTH) { + throw new Error( + `ENCRYPTION_KEY debe decodificar a ${KEY_LENGTH} bytes (256 bits) en base64 o hex; ` + + `se obtuvieron ${key.length} bytes.` + ); + } + return key; +} diff --git a/src/lib/server/crypto.ts b/src/lib/server/crypto.ts new file mode 100644 index 0000000..d451944 --- /dev/null +++ b/src/lib/server/crypto.ts @@ -0,0 +1,39 @@ +/** + * Cifrado simétrico de secretos en reposo para `restore_targets.sql_password` y su copia + * en `database_nodes.sql_password`. + * + * IMPORTANTE — interoperabilidad con a24c: + * a24c (Python/FastAPI) lee `database_nodes.sql_password` y lo descifra con + * `cryptography.fernet.Fernet`, usando la clave `sha256(SECRET_KEY)`. Por eso el panel + * cifra en **Fernet** con la MISMA `SECRET_KEY`, para que ambos proyectos se entiendan. + * + * - Escritura: siempre Fernet (`fernet.ts`), clave derivada de `SECRET_KEY`. + * - Lectura: Fernet y, por compatibilidad, el formato legado AES-256-GCM (`gcm:...`, + * `crypto-core.ts`) que el panel usaba antes con `ENCRYPTION_KEY`. + */ +import { env } from '$env/dynamic/private'; +import { decryptWithKey, decodeKey, isEncrypted as isGcmEnvelope } from './crypto-core'; +import { deriveFernetKey, fernetEncrypt, fernetDecrypt, isFernetToken } from './fernet'; + +function getFernetKey(): Buffer { + return deriveFernetKey(env.SECRET_KEY ?? ''); +} + +/** Cifra un secreto en texto plano como token Fernet, legible por a24c. */ +export function encryptSecret(plaintext: string): string { + return fernetEncrypt(plaintext, getFernetKey(), Date.now() / 1000); +} + +/** Descifra un secreto: Fernet (actual) o AES-256-GCM `gcm:` (legado del panel). */ +export function decryptSecret(payload: string): string { + if (isGcmEnvelope(payload)) { + // Datos históricos cifrados por el panel antes de migrar a Fernet. + return decryptWithKey(payload, decodeKey(env.ENCRYPTION_KEY)); + } + return fernetDecrypt(payload, getFernetKey()); +} + +/** Indica si un valor ya está cifrado (Fernet o el formato legado `gcm:`). */ +export function isEncrypted(value: string | null | undefined): boolean { + return isGcmEnvelope(value) || isFernetToken(value); +} diff --git a/src/lib/server/dashboard-pg.ts b/src/lib/server/dashboard-pg.ts new file mode 100644 index 0000000..f0e328c --- /dev/null +++ b/src/lib/server/dashboard-pg.ts @@ -0,0 +1,21 @@ +/** + * Tablas del panel Transmitiras en PostgreSQL (esquema a24c), alineadas con + * ~/dev/a24c/backend/api/v1/modules/dashboard (inglés). + */ +import { env } from '$env/dynamic/private'; + +export function dashboardSchema(): string { + const s = env.DB_CONTROLDESK_SCHEMA || 'a24c'; + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) return 'a24c'; + return s; +} + +function qTable(table: string): string { + const s = dashboardSchema().replace(/"/g, '""'); + const t = table.replace(/"/g, '""'); + return `"${s}"."${t}"`; +} + +export const tableDashboardUsers = () => qTable('dashboard_users'); +export const tableDashboardUserDbPerms = () => qTable('dashboard_user_database_permissions'); +export const tableDashboardSessions = () => qTable('dashboard_sessions'); diff --git a/src/lib/server/db-move.ts b/src/lib/server/db-move.ts new file mode 100644 index 0000000..7e58a05 --- /dev/null +++ b/src/lib/server/db-move.ts @@ -0,0 +1,190 @@ +/** + * "Mandar al nuevo": mueve una base que quedó solo en el servidor viejo hacia su servidor nuevo + * REUSANDO CloudRestoreAS. Flujo: BACKUP en el viejo -> SFTP baja el .bak -> se comprime a .zip + * -> SFTP sube el .zip a la Entrada del nuevo (CRA lo restaura) -> verificación acotada -> si ya + * quedó bien en el nuevo, se borra del viejo (borrado automático al confirmar). + * + * Si el restore de CRA tarda más que la ventana de verificación, la base queda 'en_transito': el + * siguiente escaneo la mostrará 🟢 y el borrado se completa con el botón de borrado existente. + */ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { env } from '$env/dynamic/private'; +import { + getMssqlPoolMaster, + resolveNodeSqlPassword, + queryDatabaseMetricsOnServer, + listUserDatabasesOnServer, + backupDatabaseOnServer, + dropDatabaseOnServer +} from './mssql-nodes'; +import { + getRestoreTargetWithSecretsById, + listDatabaseNodesForMssql +} from './controldesk-pg'; +import { ddlTimeoutMs, indexNodesByDbName, normalizeServerHost, sizeTolerance } from './dedup-databases'; +import { + joinRemotePath, + sftpDownload, + sftpUploadAtomic, + sftpDelete, + zipSingleFile, + type SftpCreds +} from './sftp-transfer'; +import { logger } from './logger'; + +function verifyTimeoutMs(): number { + const v = Number(env.PANEL_DEDUP_MOVE_VERIFY_TIMEOUT_MS); + return Number.isFinite(v) && v > 0 ? v : 180000; // 3 min por defecto +} +function verifyPollMs(): number { + const v = Number(env.PANEL_DEDUP_MOVE_POLL_MS); + return Number.isFinite(v) && v >= 1000 ? v : 5000; +} +/** Carpeta donde el SQL viejo escribe el .bak (default: data_folder del restore_target viejo). */ +function backupFolderFor(dataFolder: string): string { + const override = String(env.PANEL_DEDUP_BACKUP_FOLDER || '').trim(); + return override || dataFolder; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Nombre base del archivo (stem) que CRA usa para mapear el respaldo a su nodo. */ +export function backupStemForNode(node: any, fallbackDbName: string): string { + return String(node?.NodoSubNodo ?? '').trim() || String(fallbackDbName ?? '').trim(); +} + +function credsOf(t: { ssh_host: string; ssh_port: number; ssh_username: string; ssh_password: string }): SftpCreds { + return { host: t.ssh_host, port: t.ssh_port, username: t.ssh_username, password: t.ssh_password }; +} + +export type MoveStatus = 'movida_y_borrada' | 'en_transito' | 'ya_en_nuevo'; +export type MoveResult = { name: string; status: MoveStatus; message: string }; + +/** + * Mueve `dbName` del servidor viejo (`oldTargetId`) a su servidor nuevo asignado en el catálogo. + * Lanza si falta algún requisito (nodo, destino, credenciales) o si el backup/transferencia fallan. + */ +export async function moveDatabaseToNewServer( + oldTargetId: number, + dbName: string, + actor?: string +): Promise { + const oldTarget = await getRestoreTargetWithSecretsById(oldTargetId); + if (!oldTarget) throw new Error('Servidor viejo no encontrado.'); + if (!oldTarget.ssh_host || !oldTarget.ssh_username) { + throw new Error('El servidor viejo no tiene credenciales SSH configuradas.'); + } + + const oldPool = await getMssqlPoolMaster(oldTarget.server_ip, oldTarget.sql_password, oldTarget.sql_username); + // Pool aparte con timeout amplio para las operaciones largas (BACKUP y DROP) sobre el viejo. + const oldPoolDDL = await getMssqlPoolMaster( + oldTarget.server_ip, + oldTarget.sql_password, + oldTarget.sql_username, + ddlTimeoutMs() + ); + const oldDbs = await listUserDatabasesOnServer(oldPool); + const oldInfo = oldDbs.find((d) => d.name.toLowerCase() === dbName.trim().toLowerCase()); + if (!oldInfo) throw new Error(`La base "${dbName}" no existe en el servidor viejo.`); + const realName = oldInfo.name; + const allowedNames = new Set(oldDbs.map((d) => d.name)); + + const node = indexNodesByDbName(await listDatabaseNodesForMssql()).get(realName.toLowerCase()); + if (!node) throw new Error(`La base "${realName}" no tiene un nodo activo en el catálogo; no se puede enrutar.`); + + const newTargetId = Number(node.RestoreTargetId); + if (!Number.isInteger(newTargetId) || newTargetId <= 0) { + throw new Error('El nodo no tiene servidor de restauración asignado.'); + } + const newTarget = await getRestoreTargetWithSecretsById(newTargetId); + if (!newTarget) throw new Error('Servidor nuevo (destino) no encontrado.'); + if (!newTarget.ssh_host || !newTarget.ssh_username) { + throw new Error('El servidor nuevo no tiene credenciales SSH configuradas.'); + } + if (!newTarget.remote_inbox_path) { + throw new Error('El servidor nuevo no tiene carpeta de Entrada (remote_inbox_path) configurada.'); + } + if (normalizeServerHost(oldTarget.server_ip) === normalizeServerHost(newTarget.server_ip)) { + throw new Error('El destino nuevo es el mismo servidor viejo; no hay nada que mover.'); + } + + const tolerance = sizeTolerance(); + const newServer = String(node.ServerName || '').trim(); + const newPool = await getMssqlPoolMaster(newServer, resolveNodeSqlPassword(node.sql_password)); + + // Si ya existe en el nuevo, no re-enviamos (evita trabajo y sobrescrituras). + const already = await queryDatabaseMetricsOnServer(newPool, realName); + if (already) { + return { name: realName, status: 'ya_en_nuevo', message: 'La base ya existe en el servidor nuevo.' }; + } + + const stem = backupStemForNode(node, realName); + const bakName = `${stem}.bak`; + const zipName = `${stem}.zip`; + const bakRemoteOld = joinRemotePath(backupFolderFor(oldTarget.data_folder), bakName); + const zipRemoteNew = joinRemotePath(newTarget.remote_inbox_path, zipName); + + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dedup-move-')); + const localBak = path.join(workDir, bakName); + const localZip = path.join(workDir, zipName); + + try { + logger.info({ + message: 'dedup-move: iniciando', + context: { db: realName, from: oldTarget.server_ip, to: newTarget.server_ip, actor: actor ?? null } + }); + + await backupDatabaseOnServer(oldPoolDDL, realName, allowedNames, bakRemoteOld); + await sftpDownload(credsOf(oldTarget), bakRemoteOld, localBak); + await zipSingleFile(localBak, bakName, localZip); + await sftpUploadAtomic(credsOf(newTarget), localZip, zipRemoteNew); + + // Limpieza del .bak temporal en el viejo (best-effort, no aborta el flujo). + try { + await sftpDelete(credsOf(oldTarget), bakRemoteOld); + } catch (e) { + logger.warn({ + message: 'dedup-move: no se pudo borrar el .bak temporal del viejo', + context: { db: realName, path: bakRemoteOld, error: e instanceof Error ? e.message : String(e) } + }); + } + } finally { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}); + } + + // Verificación acotada: esperar a que CRA restaure en el nuevo con tamaño coherente. + const deadline = Date.now() + verifyTimeoutMs(); + const threshold = oldInfo.size_mb * (1 - tolerance); + let confirmed = false; + while (Date.now() < deadline) { + await sleep(verifyPollMs()); + const info = await queryDatabaseMetricsOnServer(newPool, realName); + if (info && (Number(info.size_mb) || 0) >= threshold) { + confirmed = true; + break; + } + } + + if (!confirmed) { + logger.info({ + message: 'dedup-move: en tránsito (CRA aún no confirma)', + context: { db: realName, to: newTarget.server_ip } + }); + return { + name: realName, + status: 'en_transito', + message: 'Enviada al servidor nuevo. CloudRestoreAS la está restaurando; se borrará del viejo al confirmar (re-escanea).' + }; + } + + await dropDatabaseOnServer(oldPoolDDL, realName, allowedNames); + logger.info({ + message: 'dedup-move: movida y borrada del viejo', + context: { db: realName, from: oldTarget.server_ip, to: newTarget.server_ip, actor: actor ?? null } + }); + return { name: realName, status: 'movida_y_borrada', message: 'Movida al servidor nuevo y borrada del viejo.' }; +} diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index bbed80b..5836837 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -1,8 +1,12 @@ -import sql from 'mssql'; import pkg from 'pg'; -const { Pool } = pkg; +const { Pool, types } = pkg; import { env } from '$env/dynamic/private'; +// DATE (OID 1082): devolver el string 'YYYY-MM-DD' crudo. Por default pg lo parsea +// como Date en hora LOCAL del proceso, y un uso posterior de .toISOString() puede +// desfasar el dia segun el timezone del servidor (afecta a24c.database_nodes.anexo24c_aviso_fecha). +types.setTypeParser(1082, (val: string) => val); + // Pool de PostgreSQL para usuarios y permisos const pgPool = new Pool({ host: env.DB_POSTGRES_HOST || '10.0.20.152', @@ -15,66 +19,11 @@ const pgPool = new Pool({ connectionTimeoutMillis: 5000 }); -const primaryConfig: sql.config = { - user: env.DB_PRIMARY_USER, - password: env.DB_PRIMARY_PASS, - server: env.DB_PRIMARY_HOST, - database: env.DB_PRIMARY_DB, - options: { - encrypt: true, - trustServerCertificate: true - } -}; - -const secondaryConfig: sql.config = { - user: env.DB_SECONDARY_USER, - password: env.DB_SECONDARY_PASS, - server: env.DB_SECONDARY_HOST, - database: env.DB_SECONDARY_DB, - options: { - encrypt: true, - trustServerCertificate: true - } -}; - -const azureConfig: sql.config = { - user: env.DB_AZURE_USER, - password: env.DB_AZURE_PASS, - server: env.DB_AZURE_HOST, - database: env.DB_AZURE_DB, - options: { - encrypt: true, - trustServerCertificate: true - } -}; - class Database { - private primaryPool: sql.ConnectionPool | null = null; - private secondaryPool: sql.ConnectionPool | null = null; - private azurePool: sql.ConnectionPool | null = null; - - async getPrimary(): Promise { - if (this.primaryPool?.connected) return this.primaryPool; - this.primaryPool = await new sql.ConnectionPool(primaryConfig).connect(); - return this.primaryPool; - } - - async getSecondary(): Promise { - if (this.secondaryPool?.connected) return this.secondaryPool; - this.secondaryPool = await new sql.ConnectionPool(secondaryConfig).connect(); - return this.secondaryPool; - } - - async getAzure(): Promise { - if (this.azurePool?.connected) return this.azurePool; - this.azurePool = await new sql.ConnectionPool(azureConfig).connect(); - return this.azurePool; - } - async getPostgres() { return pgPool.connect(); } } export const db = new Database(); -export { sql, pgPool }; +export { pgPool }; diff --git a/src/lib/server/dedup-databases.test.ts b/src/lib/server/dedup-databases.test.ts new file mode 100644 index 0000000..7bd9658 --- /dev/null +++ b/src/lib/server/dedup-databases.test.ts @@ -0,0 +1,151 @@ +/** + * Pruebas de la lógica de depuración de bases duplicadas. Cubre la decisión pura de borrado + * (classifyDuplicate / isDeletable), la normalización de host para detectar "mismo servidor" y + * la validación por whitelist de dropDatabaseOnServer (defensa contra inyección/borrado indebido). + */ +import { describe, it, expect, vi } from 'vitest'; +import { + classifyDuplicate, + isDeletable, + normalizeServerHost, + resolveCatalogState, + DEFAULT_SIZE_TOLERANCE, + type DuplicateInput +} from './dedup-databases'; +import { backupStemForNode } from './db-move'; +import { dropDatabaseOnServer } from './mssql-nodes'; + +const base: DuplicateInput = { + catalogState: 'active', + sameServer: false, + newVerified: true, + newExists: true, + oldSizeMb: 1000, + newSizeMb: 1000 +}; + +describe('classifyDuplicate', () => { + const tol = DEFAULT_SIZE_TOLERANCE; // 0.2 -> el nuevo debe pesar >= 800 MB + + it('sin nodo en el catálogo no se sabe el destino -> no_catalogo', () => { + expect(classifyDuplicate({ ...base, catalogState: 'absent' }, tol)).toBe('no_catalogo'); + }); + + it('el nodo existe pero está desactivado -> nodo_desactivado', () => { + expect(classifyDuplicate({ ...base, catalogState: 'inactive' }, tol)).toBe('nodo_desactivado'); + }); + + it('nodo desactivado tiene precedencia aunque el nuevo no cuadre', () => { + expect( + classifyDuplicate({ ...base, catalogState: 'inactive', newExists: false, sameServer: true }, tol) + ).toBe('nodo_desactivado'); + }); + + it('el destino nuevo es el mismo servidor -> mismo_servidor (nunca borrable)', () => { + expect(classifyDuplicate({ ...base, sameServer: true }, tol)).toBe('mismo_servidor'); + }); + + it('no se pudo verificar el servidor nuevo -> error_nuevo', () => { + expect(classifyDuplicate({ ...base, newVerified: false }, tol)).toBe('error_nuevo'); + }); + + it('verificado pero la base no existe en el nuevo -> falta_en_nuevo', () => { + expect(classifyDuplicate({ ...base, newExists: false }, tol)).toBe('falta_en_nuevo'); + }); + + it('existe en el nuevo con tamaño coherente -> segura', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 1000, newSizeMb: 900 }, tol)).toBe('segura'); + }); + + it('el tamaño en el límite (>= 80%) sigue siendo segura', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 1000, newSizeMb: 800 }, tol)).toBe('segura'); + }); + + it('el nuevo pesa mucho menos que el viejo -> revisar (posible copia incompleta)', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 1000, newSizeMb: 500 }, tol)).toBe('revisar'); + }); + + it('base vacía en el viejo (0 MB): cualquier tamaño en el nuevo es coherente -> segura', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 0, newSizeMb: 0 }, tol)).toBe('segura'); + }); +}); + +describe('isDeletable', () => { + it('solo "segura" es borrable', () => { + expect(isDeletable('segura')).toBe(true); + for (const s of [ + 'revisar', + 'falta_en_nuevo', + 'nodo_desactivado', + 'no_catalogo', + 'mismo_servidor', + 'error_nuevo' + ] as const) { + expect(isDeletable(s)).toBe(false); + } + }); +}); + +describe('resolveCatalogState', () => { + const active = new Map([['ventasdb', { BDName: 'VentasDB' }]]); + const all = new Map([ + ['ventasdb', { BDName: 'VentasDB' }], + ['viejadb', { BDName: 'ViejaDB' }] // en el catálogo pero NO en activos -> desactivado + ]); + + it('activo si está en el índice de nodos activos', () => { + expect(resolveCatalogState('VentasDB', active, all)).toBe('active'); + }); + it('desactivado si está en el catálogo pero no entre los activos', () => { + expect(resolveCatalogState('ViejaDB', active, all)).toBe('inactive'); + }); + it('ausente si no está en el catálogo', () => { + expect(resolveCatalogState('OtraDB', active, all)).toBe('absent'); + }); +}); + +describe('backupStemForNode', () => { + it('prefiere node_subnode_key (NodoSubNodo) para que CRA enrute el respaldo', () => { + expect(backupStemForNode({ NodoSubNodo: 'NODO001', BDName: 'VentasDB' }, 'VentasDB')).toBe('NODO001'); + }); + it('cae al nombre de la base si no hay NodoSubNodo', () => { + expect(backupStemForNode({ NodoSubNodo: ' ' }, 'VentasDB')).toBe('VentasDB'); + }); +}); + +describe('normalizeServerHost', () => { + it('ignora mayúsculas y espacios, e iguala host,puerto equivalentes', () => { + expect(normalizeServerHost(' HOST01,1433 ')).toBe(normalizeServerHost('host01,1433')); + }); + + it('distingue host distinto y puerto distinto', () => { + expect(normalizeServerHost('host01,1433')).not.toBe(normalizeServerHost('host02,1433')); + expect(normalizeServerHost('host01,1433')).not.toBe(normalizeServerHost('host01,1434')); + }); +}); + +describe('dropDatabaseOnServer (guard de whitelist)', () => { + const allowed = new Set(['VentasDB', 'ComprasDB']); + + it('rechaza un nombre fuera de la whitelist ANTES de tocar el pool', async () => { + const pool = { request: vi.fn() } as any; + await expect(dropDatabaseOnServer(pool, 'master', allowed)).rejects.toThrow(/no permitida/i); + await expect(dropDatabaseOnServer(pool, '', allowed)).rejects.toThrow(/no permitida/i); + // Intento de inyección: el string completo no está en la whitelist, así que ni llega al pool. + await expect( + dropDatabaseOnServer(pool, 'VentasDB]; DROP DATABASE Otra;--', allowed) + ).rejects.toThrow(/no permitida/i); + expect(pool.request).not.toHaveBeenCalled(); + }); + + it('un nombre permitido llega al pool con @dbname parametrizado', async () => { + const query = vi.fn().mockResolvedValue({}); + const input = vi.fn(); + const request = vi.fn(() => ({ input, query })); + const pool = { request } as any; + await dropDatabaseOnServer(pool, 'VentasDB', allowed); + expect(input).toHaveBeenCalledWith('dbname', expect.anything(), 'VentasDB'); + expect(query).toHaveBeenCalledTimes(1); + expect(String(query.mock.calls[0][0])).toContain('QUOTENAME'); + }); +}); diff --git a/src/lib/server/dedup-databases.ts b/src/lib/server/dedup-databases.ts new file mode 100644 index 0000000..10e3927 --- /dev/null +++ b/src/lib/server/dedup-databases.ts @@ -0,0 +1,320 @@ +/** + * Depuración de bases duplicadas: al mover bases a un servidor nuevo, las copias quedaron + * también en el viejo. Aquí se reconcilia (¿la base ya está bien en el nuevo?) y se borran + * las copias del servidor viejo de forma segura. + * + * El servidor viejo se identifica por su restore_target; el nuevo es el `server_name` que el + * catálogo (database_nodes) tiene asignado a esa base. Criterio de "segura para borrar": + * existe en el nuevo con tamaño coherente (>= (1 - tolerancia) del tamaño en el viejo). + */ +import { env } from '$env/dynamic/private'; +import { + getMssqlPoolMaster, + resolveNodeSqlPassword, + queryDatabaseMetricsOnServer, + listUserDatabasesOnServer, + dropDatabaseOnServer, + parseMssqlServer, + mapWithConcurrency +} from './mssql-nodes'; +import { + getRestoreTargetWithPasswordById, + listDatabaseNodes, + listDatabaseNodesForMssql +} from './controldesk-pg'; +import { logger } from './logger'; + +export const DEFAULT_SIZE_TOLERANCE = 0.2; + +/** Tolerancia de tamaño (fracción 0–1). El nuevo debe pesar >= (1 - tolerancia) del viejo. */ +export function sizeTolerance(): number { + const v = Number(env.PANEL_DEDUP_SIZE_TOLERANCE); + return Number.isFinite(v) && v >= 0 && v < 1 ? v : DEFAULT_SIZE_TOLERANCE; +} + +/** + * requestTimeout para operaciones largas de SQL Server (BACKUP/DROP). El default de node-mssql + * (15 s) aborta un BACKUP/DROP real. Default 1 h, configurable. + */ +export function ddlTimeoutMs(): number { + const v = Number(env.PANEL_DEDUP_DDL_TIMEOUT_MS); + return Number.isFinite(v) && v > 0 ? v : 3600000; +} + +/** Estado de la base en el catálogo del panel (database_nodes). */ +export type CatalogState = 'active' | 'inactive' | 'absent'; + +export type DuplicateStatus = + | 'segura' // existe en el nuevo con tamaño coherente -> se puede borrar del viejo + | 'revisar' // existe en el nuevo pero el tamaño no cuadra + | 'falta_en_nuevo' // no existe en el servidor nuevo (candidata a "mandar al nuevo") + | 'nodo_desactivado' // la base está en el catálogo pero su nodo está desactivado + | 'no_catalogo' // la base no está en el catálogo (no se sabe su destino) + | 'mismo_servidor' // el destino nuevo ES este mismo servidor (evita borrar la copia viva) + | 'error_nuevo'; // no se pudo verificar el servidor nuevo (conexión/sin destino) + +export type DuplicateInput = { + catalogState: CatalogState; + sameServer: boolean; + newVerified: boolean; + newExists: boolean; + oldSizeMb: number; + newSizeMb: number; +}; + +/** + * Clasifica una base del servidor viejo. Pura y sin efectos: es el único punto que decide si una + * base es borrable, tanto en el escaneo como en la re-verificación previa al borrado. + */ +export function classifyDuplicate(inp: DuplicateInput, tolerance: number): DuplicateStatus { + if (inp.catalogState === 'absent') return 'no_catalogo'; + if (inp.catalogState === 'inactive') return 'nodo_desactivado'; + if (inp.sameServer) return 'mismo_servidor'; + if (!inp.newVerified) return 'error_nuevo'; + if (!inp.newExists) return 'falta_en_nuevo'; + const threshold = inp.oldSizeMb * (1 - tolerance); + return inp.newSizeMb >= threshold ? 'segura' : 'revisar'; +} + +export function isDeletable(status: DuplicateStatus): boolean { + return status === 'segura'; +} + +/** Normaliza `host,puerto` / `host\instancia` a una clave comparable para detectar mismo servidor. */ +export function normalizeServerHost(address: string): string { + const { server, port } = parseMssqlServer(String(address ?? '').trim()); + return `${server.toLowerCase()}|${port ?? ''}`; +} + +export type DuplicateRow = { + name: string; + oldSizeMb: number; + oldLastRestore: string | null; + newServer: string | null; + newServerLabel: string | null; + newSizeMb: number | null; + newLastRestore: string | null; + status: DuplicateStatus; + deletable: boolean; + /** true si la base es candidata a mandarse al servidor nuevo (falta_en_nuevo con nodo activo). */ + movable: boolean; +}; + +export type ScanResult = { + target: { id: number; name: string; server_ip: string }; + rows: DuplicateRow[]; +}; + +function toIso(value: unknown): string | null { + if (!value) return null; + const d = value instanceof Date ? value : new Date(value as string); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} + +/** Indexa nodos del catálogo por nombre de base (minúsculas); conserva el primero. */ +export function indexNodesByDbName(nodes: any[]): Map { + const byName = new Map(); + for (const n of nodes) { + const key = String(n.BDName ?? '').trim().toLowerCase(); + if (key && !byName.has(key)) byName.set(key, n); + } + return byName; +} + +/** Estado en el catálogo: activo (con destino conectable), desactivado, o ausente. */ +export function resolveCatalogState( + dbName: string, + activeByName: Map, + allByName: Map +): CatalogState { + const key = dbName.toLowerCase(); + if (activeByName.has(key)) return 'active'; + if (allByName.has(key)) return 'inactive'; + return 'absent'; +} + +function nodeLabel(node: any): string | null { + return String(node?.Nombre ?? node?.NodoSubNodo ?? '').trim() || null; +} + +export type NewServerCheck = { + sameServer: boolean; + newVerified: boolean; + newExists: boolean; + newSizeMb: number | null; + newLastRestore: string | null; + newServer: string | null; +}; + +const EMPTY_NEW_CHECK: NewServerCheck = { + sameServer: false, + newVerified: false, + newExists: false, + newSizeMb: null, + newLastRestore: null, + newServer: null +}; + +/** Verifica la copia en el servidor nuevo asignado a `node` para una base dada. */ +export async function verifyOnNewServer( + node: any, + dbName: string, + oldHost: string +): Promise { + const newServer = String(node?.ServerName ?? '').trim(); + const sameServer = !!newServer && normalizeServerHost(newServer) === oldHost; + if (sameServer || !newServer) { + return { ...EMPTY_NEW_CHECK, sameServer, newServer: newServer || null }; + } + try { + const pool = await getMssqlPoolMaster(newServer, resolveNodeSqlPassword(node.sql_password)); + const info = await queryDatabaseMetricsOnServer(pool, dbName); + return { + sameServer: false, + newVerified: true, + newExists: !!info, + newSizeMb: info ? Number(info.size_mb) || 0 : null, + newLastRestore: info ? toIso(info.last_restore_date) : null, + newServer + }; + } catch (e) { + logger.error({ + message: 'dedup: no se pudo verificar el servidor nuevo', + context: { db: dbName, server: newServer, error: e instanceof Error ? e.message : String(e) } + }); + return { ...EMPTY_NEW_CHECK, newServer }; + } +} + +/** + * Escanea el servidor viejo (restore_target) y reconcilia cada base de usuario contra su destino + * nuevo en el catálogo. No borra nada. + */ +export async function scanDuplicates(oldTargetId: number): Promise { + const target = await getRestoreTargetWithPasswordById(oldTargetId); + if (!target) throw new Error('Servidor de restauración no encontrado.'); + + const tolerance = sizeTolerance(); + const oldHost = normalizeServerHost(target.server_ip); + const oldPool = await getMssqlPoolMaster(target.server_ip, target.sql_password, target.sql_username); + const oldDbs = await listUserDatabasesOnServer(oldPool); + // Nodos activos (con sql_password, para conectar al nuevo) + TODOS los nodos (para distinguir + // los que están en el catálogo pero desactivados de los que no están en absoluto). + const activeByName = indexNodesByDbName(await listDatabaseNodesForMssql()); + const allByName = indexNodesByDbName(await listDatabaseNodes()); + + const rows = await mapWithConcurrency(oldDbs, 8, async (oldDb): Promise => { + const key = oldDb.name.toLowerCase(); + const catalogState = resolveCatalogState(oldDb.name, activeByName, allByName); + const activeNode = activeByName.get(key); + const anyNode = allByName.get(key); + const v = + catalogState === 'active' && activeNode + ? await verifyOnNewServer(activeNode, oldDb.name, oldHost) + : EMPTY_NEW_CHECK; + const status = classifyDuplicate( + { + catalogState, + sameServer: v.sameServer, + newVerified: v.newVerified, + newExists: v.newExists, + oldSizeMb: oldDb.size_mb, + newSizeMb: v.newSizeMb ?? 0 + }, + tolerance + ); + return { + name: oldDb.name, + oldSizeMb: oldDb.size_mb, + oldLastRestore: toIso(oldDb.last_restore_date), + newServer: v.newServer ?? (anyNode ? String(anyNode.ServerName ?? '').trim() || null : null), + newServerLabel: nodeLabel(activeNode ?? anyNode), + newSizeMb: v.newSizeMb, + newLastRestore: v.newLastRestore, + status, + deletable: isDeletable(status), + movable: status === 'falta_en_nuevo' + }; + }); + + return { target: { id: target.id, name: target.name, server_ip: target.server_ip }, rows }; +} + +export type DropOutcome = { name: string; ok: boolean; status: string; message?: string }; + +/** + * Borra del servidor viejo las bases indicadas, RE-VERIFICANDO la seguridad del lado servidor + * (no confía en el cliente): solo borra las que siguen clasificando como 'segura'. + */ +export async function dropDuplicates( + oldTargetId: number, + names: string[], + actor?: string +): Promise { + const target = await getRestoreTargetWithPasswordById(oldTargetId); + if (!target) throw new Error('Servidor de restauración no encontrado.'); + + const tolerance = sizeTolerance(); + const oldHost = normalizeServerHost(target.server_ip); + const oldPool = await getMssqlPoolMaster(target.server_ip, target.sql_password, target.sql_username); + // Pool con timeout amplio para el DROP (SINGLE_USER + ROLLBACK puede pasar de 15 s). + const oldPoolDDL = await getMssqlPoolMaster( + target.server_ip, + target.sql_password, + target.sql_username, + ddlTimeoutMs() + ); + const oldDbs = await listUserDatabasesOnServer(oldPool); + const oldByName = new Map(oldDbs.map((d) => [d.name.toLowerCase(), d])); + const allowedNames = new Set(oldDbs.map((d) => d.name)); // whitelist exacta del propio servidor + const activeByName = indexNodesByDbName(await listDatabaseNodesForMssql()); + const allByName = indexNodesByDbName(await listDatabaseNodes()); + + const outcomes: DropOutcome[] = []; + for (const rawName of names) { + const name = String(rawName ?? '').trim(); + const oldInfo = oldByName.get(name.toLowerCase()); + if (!oldInfo) { + outcomes.push({ name, ok: false, status: 'no_existe', message: 'La base ya no existe en el servidor viejo.' }); + continue; + } + const realName = oldInfo.name; // casing canónico del servidor + const catalogState = resolveCatalogState(realName, activeByName, allByName); + const activeNode = activeByName.get(realName.toLowerCase()); + const v = + catalogState === 'active' && activeNode + ? await verifyOnNewServer(activeNode, realName, oldHost) + : EMPTY_NEW_CHECK; + const status = classifyDuplicate( + { + catalogState, + sameServer: v.sameServer, + newVerified: v.newVerified, + newExists: v.newExists, + oldSizeMb: oldInfo.size_mb, + newSizeMb: v.newSizeMb ?? 0 + }, + tolerance + ); + if (!isDeletable(status)) { + outcomes.push({ name: realName, ok: false, status, message: 'No pasó la verificación de seguridad; no se borró.' }); + continue; + } + try { + await dropDatabaseOnServer(oldPoolDDL, realName, allowedNames); + logger.info({ + message: 'dedup: base borrada del servidor viejo', + context: { db: realName, server: target.server_ip, target_id: target.id, actor: actor ?? null } + }); + outcomes.push({ name: realName, ok: true, status: 'borrada' }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logger.error({ + message: 'dedup: fallo al borrar base del servidor viejo', + context: { db: realName, server: target.server_ip, error: msg } + }); + outcomes.push({ name: realName, ok: false, status: 'error_drop', message: msg }); + } + } + return outcomes; +} diff --git a/src/lib/server/email-service.test.ts b/src/lib/server/email-service.test.ts new file mode 100644 index 0000000..e1cb446 --- /dev/null +++ b/src/lib/server/email-service.test.ts @@ -0,0 +1,66 @@ +/** + * Pruebas de los builders HTML de correo (réplica 1:1 del legacy) y del formateo de fecha. + * Importan las funciones REALES: se valida estructura legacy + escape de campos de texto. + */ +import { describe, it, expect } from 'vitest'; +import { buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } from './email-service'; + +const baseRow = { + visible_name: 'CLIENTE_DB', + clientName: 'ACME S.A.', + last_restore_date: '2026-06-28T17:26:15.290000', + daysWithout: 3 +}; + +describe('buildBackupAlertHtml — 1:1 legacy (overdue)', () => { + it('incluye logo, título, link SCAIIWeb y pie TransmitirAS', () => { + const html = buildBackupAlertHtml([baseRow]); + expect(html).toContain('https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png'); + expect(html).toContain('Notificación de Sincronización'); + expect(html).toContain('no se ha sincronizado correctamente en las últimas 24 horas'); + expect(html).toContain('https://a24.aduanasoft.com/SCAIIWeb'); + expect(html).toContain('© 2024 TransmitirAS'); + expect(html).toContain('ACME S.A.'); // cliente + expect(html).toContain('CLIENTE_DB'); // base de datos + expect(html).toContain('28 de junio de 2026, 17:26'); // fecha formateada + }); + + it('escapa visible_name y clientName (XSS)', () => { + const html = buildBackupAlertHtml([ + { visible_name: '', clientName: 'x', last_restore_date: null, daysWithout: null } + ]); + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + expect(html).not.toContain('x'); + expect(html).toContain('No disponible'); // last_restore null + }); +}); + +describe('buildBackupResolvedHtml — misma plantilla, mensaje positivo', () => { + it('incluye título restablecido, logo, SCAIIWeb y pie', () => { + const html = buildBackupResolvedHtml([baseRow]); + expect(html).toContain('Sincronización Restablecida'); + expect(html).toContain('volvió a sincronizarse correctamente'); + expect(html).toContain('https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png'); + expect(html).toContain('https://a24.aduanasoft.com/SCAIIWeb'); + expect(html).toContain('© 2024 TransmitirAS'); + }); + + it('escapa clientName', () => { + const html = buildBackupResolvedHtml([ + { visible_name: 'DB', clientName: '', last_restore_date: null, daysWithout: null } + ]); + expect(html).not.toContain(''); + expect(html).toContain('<img'); + }); +}); + +describe('formatFechaEs', () => { + it('formatea fecha ISO (con microsegundos) al estilo legacy', () => { + expect(formatFechaEs('2026-06-28T17:26:15.290000')).toBe('28 de junio de 2026, 17:26'); + }); + it('null/invalid -> No disponible', () => { + expect(formatFechaEs(null)).toBe('No disponible'); + expect(formatFechaEs('no-fecha')).toBe('No disponible'); + }); +}); diff --git a/src/lib/server/email-service.ts b/src/lib/server/email-service.ts new file mode 100644 index 0000000..6689821 --- /dev/null +++ b/src/lib/server/email-service.ts @@ -0,0 +1,162 @@ +/** + * Servicio SMTP genérico. Si SMTP_HOST no está configurado, las funciones no hacen nada. + * Porta el patrón de backend/core/mail.py de a24c a Node.js/nodemailer. + */ +import nodemailer from 'nodemailer'; + +export interface SendEmailOptions { + toAddrs: string[]; + subject: string; + plainBody: string; + htmlBody?: string; + fromDisplayName?: string; + highImportance?: boolean; +} + +function buildTransporter() { + const host = (process.env.SMTP_HOST ?? '').trim(); + if (!host) return null; + + const port = parseInt(process.env.SMTP_PORT ?? '587', 10); + const user = (process.env.SMTP_USER ?? '').trim(); + const pass = (process.env.SMTP_PASSWORD ?? '').trim(); + const useTls = (process.env.SMTP_USE_TLS ?? 'true').toLowerCase() !== 'false'; + + return nodemailer.createTransport({ + host, + port, + secure: port === 465, + ...(port !== 465 && { requireTLS: useTls }), + ...(user && { auth: { user, pass } }) + }); +} + +export async function sendSmtpEmail(opts: SendEmailOptions): Promise { + const host = (process.env.SMTP_HOST ?? '').trim(); + if (!host || opts.toAddrs.length === 0) return; + + const fromAddr = (process.env.SMTP_FROM || process.env.SMTP_USER || '').trim(); + if (!fromAddr) { + console.warn('[email-service] SMTP_FROM o SMTP_USER debe configurarse para enviar correo'); + return; + } + + const transporter = buildTransporter(); + if (!transporter) return; + + const from = opts.fromDisplayName?.trim() + ? `"${opts.fromDisplayName.trim()}" <${fromAddr}>` + : fromAddr; + + const headers: Record = {}; + if (opts.highImportance) { + headers['Importance'] = 'high'; + headers['X-Priority'] = '1'; + headers['X-MSMail-Priority'] = 'High'; + } + + await transporter.sendMail({ + from, + to: opts.toAddrs.join(', '), + subject: opts.subject, + text: opts.plainBody, + ...(opts.htmlBody ? { html: opts.htmlBody } : {}), + headers + }); +} + +// ============================================================================ +// Plantillas de correo — réplica 1:1 del legacy index.php (logo, azul, narrativa +// por cliente, link SCAIIWeb, pie © TransmitirAS). +// ============================================================================ + +const LOGO_URL = 'https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png'; + +const SCAIIWEB_H4 = + `

` + + `Consulta la última sincronización de datos fácilmente desde ` + + `SCAIIWeb. ` + + `Inicia sesión y encontrarás esta información en la esquina inferior derecha de la pantalla.

`; + +const FOOTER = + `
` + + `© 2024 TransmitirAS. Todos los derechos reservados.
`; + +/** Envuelve el contenido en el mismo cascarón del legacy (Arial, logo centrado, pie). */ +function renderSyncEmail(inner: string): string { + return ` + + + +
+
+ Logo +
+ ${inner} + ${FOOTER} +
+ +`; +} + +/** Fecha en español "28 de junio de 2026, 17:26" (o "No disponible"), como el strftime del legacy. */ +export function formatFechaEs(iso: string | null | undefined): string { + if (!iso) return 'No disponible'; + // Normaliza microsegundos (a24c manda isoformat con 6 dígitos) a milisegundos. + const cleaned = String(iso).replace(/(\.\d{3})\d+/, '$1'); + const d = new Date(cleaned); + if (isNaN(d.getTime())) return 'No disponible'; + const meses = [ + 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', + 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre' + ]; + const dd = String(d.getDate()).padStart(2, '0'); + const mm = meses[d.getMonth()]; + const yyyy = d.getFullYear(); + const HH = String(d.getHours()).padStart(2, '0'); + const MM = String(d.getMinutes()).padStart(2, '0'); + return `${dd} de ${mm} de ${yyyy}, ${HH}:${MM}`; +} + +/** Correo de alerta de sincronización (kind=overdue), 1:1 con el legacy index.php. */ +export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string { + const a = alerts[0]; + const nombre = escHtml(a?.clientName ?? a?.visible_name ?? ''); + const bd = escHtml(a?.visible_name ?? ''); + const fecha = escHtml(formatFechaEs(a?.last_restore_date)); + const inner = ` +

Notificación de Sincronización

+

Estimado ${nombre},

+

Detectamos que la base de datos ${bd} no se ha sincronizado correctamente en las últimas 24 horas.

+

La última restauración registrada fue: ${fecha}.

+ ${SCAIIWEB_H4} +

Por favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.

`; + return renderSyncEmail(inner); +} + +/** Correo de "sincronización restablecida" (kind=resolved): misma plantilla legacy, mensaje positivo. */ +export function buildBackupResolvedHtml(alerts: BackupAlertRow[]): string { + const a = alerts[0]; + const nombre = escHtml(a?.clientName ?? a?.visible_name ?? ''); + const bd = escHtml(a?.visible_name ?? ''); + const fecha = escHtml(formatFechaEs(a?.last_restore_date)); + const inner = ` +

Sincronización Restablecida

+

Estimado ${nombre},

+

La base de datos ${bd} volvió a sincronizarse correctamente.

+

La última restauración registrada fue: ${fecha}.

+ ${SCAIIWEB_H4} +

No se requiere ninguna acción de su parte. Gracias por mantener su equipo y la aplicación en funcionamiento.

`; + return renderSyncEmail(inner); +} + +export interface BackupAlertRow { + visible_name: string; + clientName: string | null; + last_restore_date: string | null; + daysWithout: number | null; +} + +function escHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/src/lib/server/fernet.test.ts b/src/lib/server/fernet.test.ts new file mode 100644 index 0000000..17286ec --- /dev/null +++ b/src/lib/server/fernet.test.ts @@ -0,0 +1,74 @@ +/** + * Pruebas del esquema Fernet, compatible con `cryptography.fernet.Fernet` de a24c. + * Cubre: round-trip, detección de token, HMAC, clave errónea y derivación de clave. + */ +import { describe, it, expect } from 'vitest'; +import { + deriveFernetKey, + fernetEncrypt, + fernetDecrypt, + isFernetToken +} from './fernet'; + +const SECRET = 'clave-secreta-de-prueba-compartida-con-a24c'; +const KEY = deriveFernetKey(SECRET); +const TS = 1_700_000_000; // segundos Unix fijos para determinismo + +describe('fernet (interop a24c)', () => { + it('deriva una clave de 32 bytes desde SECRET_KEY (sha256)', () => { + expect(KEY.length).toBe(32); + // La misma SECRET_KEY produce siempre la misma clave + expect(deriveFernetKey(SECRET).equals(KEY)).toBe(true); + }); + + it('lanza si SECRET_KEY está vacía', () => { + expect(() => deriveFernetKey('')).toThrow(/SECRET_KEY/); + expect(() => deriveFernetKey(' ')).toThrow(/SECRET_KEY/); + }); + + it('round-trip: descifrar devuelve el texto original (incl. UTF-8)', () => { + for (const pt of ['Soluciones01!', 'ClaveConÑ_áé#2024', '']) { + const token = fernetEncrypt(pt, KEY, TS); + expect(fernetDecrypt(token, KEY)).toBe(pt); + } + }); + + it('produce un token Fernet reconocible (prefijo gAAAAA, base64-url)', () => { + const token = fernetEncrypt('secreto', KEY, TS); + expect(token.startsWith('gAAAAA')).toBe(true); + expect(isFernetToken(token)).toBe(true); + }); + + it('isFernetToken rechaza texto plano y valores no-token', () => { + expect(isFernetToken('Soluciones01!')).toBe(false); + expect(isFernetToken('gcm:a:b:c')).toBe(false); + expect(isFernetToken('')).toBe(false); + expect(isFernetToken(null)).toBe(false); + expect(isFernetToken(undefined)).toBe(false); + }); + + it('usa IV aleatorio: dos cifrados difieren pero descifran igual', () => { + const a = fernetEncrypt('mismo', KEY, TS); + const b = fernetEncrypt('mismo', KEY, TS); + expect(a).not.toBe(b); + expect(fernetDecrypt(a, KEY)).toBe(fernetDecrypt(b, KEY)); + }); + + it('falla con clave incorrecta (HMAC no valida)', () => { + const token = fernetEncrypt('secreto', KEY, TS); + const otherKey = deriveFernetKey('otra-secret-key'); + expect(() => fernetDecrypt(token, otherKey)).toThrow(/HMAC/); + }); + + it('detecta manipulación del token', () => { + const token = fernetEncrypt('integridad', KEY, TS); + const data = Buffer.from(token.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + data[20] = data[20] ^ 0xff; // altera un byte del ciphertext + const tampered = data.toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); + expect(() => fernetDecrypt(tampered, KEY)).toThrow(); + }); + + it('rechaza tokens demasiado cortos o con versión inválida', () => { + expect(() => fernetDecrypt('gA==', KEY)).toThrow(/corto/); + }); +}); diff --git a/src/lib/server/fernet.ts b/src/lib/server/fernet.ts new file mode 100644 index 0000000..2b536fc --- /dev/null +++ b/src/lib/server/fernet.ts @@ -0,0 +1,115 @@ +/** + * Implementación pura del esquema Fernet (spec oficial), compatible byte a byte con + * `cryptography.fernet.Fernet` de Python — que es lo que usa a24c para descifrar la + * contraseña SQL de cada nodo (`database_nodes.sql_password`). + * + * Fernet = AES-128-CBC (PKCS7) + HMAC-SHA256, sobre una clave de 32 bytes: + * - bytes [0..16) → clave de firma (HMAC) + * - bytes [16..32) → clave de cifrado (AES-128) + * + * Formato del token (antes de base64-url): + * 0x80 | timestamp(8, big-endian) | iv(16) | ciphertext(múltiplo de 16) | hmac(32) + * + * a24c deriva la clave así: base64.urlsafe_b64encode(sha256(SECRET_KEY).digest()) + * que Fernet vuelve a decodificar a los 32 bytes crudos de `sha256(SECRET_KEY)`. + * Aquí replicamos exactamente esa derivación con `deriveFernetKey`. + */ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + randomBytes, + timingSafeEqual +} from 'node:crypto'; + +const FERNET_VERSION = 0x80; +const KEY_LENGTH = 32; // 16 firma + 16 cifrado +const IV_LENGTH = 16; +const HMAC_LENGTH = 32; +const HEADER_LENGTH = 1 + 8 + IV_LENGTH; // version + timestamp + iv +/** Longitud mínima de un token válido: header + 1 bloque AES + hmac. */ +const MIN_TOKEN_BYTES = HEADER_LENGTH + 16 + HMAC_LENGTH; + +function toUrlSafeBase64(buf: Buffer): string { + // Padded url-safe base64 (con `=`), como produce Python; su decoder lo exige. + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); +} + +function fromUrlSafeBase64(token: string): Buffer { + return Buffer.from(token.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); +} + +/** Deriva la clave Fernet de 32 bytes desde SECRET_KEY, idéntica a la de a24c. */ +export function deriveFernetKey(secret: string): Buffer { + if (!secret || !secret.trim()) { + throw new Error('SECRET_KEY no está configurada (debe coincidir con la de a24c).'); + } + return createHash('sha256').update(secret, 'utf8').digest(); // 32 bytes +} + +/** + * Cifra `plaintext` como token Fernet. `timestampSec` permite inyectar el tiempo + * (segundos Unix) para pruebas deterministas; en producción se pasa el reloj real. + */ +export function fernetEncrypt(plaintext: string, key32: Buffer, timestampSec: number): string { + if (key32.length !== KEY_LENGTH) { + throw new Error(`La clave Fernet debe ser de ${KEY_LENGTH} bytes; se recibieron ${key32.length}.`); + } + const signingKey = key32.subarray(0, 16); + const encKey = key32.subarray(16, 32); + + const iv = randomBytes(IV_LENGTH); + const ts = Buffer.alloc(8); + ts.writeBigUInt64BE(BigInt(Math.floor(timestampSec))); + + const cipher = createCipheriv('aes-128-cbc', encKey, iv); // PKCS7 automático + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + + const parts = Buffer.concat([Buffer.from([FERNET_VERSION]), ts, iv, ciphertext]); + const hmac = createHmac('sha256', signingKey).update(parts).digest(); + + return toUrlSafeBase64(Buffer.concat([parts, hmac])); +} + +/** Descifra un token Fernet. Lanza si el HMAC no valida o el formato es inválido. */ +export function fernetDecrypt(token: string, key32: Buffer): string { + if (key32.length !== KEY_LENGTH) { + throw new Error(`La clave Fernet debe ser de ${KEY_LENGTH} bytes; se recibieron ${key32.length}.`); + } + const signingKey = key32.subarray(0, 16); + const encKey = key32.subarray(16, 32); + + const data = fromUrlSafeBase64(String(token ?? '')); + if (data.length < MIN_TOKEN_BYTES) throw new Error('Token Fernet demasiado corto.'); + if (data[0] !== FERNET_VERSION) throw new Error('Versión de token Fernet inválida.'); + + const hmacOffset = data.length - HMAC_LENGTH; + const signed = data.subarray(0, hmacOffset); + const providedHmac = data.subarray(hmacOffset); + const expectedHmac = createHmac('sha256', signingKey).update(signed).digest(); + if (!timingSafeEqual(providedHmac, expectedHmac)) { + throw new Error('HMAC del token Fernet no coincide (clave incorrecta o dato manipulado).'); + } + + const iv = data.subarray(9, 9 + IV_LENGTH); + const ciphertext = data.subarray(9 + IV_LENGTH, hmacOffset); + const decipher = createDecipheriv('aes-128-cbc', encKey, iv); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); +} + +/** + * Heurística sin clave para distinguir un token Fernet de texto plano legado: + * base64-url válido que decodifica a un blob con versión 0x80 y longitud mínima. + */ +export function isFernetToken(value: unknown): boolean { + if (typeof value !== 'string') return false; + const v = value.trim(); + if (v.length < 100 || !/^[A-Za-z0-9_-]+={0,2}$/.test(v)) return false; + try { + const data = fromUrlSafeBase64(v); + return data.length >= MIN_TOKEN_BYTES && data[0] === FERNET_VERSION; + } catch { + return false; + } +} diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts new file mode 100644 index 0000000..ab901d6 --- /dev/null +++ b/src/lib/server/logger.ts @@ -0,0 +1,36 @@ +/** + * Logging estructurado en JSON (estándar Aduanasoft §13). Escribe a stdout/stderr; + * el orquestador (Docker/K8s) lo recolecta. Evita console.log de depuración suelto. + */ +const SERVICE = 'panel-bases-anexo24'; + +type LogLevel = 'info' | 'warn' | 'error'; + +interface LogFields { + trace_id?: string; + message: string; + context?: Record; +} + +function emit(level: LogLevel, fields: LogFields): void { + const entry = { + timestamp: new Date().toISOString(), + level, + service: SERVICE, + trace_id: fields.trace_id ?? null, + message: fields.message, + context: fields.context ?? {} + }; + const line = JSON.stringify(entry); + if (level === 'error') { + console.error(line); + } else { + console.log(line); + } +} + +export const logger = { + info: (fields: LogFields) => emit('info', fields), + warn: (fields: LogFields) => emit('warn', fields), + error: (fields: LogFields) => emit('error', fields) +}; diff --git a/src/lib/server/mssql-nodes.test.ts b/src/lib/server/mssql-nodes.test.ts new file mode 100644 index 0000000..7dc9bd6 --- /dev/null +++ b/src/lib/server/mssql-nodes.test.ts @@ -0,0 +1,86 @@ +/** + * Pruebas de resolveNodeSqlPassword: la columna database_nodes.sql_password se autorrellena + * al asignar un restaurador copiando su sobre cifrado (gcm:), así que al resolverla hay que + * descifrarla. Cubre: sin valor → global, texto plano legado, sobre gcm: descifrado, y + * descifrado que falla → global (no debe tumbar el dashboard). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { env } from '$env/dynamic/private'; + +// Se conserva el isEncrypted real (trivial: prefijo gcm:) y solo se controla el descifrado. +vi.mock('./crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, decryptSecret: vi.fn() }; +}); + +import { decryptSecret } from './crypto'; +import { resolveNodeSqlPassword, parseMssqlServer } from './mssql-nodes'; + +const decryptMock = vi.mocked(decryptSecret); + +beforeEach(() => { + vi.clearAllMocks(); + env.PANEL_MSSQL_PASSWORD = 'GLOBAL_PWD'; +}); + +describe('resolveNodeSqlPassword', () => { + it('sin valor (null/undefined/vacío/espacios) usa la contraseña global', () => { + expect(resolveNodeSqlPassword(null)).toBe('GLOBAL_PWD'); + expect(resolveNodeSqlPassword(undefined)).toBe('GLOBAL_PWD'); + expect(resolveNodeSqlPassword('')).toBe('GLOBAL_PWD'); + expect(resolveNodeSqlPassword(' ')).toBe('GLOBAL_PWD'); + expect(decryptMock).not.toHaveBeenCalled(); + }); + + it('texto plano legado (sin prefijo gcm:) se usa tal cual y se recorta, sin descifrar', () => { + expect(resolveNodeSqlPassword(' MiPassPlano ')).toBe('MiPassPlano'); + expect(decryptMock).not.toHaveBeenCalled(); + }); + + it('sobre cifrado gcm: se descifra y devuelve el texto plano', () => { + decryptMock.mockReturnValue('SecretoDescifrado'); + const envelope = 'gcm:aXY=:dGFn:Y2lwaGVy'; + expect(resolveNodeSqlPassword(envelope)).toBe('SecretoDescifrado'); + expect(decryptMock).toHaveBeenCalledWith(envelope); + }); + + it('si el descifrado falla (sobre corrupto o clave equivocada) cae a la global sin propagar el error', () => { + decryptMock.mockImplementation(() => { + throw new Error('autenticación GCM inválida'); + }); + expect(resolveNodeSqlPassword('gcm:corrupto')).toBe('GLOBAL_PWD'); + expect(decryptMock).toHaveBeenCalledOnce(); + }); +}); + +describe('parseMssqlServer', () => { + it('host,puerto → server y port separados (evita el bug host,puerto:1433)', () => { + expect(parseMssqlServer('192.168.1.20,1433')).toEqual({ + server: '192.168.1.20', + port: 1433 + }); + }); + + it('solo host → sin puerto (tedious usa 1433 por defecto)', () => { + expect(parseMssqlServer('SQLSERVER01')).toEqual({ server: 'SQLSERVER01' }); + }); + + it('host\\instancia → instanceName', () => { + expect(parseMssqlServer('HOST\\SQLEXPRESS')).toEqual({ + server: 'HOST', + instanceName: 'SQLEXPRESS' + }); + }); + + it('host\\instancia,puerto → server, instanceName y port', () => { + expect(parseMssqlServer('HOST\\SQLEXPRESS,1450')).toEqual({ + server: 'HOST', + port: 1450, + instanceName: 'SQLEXPRESS' + }); + }); + + it('recorta espacios e ignora puerto no numérico', () => { + expect(parseMssqlServer(' 10.0.0.5 , abc ')).toEqual({ server: '10.0.0.5' }); + }); +}); diff --git a/src/lib/server/mssql-nodes.ts b/src/lib/server/mssql-nodes.ts new file mode 100644 index 0000000..6a58560 --- /dev/null +++ b/src/lib/server/mssql-nodes.ts @@ -0,0 +1,528 @@ +/** + * Conexiones SQL Server por nodo (paridad con a24c: server_name / database_name en database_nodes, + * usuario global PANEL_MSSQL_USER, contraseña por nodo sql_password o PANEL_MSSQL_PASSWORD). + */ +import sql from 'mssql'; +import { env } from '$env/dynamic/private'; +import { decryptSecret, isEncrypted } from './crypto'; + +// Timeout de conexión acotado en TODOS los entornos: un SQL Server sano conecta en <1s, +// así que 5s (alineado con el pool de PostgreSQL) es holgado. Antes eran 30s, lo que colgaba +// la carga del dashboard cuando algún servidor no responde. Configurable por env si un +// despliegue lo necesita. +const MSSQL_CONNECT_TIMEOUT_MS = Number(env.PANEL_MSSQL_CONNECT_TIMEOUT_MS) || 5000; + +const MAX_POOLS = 16; +// Se cachea la *promesa* del pool (no el pool ya resuelto) para que varias cargas de nodos +// concurrentes sobre el mismo servidor reutilicen una sola conexión en vuelo y no abran pools +// duplicados (condición de carrera que aparece al paralelizar el dashboard). +const poolMap = new Map>(); + +export function adjustMssqlServerForDocker(serverName: string): string { + const docker = + String(env.PANEL_MSSQL_DOCKER || '') + .trim() + .toLowerCase() === 'true' || + String(env.IN_DOCKER || '') + .trim() + .toLowerCase() === 'true'; + if (!docker) return serverName.trim(); + const parts = serverName.trim().split(',', 2); + const host = (parts[0] || '').trim().toLowerCase(); + if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') { + const port = parts[1]?.trim(); + return port ? `host.docker.internal,${port}` : 'host.docker.internal'; + } + return serverName.trim(); +} + +/** + * Descompone una dirección estilo SQL Server (`host,puerto` / `host\instancia` / combinaciones) + * en los campos separados que espera node-mssql (tedious). Es imprescindible: tedious NO entiende + * `host,puerto` en el campo `server` — lo toma como nombre de host literal y le añade el puerto + * por defecto (1433), produciendo intentos de conexión a `host,puerto:1433`. + */ +export function parseMssqlServer(address: string): { + server: string; + port?: number; + instanceName?: string; +} { + const raw = String(address ?? '').trim(); + const commaIdx = raw.indexOf(','); + const left = (commaIdx >= 0 ? raw.slice(0, commaIdx) : raw).trim(); + const portStr = commaIdx >= 0 ? raw.slice(commaIdx + 1).trim() : ''; + + const [host, instance] = left.split('\\', 2); + const result: { server: string; port?: number; instanceName?: string } = { + server: host.trim() + }; + const port = Number(portStr); + if (portStr && Number.isInteger(port) && port > 0) result.port = port; + if (instance && instance.trim()) result.instanceName = instance.trim(); + return result; +} + +export function resolveMssqlUser(): string { + return ( + String(env.PANEL_MSSQL_USER || '').trim() + ); +} + +/** + * Contraseña SQL del nodo. La columna sql_password se autorrellena al asignar un + * restaurador copiando su sobre cifrado (gcm:), así que aquí se descifra al vuelo. + * Acepta también texto plano legado. Si no hay valor por nodo, cae a la variable global. + */ +export function resolveNodeSqlPassword(nodeSqlPassword: string | null | undefined): string { + const raw = nodeSqlPassword != null ? String(nodeSqlPassword).trim() : ''; + if (raw) { + if (!isEncrypted(raw)) return raw; // texto plano legado + try { + return decryptSecret(raw); + } catch { + // sobre corrupto o clave equivocada: caer al global en vez de tumbar el dashboard + return String(env.PANEL_MSSQL_PASSWORD || '').trim(); + } + } + return String( + env.PANEL_MSSQL_PASSWORD || '' + ).trim(); +} + +function poolCacheKey(server: string, user: string, password: string, requestTimeoutMs?: number): string { + return `${server}\t${user}\t${password}\t${requestTimeoutMs ?? ''}`; +} + +async function evictPoolIfNeeded(): Promise { + while (poolMap.size >= MAX_POOLS) { + const first = poolMap.keys().next().value as string | undefined; + if (!first) break; + const old = poolMap.get(first); + poolMap.delete(first); + try { + (await old)?.close(); + } catch { + /* ignore */ + } + } +} + +/** + * Ejecuta `fn` sobre cada elemento con un máximo de `limit` tareas simultáneas. + * Conserva el orden de `items` en el arreglo de resultados. + */ +export async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise +): Promise { + const results: R[] = new Array(items.length); + let cursor = 0; + const workerCount = Math.min(Math.max(1, limit), items.length || 1); + async function worker(): Promise { + while (cursor < items.length) { + const index = cursor++; + results[index] = await fn(items[index], index); + } + } + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} + +/** + * Pool conectado a `master` en el servidor del nodo (permite consultar cualquier BD con nombre de tres partes). + */ +export async function getMssqlPoolMaster( + serverHost: string, + password: string, + userOverride?: string, + requestTimeoutMs?: number +): Promise { + // El dashboard usa el usuario global (PANEL_MSSQL_USER); la depuración de duplicados conecta + // al servidor viejo con el usuario propio del restore_target, de ahí el override opcional. + const user = (userOverride && userOverride.trim()) || resolveMssqlUser(); + if (!user || !password) { + throw new Error( + 'Falta PANEL_MSSQL_USER / PANEL_MSSQL_PASSWORD (o sql_password en database_nodes).' + ); + } + const server = adjustMssqlServerForDocker(serverHost); + // El requestTimeout entra en la clave de caché: las operaciones largas (BACKUP/DROP) usan un + // pool distinto con timeout amplio, sin alterar el pool de consultas rápidas del dashboard. + const key = poolCacheKey(server, user, password, requestTimeoutMs); + + const existing = poolMap.get(key); + if (existing) { + try { + const pool = await existing; + if (pool.connected || pool.connecting) return pool; + } catch { + /* pool inservible: se descarta y se recrea abajo */ + } + poolMap.delete(key); + } + + // `server` puede venir como `host,puerto` (formato SQL Server); tedious necesita host y puerto + // en campos separados, o intentará conectar a `host,puerto:1433`. + const { server: host, port, instanceName } = parseMssqlServer(server); + const cfg: sql.config = { + user, + password, + server: host, + ...(port ? { port } : {}), + database: 'master', + // node-mssql gobierna el timeout de conexión con `connectionTimeout` (top-level); + // se replica en options.connectTimeout (tedious) para cubrir ambas rutas. + connectionTimeout: MSSQL_CONNECT_TIMEOUT_MS, + // requestTimeout por defecto de node-mssql es 15 s: insuficiente para BACKUP/DROP de bases + // reales. Cuando se pide, se amplía (el dashboard sigue con el default corto). + ...(requestTimeoutMs ? { requestTimeout: requestTimeoutMs } : {}), + options: { + encrypt: true, + trustServerCertificate: true, + connectTimeout: MSSQL_CONNECT_TIMEOUT_MS, + ...(instanceName ? { instanceName } : {}) + } + }; + + // El slot se reserva en el mapa de forma SÍNCRONA (antes de cualquier await) para que las + // cargas concurrentes al mismo servidor compartan esta conexión en vuelo y no creen pools + // duplicados. La purga (evictPoolIfNeeded) ocurre dentro de la promesa, no antes de registrarla. + const connecting = (async () => { + await evictPoolIfNeeded(); + return new sql.ConnectionPool(cfg).connect(); + })(); + poolMap.set(key, connecting); + try { + return await connecting; + } catch (e) { + // No conservar en caché una conexión que falló al establecerse. + poolMap.delete(key); + throw e; + } +} + +export type CatalogNodeRow = { + ID: number; + ServerName: string; + BDName: string; + NodoSubNodo: string; + Nombre: string; + Activo?: number; + sql_password?: string | null; +}; + +/** Métricas de una base en un servidor (conexión a master). */ +export async function queryDatabaseMetricsOnServer( + pool: sql.ConnectionPool, + databaseName: string +): Promise { + const req = pool.request(); + req.input('dbname', sql.VarChar(100), databaseName); + // No unir restorehistory en el mismo SELECT que agrega mf.size: cada fila de + // restorehistory duplicaría los archivos y SUM inflaría el tamaño (p. ej. cientos de TB). + const result = await req.query(` + SELECT + d.name AS visible_name, + d.name AS original_name, + CAST(( + SELECT SUM(mf2.size) * 8.0 / 1024 + FROM sys.master_files mf2 + WHERE mf2.database_id = d.database_id + ) AS DECIMAL(10,2)) AS size_mb, + CAST(( + SELECT SUM(mf2.size) * 8.0 / 1024 / 1024 + FROM sys.master_files mf2 + WHERE mf2.database_id = d.database_id + ) AS DECIMAL(10,2)) AS total_size_gb, + ( + SELECT MAX(rh.restore_date) + FROM msdb.dbo.restorehistory rh + WHERE rh.destination_database_name = d.name + ) AS last_restore_date, + d.create_date, + d.state_desc, + d.recovery_model_desc + FROM sys.databases d + WHERE d.name = @dbname + `); + const row = result.recordset?.[0]; + return row ?? null; +} + +/** Alertas: misma regla que antes (sin restore reciente o null). */ +export async function queryDatabaseAlertRow( + pool: sql.ConnectionPool, + databaseName: string +): Promise<{ visible_name: string; last_restore_date: Date | null } | null> { + const req = pool.request(); + req.input('dbname', sql.VarChar(100), databaseName); + const result = await req.query(` + SELECT + d.name AS visible_name, + MAX(rh.restore_date) AS last_restore_date + FROM sys.databases d + LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name + WHERE d.name = @dbname + GROUP BY d.name + HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL + `); + const row = result.recordset?.[0]; + return row + ? { + visible_name: String(row.visible_name), + last_restore_date: row.last_restore_date ?? null + } + : null; +} + +export async function queryRestoreHistoryForDatabase( + pool: sql.ConnectionPool, + databaseName: string +): Promise<{ restore_date: Date }[]> { + const req = pool.request(); + req.input('dbname', sql.VarChar(100), databaseName); + const result = await req.query(` + SELECT restore_date + FROM msdb.dbo.restorehistory + WHERE destination_database_name = @dbname + AND restore_date >= DATEADD(DAY, -120, GETDATE()) + `); + return (result.recordset as any[]).map((r) => ({ restore_date: r.restore_date })); +} + +function computeEffectivenessFromHistory( + restoreHistory: Record +): Record { + const effectivenessByDb: Record = {}; + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth(); + const monthsToInclude = [currentMonth, currentMonth - 1, currentMonth - 2].filter((m) => m >= 0); + + for (const [dbName, history] of Object.entries(restoreHistory)) { + const monthly: { + [monthKey: string]: { daysWithRestore: Set; totalDays: number }; + } = {}; + for (const m of monthsToInclude) { + const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`; + monthly[monthKey] = { + daysWithRestore: new Set(), + totalDays: new Date(currentYear, m + 1, 0).getDate() + }; + } + for (const h of history) { + const d = new Date(h.restore_date); + const y = d.getFullYear(); + const m = d.getMonth(); + if (y !== currentYear || !monthsToInclude.includes(m)) continue; + const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`; + const dayKey = d.toISOString().slice(0, 10); + monthly[monthKey]?.daysWithRestore.add(dayKey); + } + effectivenessByDb[dbName] = Object.entries(monthly).map(([month, data]) => { + const eff = + data.totalDays === 0 ? 0 : (data.daysWithRestore.size / data.totalDays) * 100; + return { month, effectiveness: Number(eff.toFixed(1)) }; + }); + } + return effectivenessByDb; +} + +export type ServerDatabaseInfo = { + name: string; + size_mb: number; + last_restore_date: Date | null; + state_desc: string; + create_date: Date | null; +}; + +/** + * Lista las bases de USUARIO de un servidor (pool a master), con tamaño y último restore. + * Excluye las de sistema (database_id <= 4: master/tempdb/model/msdb). + */ +export async function listUserDatabasesOnServer( + pool: sql.ConnectionPool +): Promise { + const result = await pool.request().query(` + SELECT + d.name AS name, + CAST(( + SELECT SUM(mf.size) * 8.0 / 1024 + FROM sys.master_files mf + WHERE mf.database_id = d.database_id + ) AS DECIMAL(18,2)) AS size_mb, + ( + SELECT MAX(rh.restore_date) + FROM msdb.dbo.restorehistory rh + WHERE rh.destination_database_name = d.name + ) AS last_restore_date, + d.state_desc, + d.create_date + FROM sys.databases d + WHERE d.database_id > 4 + ORDER BY d.name + `); + return (result.recordset as any[]).map((r) => ({ + name: String(r.name), + size_mb: Number(r.size_mb) || 0, + last_restore_date: r.last_restore_date ?? null, + state_desc: String(r.state_desc ?? ''), + create_date: r.create_date ?? null + })); +} + +/** + * Borra una base en el servidor del pool. Fuerza SINGLE_USER (WITH ROLLBACK IMMEDIATE, cierra + * conexiones activas) y luego DROP DATABASE. + * + * Un identificador T-SQL NO se puede parametrizar, así que hay doble defensa contra inyección: + * (1) `databaseName` debe estar en `allowedNames` (lista real leída del propio servidor) y + * (2) dentro del batch se escapa con QUOTENAME. La validación ocurre ANTES de tocar el pool. + */ +export async function dropDatabaseOnServer( + pool: sql.ConnectionPool, + databaseName: string, + allowedNames: Set +): Promise { + const name = String(databaseName ?? '').trim(); + if (!name || !allowedNames.has(name)) { + throw new Error(`Base no permitida para borrado: "${name}".`); + } + const req = pool.request(); + req.input('dbname', sql.NVarChar(128), name); + await req.query(` + IF DB_ID(@dbname) IS NULL + THROW 50000, 'La base ya no existe en este servidor.', 1; + DECLARE @stmt NVARCHAR(MAX) = + N'ALTER DATABASE ' + QUOTENAME(@dbname) + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;' + + N'DROP DATABASE ' + QUOTENAME(@dbname) + N';'; + EXEC sys.sp_executesql @stmt; + `); +} + +/** + * Respalda una base a `destPath` en el servidor del pool (COPY_ONLY para no romper la cadena de + * respaldos del cliente). El identificador NO se puede parametrizar: se valida contra `allowedNames` + * (lista real del servidor) y se escapa con QUOTENAME; la ruta destino SÍ va como parámetro. + */ +export async function backupDatabaseOnServer( + pool: sql.ConnectionPool, + databaseName: string, + allowedNames: Set, + destPath: string +): Promise { + const name = String(databaseName ?? '').trim(); + if (!name || !allowedNames.has(name)) { + throw new Error(`Base no permitida para respaldo: "${name}".`); + } + const dest = String(destPath ?? '').trim(); + if (!dest) throw new Error('Ruta de respaldo vacía.'); + const req = pool.request(); + req.input('dbname', sql.NVarChar(128), name); + req.input('dest', sql.NVarChar(4000), dest); + await req.query(` + IF DB_ID(@dbname) IS NULL + THROW 50000, 'La base no existe en este servidor.', 1; + DECLARE @stmt NVARCHAR(MAX) = + N'BACKUP DATABASE ' + QUOTENAME(@dbname) + + N' TO DISK = @p_dest WITH COPY_ONLY, INIT, FORMAT, NAME = N''dedup-move'';'; + EXEC sys.sp_executesql @stmt, N'@p_dest NVARCHAR(4000)', @p_dest = @dest; + `); +} + +export type SqlDashboardBundle = { + databaseRows: any[]; + summaryMain: { total_databases: number; total_size_gb: number }; + alertsData: any[]; + restoreHistory: Record; + effectivenessByDb: Record; +}; + +/** + * Recorre nodos activos del catálogo y consulta SQL Server en server_name (BD = database_name). + */ +export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promise { + const databaseRows: any[] = []; + const alertsData: any[] = []; + const restoreHistory: Record = {}; + let totalSizeGb = 0; + + // Para cada nodo las 3 consultas (métricas, alerta, historial) son independientes y se lanzan + // en paralelo. Los nodos se procesan con concurrencia acotada para no saturar los pools. + // Antes el patrón era N×3 round-trips en serie contra SQL Server (decenas de segundos). + const NODE_CONCURRENCY = 8; + const perNode = await mapWithConcurrency(nodes, NODE_CONCURRENCY, async (node) => { + const dbn = String(node.BDName || '').trim(); + if (!dbn) return null; + const pwd = resolveNodeSqlPassword(node.sql_password); + try { + const pool = await getMssqlPoolMaster(String(node.ServerName || '').trim(), pwd); + const [row, alertRow, hist] = await Promise.all([ + queryDatabaseMetricsOnServer(pool, dbn), + queryDatabaseAlertRow(pool, dbn), + queryRestoreHistoryForDatabase(pool, dbn) + ]); + // Conexión al servidor OK pero la base no existe en él: se marca como alerta + // "no encontrada" (sin métricas ni días sin sincronizar), no se descarta el nodo. + if (!row) return { node, dbn, row: null, notFound: true, alertRow: null, hist: [] }; + return { node, dbn, row, alertRow, hist }; + } catch (e) { + console.error( + `SQL Server nodo id=${node.ID} server=${node.ServerName} db=${dbn}:`, + e + ); + return null; + } + }); + + // Agregación secuencial sobre resultados ya resueltos: evita condiciones de carrera sobre + // las estructuras compartidas y conserva el orden original de los nodos. + for (const res of perNode) { + if (!res) continue; + const { node, dbn, row, alertRow, hist } = res; + + // Base no encontrada en el servidor: solo alerta (sin fila de métricas ni tamaño). + // last_restore_date en null => la UI muestra los días sin sincronizar como N/D. + if ((res as any).notFound) { + alertsData.push({ + visible_name: dbn, + last_restore_date: null, + not_found: true + }); + continue; + } + + const visible = String(row.visible_name ?? dbn); + const keyLower = visible.toLowerCase(); + + databaseRows.push({ + ...row, + visible_name: visible, + original_name: row.original_name ?? visible, + NodoSubNodo: node.NodoSubNodo, + client_name: node.Nombre, + BDName: dbn, + _node_id: node.ID, + _server: adjustMssqlServerForDocker(String(node.ServerName || '').trim()) + }); + + totalSizeGb += Number(row.total_size_gb) || 0; + + if (alertRow) alertsData.push(alertRow); + + if (!restoreHistory[keyLower]) restoreHistory[keyLower] = []; + for (const h of hist) { + restoreHistory[keyLower].push(h); + } + } + + const summaryMain = { + total_databases: databaseRows.length, + total_size_gb: Math.round(totalSizeGb * 100) / 100 + }; + + const effectivenessByDb = computeEffectivenessFromHistory(restoreHistory); + + return { databaseRows, summaryMain, alertsData, restoreHistory, effectivenessByDb }; +} diff --git a/src/lib/server/notification-emails-pg.ts b/src/lib/server/notification-emails-pg.ts new file mode 100644 index 0000000..ca73cfc --- /dev/null +++ b/src/lib/server/notification-emails-pg.ts @@ -0,0 +1,85 @@ +/** + * Correos de notificación adicionales en PostgreSQL (esquema a24c; DDL aprovisionado por la app a24c). + * Dos catálogos paralelos, ambos ligados al nodo por `node_subnode_key`: + * - additional_emails: destinatarios extra para alertas de respaldos / actividad. + * - authority_notification_emails: destinatarios para el ingreso de la autoridad al portal. + * a24c los lee en vivo al notificar (comparación case-insensitive + trim), por eso aquí se usa el + * mismo criterio `lower(btrim(...))` para evitar duplicados que a24c terminaría deduplicando. + */ +import { pgPool } from './db'; + +function schemaName(): string { + const s = 'a24c'; + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) return 'a24c'; + return s; +} + +function qAdditional(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."additional_emails"`; +} + +function qAuthority(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."authority_notification_emails"`; +} + +const ROW_EMAIL = ` + id AS "ID", + node_subnode_key AS "NodoSubNodo", + email AS "Correo" +`; + +export async function listAdditionalEmails(): Promise { + const r = await pgPool.query(`SELECT ${ROW_EMAIL} FROM ${qAdditional()} ORDER BY id`); + return r.rows; +} + +export async function listAuthorityEmails(): Promise { + const r = await pgPool.query(`SELECT ${ROW_EMAIL} FROM ${qAuthority()} ORDER BY id`); + return r.rows; +} + +async function emailExists(table: string, nodeSubnodeKey: string, email: string): Promise { + const sql = ` + SELECT 1 + FROM ${table} + WHERE LOWER(BTRIM(node_subnode_key)) = LOWER(BTRIM($1::text)) + AND LOWER(BTRIM(email)) = LOWER(BTRIM($2::text)) + LIMIT 1 + `; + const r = await pgPool.query(sql, [nodeSubnodeKey, email]); + return (r.rowCount ?? 0) > 0; +} + +export async function additionalEmailExists(nodeSubnodeKey: string, email: string): Promise { + return emailExists(qAdditional(), nodeSubnodeKey, email); +} + +export async function authorityEmailExists(nodeSubnodeKey: string, email: string): Promise { + return emailExists(qAuthority(), nodeSubnodeKey, email); +} + +export async function insertAdditionalEmail(nodeSubnodeKey: string, email: string): Promise { + const r = await pgPool.query( + `INSERT INTO ${qAdditional()} (node_subnode_key, email) VALUES ($1, $2) RETURNING ${ROW_EMAIL}`, + [nodeSubnodeKey, email] + ); + return r.rows[0]; +} + +export async function insertAuthorityEmail(nodeSubnodeKey: string, email: string): Promise { + const r = await pgPool.query( + `INSERT INTO ${qAuthority()} (node_subnode_key, email) VALUES ($1, $2) RETURNING ${ROW_EMAIL}`, + [nodeSubnodeKey, email] + ); + return r.rows[0]; +} + +export async function deleteAdditionalEmail(id: number): Promise { + await pgPool.query(`DELETE FROM ${qAdditional()} WHERE id = $1`, [id]); +} + +export async function deleteAuthorityEmail(id: number): Promise { + await pgPool.query(`DELETE FROM ${qAuthority()} WHERE id = $1`, [id]); +} diff --git a/src/lib/server/report-excel.ts b/src/lib/server/report-excel.ts new file mode 100644 index 0000000..996db31 --- /dev/null +++ b/src/lib/server/report-excel.ts @@ -0,0 +1,50 @@ +/** + * Utilidades compartidas para los reportes en Excel del panel: + * - Guard de sesión admin (los reportes administrativos son confidenciales). + * - Estilos de encabezado y filas (cebra) reutilizados por todas las hojas. + */ +import type { Cookies } from '@sveltejs/kit'; +import type ExcelJS from 'exceljs'; +import { verifyToken } from './auth'; +import { getUserById } from './users'; +import type { Usuario } from './auth'; + +/** + * Valida la cookie de sesión y exige rol admin. Devuelve el usuario o `null` + * (sin lanzar) para que el endpoint responda 401/403 con JSON, no un redirect. + */ +export async function getAdminFromCookies(cookies: Cookies): Promise { + const token = cookies.get('session_token'); + if (!token) return null; + const session = verifyToken(token); + if (!session) return null; + const user = await getUserById(session.userId); + if (!user || !user.activo || !user.es_admin) return null; + return user; +} + +/** Aplica el estilo de encabezado oscuro a la primera fila de una hoja. */ +export function styleHeader(row: ExcelJS.Row): void { + row.eachCell((cell) => { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E293B' } }; + cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 }; + cell.alignment = { vertical: 'middle', horizontal: 'center' }; + cell.border = { bottom: { style: 'thin', color: { argb: 'FF94A3B8' } } }; + }); + row.height = 20; +} + +/** Aplica el cebrado (filas alternas) y borde inferior tenue a una fila de datos. */ +export function styleDataRow(row: ExcelJS.Row, index: number): void { + const fill: ExcelJS.FillPattern = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: index % 2 === 0 ? 'FFF8FAFC' : 'FFFFFFFF' } + }; + row.eachCell((cell) => { + cell.fill = fill; + cell.alignment = { vertical: 'middle' }; + cell.border = { bottom: { style: 'hair', color: { argb: 'FFE2E8F0' } } }; + }); + row.height = 16; +} diff --git a/src/lib/server/restore-fetch.ts b/src/lib/server/restore-fetch.ts new file mode 100644 index 0000000..a95a3d3 --- /dev/null +++ b/src/lib/server/restore-fetch.ts @@ -0,0 +1,103 @@ +/** + * Descarga de respaldos por restaurador, con ruta RELATIVA y SIN rutas fijas. + * + * La base de cada restaurador se DERIVA al vuelo de su Entrada reportada + * (`cloudrestore_status.input_folder`): hermana `Procesados`/`Fallados` (layout por defecto del + * install). El cliente solo envía `target` + `kind` + `relPath`; el servidor arma `base + relPath` + * y lo sirve leyendo la carpeta por filesystem (local o montada como share). CloudRestoreAS no + * reporta ni configura nada nuevo. + */ +import fs from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; +import { deriveSiblingFolder } from './backup-files'; +import { getRestoreTargetForDownload, type RestoreTargetDownload } from './controldesk-pg'; + +export type BackupKind = 'procesados' | 'fallados'; + +export class BackupDownloadError extends Error { + constructor( + public status: number, + message: string + ) { + super(message); + this.name = 'BackupDownloadError'; + } +} + +function baseSeparator(p: string): '\\' | '/' { + return p.includes('\\') ? '\\' : '/'; +} + +/** Carpeta base (procesados/fallados) del restaurador, SIEMPRE derivada de su Entrada. */ +function resolveBaseFolder(t: RestoreTargetDownload, kind: BackupKind): string | null { + const input = t.input_folder; + if (!input) return null; + return deriveSiblingFolder(input, kind === 'procesados' ? 'Procesados' : 'Fallados'); +} + +/** Valida la ruta relativa: sin '..', sin absoluto, sin componentes vacíos. */ +export function sanitizeRelPath(relPath: string): string | null { + const rel = String(relPath ?? '') + .replace(/\\/g, '/') + .trim(); + if (!rel) return null; + if (rel.startsWith('/') || /^[a-zA-Z]:/.test(rel)) return null; // absoluto + const parts = rel.split('/'); + if (parts.some((p) => p === '' || p === '.' || p === '..')) return null; + return parts.join('/'); +} + +function localJoin(base: string, rel: string): string { + const sep = baseSeparator(base); + const b = base.replace(/[\\/]+$/, ''); + const relNative = rel.split('/').join(sep); + return `${b}${sep}${relNative}`; +} + +export interface BackupDownload { + body: ReadableStream; + size: number | null; + filename: string; + cleanup: () => void; +} + +/** + * Abre el stream de descarga de un respaldo desde la carpeta de procesados/fallados del + * restaurador (por filesystem). Lanza BackupDownloadError con el código HTTP apropiado. + */ +export async function openBackupDownload( + targetId: number, + kind: BackupKind, + relPathRaw: string +): Promise { + const rel = sanitizeRelPath(relPathRaw); + if (!rel) throw new BackupDownloadError(400, 'Ruta de archivo inválida'); + + const target = await getRestoreTargetForDownload(targetId); + if (!target) throw new BackupDownloadError(404, 'Restaurador no encontrado'); + + const base = resolveBaseFolder(target, kind); + if (!base) throw new BackupDownloadError(409, 'El restaurador aún no reportó su carpeta de entrada'); + + const filename = rel.split('/').pop() as string; + const localPath = localJoin(base, rel); + + let st; + try { + st = await fs.stat(localPath); + } catch { + throw new BackupDownloadError(404, 'Archivo de respaldo no encontrado o carpeta inaccesible'); + } + if (!st.isFile()) { + throw new BackupDownloadError(404, 'La ruta indicada no es un archivo'); + } + + const node = createReadStream(localPath, { highWaterMark: 1024 * 1024 }); + return { + body: Readable.toWeb(node) as unknown as ReadableStream, + size: st.size, + filename, + cleanup: () => node.destroy() + }; +} diff --git a/src/lib/server/restore-inventory.ts b/src/lib/server/restore-inventory.ts new file mode 100644 index 0000000..e9a2a07 --- /dev/null +++ b/src/lib/server/restore-inventory.ts @@ -0,0 +1,131 @@ +/** + * Escaneo del inventario de restauraciones LEYENDO las carpetas que usa CloudRestoreAS. + * + * Por cada restaurador toma la Entrada (`input_folder`) que ya reporta en `cloudrestore_status`, + * DERIVA sus hermanas `Procesados`/`Fallados` (deriveSiblingFolder) y las LEE por filesystem + * (listBackupFiles). Mapea cada archivo a su nodo (matchNodeRowFromBackupStem) y arma: + * - restaurados (de Procesados) → además hace upsert en `node_last_restore` (cache durable que + * preserva el "último respaldo por nodo" aunque se reasigne el restaurador o una carpeta quede + * temporalmente ilegible). + * - fallidos (de Fallados) → enriquecidos con `error_message` de `restore_job_logs` por filename. + * + * No requiere que CloudRestoreAS reporte nada nuevo: solo usa el `input_folder` ya reportado. + */ +import path from 'node:path'; +import { deriveSiblingFolder, listBackupFiles } from './backup-files'; +import { + listRestoreTargets, + listCloudRestoreStatuses, + listNodesForAssignment, + matchNodeRowFromBackupStem, + upsertNodeLastRestore, + listNodeLastRestore, + listFailedRestoreJobLogs, + type NodeLastRestoreRow +} from './controldesk-pg'; + +export interface FailedItem { + id: string; // clave estable para la UI: `${restore_target_id}:${rel_path}` + restore_target_id: number | null; + server_name: string | null; + node_key: string | null; + client_name: string | null; + db_name: string | null; + filename: string; + rel_path: string; + size_bytes: number; + error_message: string | null; + restored_at: Date; +} + +export interface RestoreInventory { + restored: NodeLastRestoreRow[]; + failed: FailedItem[]; +} + +const SCAN_OPTS = { maxDepth: 3, maxFiles: 5000 } as const; + +/** Escanea las carpetas de todos los restauradores y devuelve restaurados + fallidos. */ +export async function scanRestoreInventory(): Promise { + const [targets, statuses, nodes] = await Promise.all([ + listRestoreTargets(), + listCloudRestoreStatuses(), + listNodesForAssignment() + ]); + + // instance_key (case-insensitive) → input_folder reportado. + const inputByInstance = new Map(); + for (const s of statuses) { + if (s.input_folder) inputByInstance.set(s.instance_key.trim().toLowerCase(), s.input_folder); + } + + const failed: FailedItem[] = []; + + for (const t of targets) { + const input = inputByInstance.get(String(t.name).trim().toLowerCase()); + if (!input) continue; // este restaurador aún no reportó su Entrada + + // Procesados → upsert del último respaldo por nodo. + try { + const procesados = deriveSiblingFolder(input, 'Procesados'); + const { files } = await listBackupFiles(procesados, SCAN_OPTS); + for (const f of files) { + const match = matchNodeRowFromBackupStem(path.parse(f.name).name, nodes); + if (match && match.ID != null) { + await upsertNodeLastRestore({ + databaseNodeId: Number(match.ID), + restoreTargetId: t.id, + dbName: (match.BDName as string) ?? null, + nodeKey: (match.NodoSubNodo as string) ?? null, + filename: f.name, + relPath: f.relPath, + sizeBytes: f.sizeBytes, + restoredAt: new Date(f.mtimeMs) + }); + } + } + } catch { + /* Carpeta Procesados ilegible: se conserva lo cacheado en node_last_restore. */ + } + + // Fallados → lista viva (con enriquecimiento de error más abajo). + try { + const fallados = deriveSiblingFolder(input, 'Fallados'); + const { files } = await listBackupFiles(fallados, SCAN_OPTS); + for (const f of files) { + const match = matchNodeRowFromBackupStem(path.parse(f.name).name, nodes); + failed.push({ + id: `${t.id}:${f.relPath}`, + restore_target_id: t.id, + server_name: t.name, + node_key: (match?.NodoSubNodo as string) ?? null, + client_name: (match?.Nombre as string) ?? null, + db_name: (match?.BDName as string) ?? null, + filename: f.name, + rel_path: f.relPath, + size_bytes: f.sizeBytes, + error_message: null, + restored_at: new Date(f.mtimeMs) + }); + } + } catch { + /* Carpeta Fallados ilegible: se omite este restaurador en fallidos. */ + } + } + + // Enriquecer fallidos con el error reportado (restore_job_logs) por filename. + try { + const logs = await listFailedRestoreJobLogs(500); + const errByFile = new Map(); + for (const l of logs) errByFile.set(l.filename.trim().toLowerCase(), l.error_message); + for (const f of failed) { + f.error_message = errByFile.get(f.filename.trim().toLowerCase()) ?? null; + } + } catch { + /* sin bitácora: los fallidos se listan sin mensaje de error */ + } + + // Restaurados = estado durable por nodo (recién refrescado por el escaneo). + const restored = await listNodeLastRestore(); + return { restored, failed }; +} diff --git a/src/lib/server/service-auth.ts b/src/lib/server/service-auth.ts new file mode 100644 index 0000000..ca91933 --- /dev/null +++ b/src/lib/server/service-auth.ts @@ -0,0 +1,40 @@ +/** + * Autenticación servicio-a-servicio por token Bearer para los endpoints que + * consume CloudRestoreAS. Separado del JWT de usuarios (cookie) porque el cliente + * es una máquina, no un navegador. + * + * NOTA DE SEGURIDAD: estos endpoints entregan credenciales SQL. En producción + * deben servirse solo sobre transporte cifrado (TLS/SSH/VPN) — ver plan, G5. + */ +import { timingSafeEqual } from 'node:crypto'; +import { env } from '$env/dynamic/private'; + +/** + * Valida el header Authorization: Bearer contra CLOUDRESTORE_API_TOKEN. + * Devuelve null si es válido, o el código HTTP a responder (401/500) si no. + * Comparación en tiempo constante para no filtrar el token por timing. + */ +export function checkServiceToken(request: Request): { ok: true } | { ok: false; status: 401 | 500 } { + const expected = env.CLOUDRESTORE_API_TOKEN; + if (!expected || !expected.trim()) { + // Sin token configurado no se puede autenticar de forma segura: se rechaza. + return { ok: false, status: 500 }; + } + + const header = request.headers.get('authorization') ?? ''; + const match = header.match(/^Bearer\s+(.+)$/i); + if (!match) { + return { ok: false, status: 401 }; + } + const provided = match[1].trim(); + + const expectedBuf = Buffer.from(expected.trim(), 'utf8'); + const providedBuf = Buffer.from(provided, 'utf8'); + if (expectedBuf.length !== providedBuf.length) { + return { ok: false, status: 401 }; + } + if (!timingSafeEqual(expectedBuf, providedBuf)) { + return { ok: false, status: 401 }; + } + return { ok: true }; +} diff --git a/src/lib/server/sftp-transfer.test.ts b/src/lib/server/sftp-transfer.test.ts new file mode 100644 index 0000000..e6b810c --- /dev/null +++ b/src/lib/server/sftp-transfer.test.ts @@ -0,0 +1,31 @@ +/** + * Pruebas de las funciones puras de armado de rutas para la transferencia SFTP. La I/O real + * (SFTP/zip) no se prueba aquí; se cubre la construcción de rutas que es donde vive el riesgo + * de separadores Windows/POSIX. + */ +import { describe, it, expect } from 'vitest'; +import { joinRemotePath, toSftpPath } from './sftp-transfer'; + +describe('joinRemotePath', () => { + it('usa backslash cuando la carpeta es estilo Windows', () => { + expect(joinRemotePath('D:\\SQLDATA', 'NODO001.bak')).toBe('D:\\SQLDATA\\NODO001.bak'); + }); + + it('respeta separadores finales duplicados', () => { + expect(joinRemotePath('D:\\SQLDATA\\\\', 'a.zip')).toBe('D:\\SQLDATA\\a.zip'); + }); + + it('usa slash cuando la carpeta es POSIX', () => { + expect(joinRemotePath('/var/inbox/', 'a.zip')).toBe('/var/inbox/a.zip'); + }); +}); + +describe('toSftpPath', () => { + it('convierte backslashes de Windows a slashes para OpenSSH SFTP', () => { + expect(toSftpPath('D:\\SQLDATA\\NODO001.bak')).toBe('D:/SQLDATA/NODO001.bak'); + }); + + it('deja intactas las rutas POSIX', () => { + expect(toSftpPath('/var/inbox/a.zip')).toBe('/var/inbox/a.zip'); + }); +}); diff --git a/src/lib/server/sftp-transfer.ts b/src/lib/server/sftp-transfer.ts new file mode 100644 index 0000000..bde5a40 --- /dev/null +++ b/src/lib/server/sftp-transfer.ts @@ -0,0 +1,103 @@ +/** + * Transferencia de respaldos entre servidores por SFTP, usando las credenciales SSH que ya guarda + * cada restore_target. Se usa para mover una base del servidor viejo al nuevo: bajar el .bak del + * viejo, comprimirlo y subir el .zip a la carpeta de Entrada del nuevo (donde CloudRestoreAS lo + * restaura). Las funciones de armado de rutas son puras (probadas aparte). + */ +import { createWriteStream } from 'node:fs'; +import Client from 'ssh2-sftp-client'; +import archiver from 'archiver'; + +export type SftpCreds = { + host: string; + port: number; + username: string; + password: string; +}; + +const SFTP_READY_TIMEOUT_MS = 20000; + +/** Une carpeta + nombre respetando el separador dominante (Windows `\` o POSIX `/`). */ +export function joinRemotePath(folder: string, name: string): string { + const raw = String(folder ?? '').trim(); + const sep = raw.includes('\\') ? '\\' : '/'; + const trimmed = raw.replace(/[\\/]+$/, ''); + return `${trimmed}${sep}${name}`; +} + +/** Convierte una ruta Windows (`D:\x\y`) a la forma con `/` que acepta OpenSSH SFTP. */ +export function toSftpPath(p: string): string { + return String(p ?? '').replace(/\\/g, '/'); +} + +/** Abre una sesión SFTP, ejecuta `fn` y siempre cierra la conexión. */ +export async function withSftp(creds: SftpCreds, fn: (sftp: Client) => Promise): Promise { + const client = new Client(); + try { + await client.connect({ + host: creds.host, + port: creds.port || 22, + username: creds.username, + password: creds.password, + readyTimeout: SFTP_READY_TIMEOUT_MS + }); + return await fn(client); + } finally { + try { + await client.end(); + } catch { + /* ignore */ + } + } +} + +/** Baja un archivo remoto a una ruta local. */ +export async function sftpDownload(creds: SftpCreds, remotePath: string, localPath: string): Promise { + await withSftp(creds, (sftp) => sftp.fastGet(toSftpPath(remotePath), localPath)); +} + +/** + * Sube un archivo de forma atómica: primero a `.part` y luego rename a ``, para + * que CloudRestoreAS nunca vea un archivo a medio escribir en su carpeta de Entrada. + * + * El rename usa `posix-rename@openssh.com` (posixRename), que SOBRESCRIBE el destino: el + * SSH_FXP_RENAME estándar de OpenSSH falla si el `.zip` ya existe (de un intento previo o de una + * copia sin consumir). Si el servidor no tuviera la extensión, se cae a borrar-y-renombrar. + */ +export async function sftpUploadAtomic(creds: SftpCreds, localPath: string, remotePath: string): Promise { + const finalPath = toSftpPath(remotePath); + const tmpPath = `${finalPath}.part`; + await withSftp(creds, async (sftp) => { + await sftp.fastPut(localPath, tmpPath); + try { + await sftp.posixRename(tmpPath, finalPath); + } catch { + // Servidor sin posix-rename: borrar el destino (si existe) y renombrar clásico. + try { + await sftp.delete(finalPath); + } catch { + /* no existía */ + } + await sftp.rename(tmpPath, finalPath); + } + }); +} + +/** Borra un archivo remoto (limpieza del .bak temporal en el servidor viejo). */ +export async function sftpDelete(creds: SftpCreds, remotePath: string): Promise { + await withSftp(creds, (sftp) => sftp.delete(toSftpPath(remotePath))); +} + +/** Comprime un único archivo en un .zip con el nombre de entrada indicado. */ +export async function zipSingleFile(srcPath: string, entryName: string, destZipPath: string): Promise { + await new Promise((resolve, reject) => { + const output = createWriteStream(destZipPath); + const archive = archiver('zip', { zlib: { level: 6 } }); + output.on('close', () => resolve()); + output.on('error', reject); + archive.on('error', reject); + archive.pipe(output); + archive.file(srcPath, { name: entryName }); + archive.finalize(); + }); +} diff --git a/src/lib/server/users.ts b/src/lib/server/users.ts index 1872b3b..5062b1f 100644 --- a/src/lib/server/users.ts +++ b/src/lib/server/users.ts @@ -1,15 +1,48 @@ import { pgPool } from './db'; import { hashPassword, verifyPassword, type Usuario } from './auth'; -import type { PoolClient } from 'pg'; +import { + tableDashboardUsers, + tableDashboardUserDbPerms +} from './dashboard-pg'; + +/** Mapea fila SQL (alias español opcional) al tipo UI. */ +function rowToUsuario(row: Record): Usuario { + return { + id: row.id as number, + username: row.username as string, + email: (row.email as string) ?? '', + nombre_completo: (row.nombre_completo ?? row.full_name ?? '') as string, + activo: (row.activo ?? row.is_active) as boolean, + es_admin: (row.es_admin ?? row.is_admin) as boolean + }; +} + +/** Solo para login (incluye hash). */ +const SELECT_USER_LOGIN = ` + id, username, email, password_hash, + full_name AS nombre_completo, + is_active AS activo, + is_admin AS es_admin +`; + +const SELECT_USER_PUBLIC = ` + id, username, email, + full_name AS nombre_completo, + is_active AS activo, + is_admin AS es_admin +`; /** * Autenticar usuario por username y password */ export async function authenticateUser(username: string, password: string): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const result = await client.query( - 'SELECT id, username, email, password_hash, nombre_completo, activo, es_admin FROM usuarios WHERE username = $1 AND activo = true', + `SELECT ${SELECT_USER_LOGIN} + FROM ${t} + WHERE username = $1 AND is_active = true`, [username] ); @@ -24,20 +57,11 @@ export async function authenticateUser(username: string, password: string): Prom return null; } - // Actualizar último acceso - await client.query( - 'UPDATE usuarios SET ultimo_acceso = CURRENT_TIMESTAMP WHERE id = $1', - [user.id] - ); + await client.query(`UPDATE ${t} SET last_access_at = CURRENT_TIMESTAMP WHERE id = $1`, [ + user.id + ]); - return { - id: user.id, - username: user.username, - email: user.email, - nombre_completo: user.nombre_completo, - activo: user.activo, - es_admin: user.es_admin - }; + return rowToUsuario(user); } finally { client.release(); } @@ -48,9 +72,10 @@ export async function authenticateUser(username: string, password: string): Prom */ export async function getUserById(userId: number): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const result = await client.query( - 'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios WHERE id = $1', + `SELECT ${SELECT_USER_PUBLIC} FROM ${t} WHERE id = $1`, [userId] ); @@ -58,7 +83,7 @@ export async function getUserById(userId: number): Promise { return null; } - return result.rows[0]; + return rowToUsuario(result.rows[0]); } finally { client.release(); } @@ -75,17 +100,24 @@ export async function createUser(data: { es_admin?: boolean; }): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const passwordHash = await hashPassword(data.password); const result = await client.query( - `INSERT INTO usuarios (username, email, password_hash, nombre_completo, es_admin) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, username, email, nombre_completo, activo, es_admin`, - [data.username, data.email, passwordHash, data.nombre_completo || '', data.es_admin || false] + `INSERT INTO ${t} (username, email, password_hash, full_name, is_admin, is_active) + VALUES ($1, $2, $3, $4, $5, true) + RETURNING ${SELECT_USER_PUBLIC}`, + [ + data.username, + data.email, + passwordHash, + data.nombre_completo || '', + data.es_admin || false + ] ); - return result.rows[0]; + return rowToUsuario(result.rows[0]); } finally { client.release(); } @@ -105,9 +137,10 @@ export async function updateUser( } ): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const updates: string[] = []; - const values: any[] = []; + const values: unknown[] = []; let paramIndex = 1; if (data.email !== undefined) { @@ -116,17 +149,17 @@ export async function updateUser( } if (data.nombre_completo !== undefined) { - updates.push(`nombre_completo = $${paramIndex++}`); + updates.push(`full_name = $${paramIndex++}`); values.push(data.nombre_completo); } if (data.activo !== undefined) { - updates.push(`activo = $${paramIndex++}`); + updates.push(`is_active = $${paramIndex++}`); values.push(data.activo); } if (data.es_admin !== undefined) { - updates.push(`es_admin = $${paramIndex++}`); + updates.push(`is_admin = $${paramIndex++}`); values.push(data.es_admin); } @@ -143,14 +176,14 @@ export async function updateUser( values.push(userId); const result = await client.query( - `UPDATE usuarios + `UPDATE ${t} SET ${updates.join(', ')} WHERE id = $${paramIndex} - RETURNING id, username, email, nombre_completo, activo, es_admin`, + RETURNING ${SELECT_USER_PUBLIC}`, values ); - return result.rows[0] || null; + return result.rows[0] ? rowToUsuario(result.rows[0]) : null; } finally { client.release(); } @@ -161,8 +194,9 @@ export async function updateUser( */ export async function deleteUser(userId: number): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { - const result = await client.query('DELETE FROM usuarios WHERE id = $1', [userId]); + const result = await client.query(`DELETE FROM ${t} WHERE id = $1`, [userId]); return result.rowCount ? result.rowCount > 0 : false; } finally { client.release(); @@ -174,11 +208,12 @@ export async function deleteUser(userId: number): Promise { */ export async function listUsers(): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const result = await client.query( - 'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios ORDER BY id DESC' + `SELECT ${SELECT_USER_PUBLIC} FROM ${t} ORDER BY id DESC` ); - return result.rows; + return result.rows.map(rowToUsuario); } finally { client.release(); } @@ -189,12 +224,13 @@ export async function listUsers(): Promise { */ export async function getUserDatabasePermissions(userId: number): Promise { const client = await pgPool.connect(); + const t = tableDashboardUserDbPerms(); try { const result = await client.query( - 'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true', + `SELECT database_name FROM ${t} WHERE dashboard_user_id = $1 AND can_view = true`, [userId] ); - return result.rows.map(row => row.base_datos_nombre); + return result.rows.map((row) => row.database_name as string); } finally { client.release(); } @@ -213,15 +249,17 @@ export async function assignDatabaseToUser( } = {} ): Promise { const client = await pgPool.connect(); + const t = tableDashboardUserDbPerms(); try { await client.query( - `INSERT INTO usuario_base_datos (usuario_id, base_datos_nombre, puede_ver, puede_descargar_backup, puede_restaurar) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (usuario_id, base_datos_nombre) + `INSERT INTO ${t} ( + dashboard_user_id, database_name, can_view, can_download_backup, can_restore + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (dashboard_user_id, database_name) DO UPDATE SET - puede_ver = $3, - puede_descargar_backup = $4, - puede_restaurar = $5`, + can_view = EXCLUDED.can_view, + can_download_backup = EXCLUDED.can_download_backup, + can_restore = EXCLUDED.can_restore`, [ userId, baseDatosNombre, @@ -240,9 +278,10 @@ export async function assignDatabaseToUser( */ export async function removeDatabaseFromUser(userId: number, baseDatosNombre: string): Promise { const client = await pgPool.connect(); + const t = tableDashboardUserDbPerms(); try { await client.query( - 'DELETE FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2', + `DELETE FROM ${t} WHERE dashboard_user_id = $1 AND database_name = $2`, [userId, baseDatosNombre] ); } finally { @@ -255,24 +294,21 @@ export async function removeDatabaseFromUser(userId: number, baseDatosNombre: st */ export async function userCanViewDatabase(userId: number, baseDatosNombre: string): Promise { const client = await pgPool.connect(); + const tu = tableDashboardUsers(); + const tp = tableDashboardUserDbPerms(); try { - // Los admins pueden ver todo - const userResult = await client.query( - 'SELECT es_admin FROM usuarios WHERE id = $1', - [userId] - ); + const userResult = await client.query(`SELECT is_admin FROM ${tu} WHERE id = $1`, [userId]); - if (userResult.rows.length > 0 && userResult.rows[0].es_admin) { + if (userResult.rows.length > 0 && userResult.rows[0].is_admin) { return true; } - // Verificar permiso específico const permResult = await client.query( - 'SELECT puede_ver FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2', + `SELECT can_view FROM ${tp} WHERE dashboard_user_id = $1 AND database_name = $2`, [userId, baseDatosNombre] ); - return permResult.rows.length > 0 && permResult.rows[0].puede_ver; + return permResult.rows.length > 0 && permResult.rows[0].can_view; } finally { client.release(); } @@ -286,26 +322,25 @@ export async function filterDatabasesByUserPermissions { const client = await pgPool.connect(); + const tu = tableDashboardUsers(); + const tp = tableDashboardUserDbPerms(); try { - // Los admins ven todo - const userResult = await client.query( - 'SELECT es_admin FROM usuarios WHERE id = $1', - [userId] - ); + const userResult = await client.query(`SELECT is_admin FROM ${tu} WHERE id = $1`, [userId]); - if (userResult.rows.length > 0 && userResult.rows[0].es_admin) { + if (userResult.rows.length > 0 && userResult.rows[0].is_admin) { return databases; } - // Obtener bases de datos permitidas const permResult = await client.query( - 'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true', + `SELECT database_name FROM ${tp} WHERE dashboard_user_id = $1 AND can_view = true`, [userId] ); - const allowedDatabases = new Set(permResult.rows.map(row => row.base_datos_nombre.toLowerCase())); + const allowedDatabases = new Set( + permResult.rows.map((row) => String(row.database_name).toLowerCase()) + ); - return databases.filter(db => allowedDatabases.has(db.visible_name.toLowerCase())); + return databases.filter((db) => allowedDatabases.has(db.visible_name.toLowerCase())); } finally { client.release(); } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index c4139cd..79c3400 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,634 +1,795 @@ -import { db } from '$lib/server/db'; -import { env } from '$env/dynamic/private'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad, Actions } from './$types'; -import { verifyToken } from '$lib/server/auth'; -import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users'; - -// Helper to check disk space (Simple Windows implementation) -// Note: In production, consider a specialized library -async function getDiskSpace(drive: string) { - try { - // Using fs.statfs if available (Node 18.15+) or just mock for now - // Implementing proper disk check via Powershell is safer - return { free: 0, total: 0 }; - } catch { - return { free: 0, total: 0 }; - } -} - - -export const load: PageServerLoad = async ({ cookies }) => { - // 1. Auth Check - Verificar token JWT - const token = cookies.get('session_token'); - if (!token) { - throw redirect(303, '/login'); - } - - const session = verifyToken(token); - if (!session) { - throw redirect(303, '/login'); - } - - const currentUser = await getUserById(session.userId); - if (!currentUser || !currentUser.activo) { - throw redirect(303, '/login'); - } - - // Initialize result containers - let databaseRows: any[] = []; - let summaryMain: any = null; - let restoredCount = 0; - let notRestoredCount = 0; - - let databaseRowsAZ: any[] = []; - let summaryAZ: any = null; - - let backupFiles: any[] = []; - let clientsData: any[] = []; - let alertsData: any[] = []; - let basesDeDatosList: any[] = []; - let restoreHistory: Record = {}; - let effectivenessByDb: Record = {}; - - // Connection errors to be passed to UI - let errors = { - primary: null as string | null, - secondary: null as string | null, - azure: null as string | null, - backups: null as string | null - }; - - // --- 1. Load Primary Data (Servidor 152 - Bases Restauradas) --- - try { - // Usamos el servidor SECUNDARIO (152) como fuente del panel principal - const primary = await db.getSecondary(); - - // Main Database Rows - Bases restauradas localmente - const queryMain = ` - SELECT - d.name AS visible_name, - d.name AS original_name, - CAST(SUM(mf.size * 8.0 / 1024) AS DECIMAL(10,2)) AS size_mb, - CAST(SUM(mf.size * 8.0 / 1024 / 1024) AS DECIMAL(10,2)) AS total_size_gb, - MAX(rh.restore_date) AS last_restore_date - FROM - sys.databases d - LEFT JOIN - sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0 - LEFT JOIN - msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name - WHERE - d.name NOT IN ('master', 'tempdb', 'model', 'msdb') - GROUP BY - d.name - ORDER BY - d.name - `; - const resultMain = await primary.request().query(queryMain); - databaseRows = resultMain.recordset; - - // Summary Main - const querySummary = ` - SELECT - COUNT(DISTINCT d.database_id) AS total_databases, - SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb - FROM - sys.databases d - LEFT JOIN - sys.master_files mf ON d.database_id = mf.database_id - WHERE - mf.type = 0 AND d.name != 'tempdb' - `; - const resSummary = await primary.request().query(querySummary); - summaryMain = resSummary.recordset[0]; - - // Alerts Data (bases sin restore reciente) - const sqlAlerts = ` - SELECT - d.name AS visible_name, - MAX(rh.restore_date) AS last_restore_date - FROM sys.databases d - LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name - WHERE d.name != 'tempdb' - GROUP BY d.name - HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL - `; - const resAlerts = await primary.request().query(sqlAlerts); - alertsData = resAlerts.recordset; - - // Historial de restauraciones por base (últimos 60 días) - const sqlHistory = ` - SELECT - destination_database_name AS visible_name, - restore_date - FROM msdb.dbo.restorehistory - WHERE restore_date >= DATEADD(DAY, -120, GETDATE()) - `; - const resHistory = await primary.request().query(sqlHistory); - for (const row of resHistory.recordset as any[]) { - const name = String(row.visible_name ?? '').toLowerCase(); - if (!restoreHistory[name]) restoreHistory[name] = []; - restoreHistory[name].push({ restore_date: row.restore_date }); - } - - // Calcular efectividad mensual por base (1 restore esperado por día) - const now = new Date(); - const currentYear = now.getFullYear(); - const currentMonth = now.getMonth(); // 0-11 - - const monthsToInclude = [currentMonth, currentMonth - 1, currentMonth - 2].filter( - (m) => m >= 0 - ); - - for (const [dbName, history] of Object.entries(restoreHistory)) { - const monthly: { [monthKey: string]: { daysWithRestore: Set; totalDays: number } } = {}; - - // inicializar meses con días del mes - for (const m of monthsToInclude) { - const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`; - monthly[monthKey] = { - daysWithRestore: new Set(), - totalDays: new Date(currentYear, m + 1, 0).getDate() - }; - } - - for (const h of history) { - const d = new Date(h.restore_date); - const y = d.getFullYear(); - const m = d.getMonth(); - if (y !== currentYear || !monthsToInclude.includes(m)) continue; - const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`; - const dayKey = d.toISOString().slice(0, 10); - monthly[monthKey]?.daysWithRestore.add(dayKey); - } - - effectivenessByDb[dbName] = Object.entries(monthly).map( - ([month, data]) => { - const eff = - data.totalDays === 0 - ? 0 - : (data.daysWithRestore.size / data.totalDays) * 100; - return { month, effectiveness: Number(eff.toFixed(1)) }; - } - ); - } - - } catch (e: any) { - console.error("Error loading Primary DB data:", e); - errors.primary = `Error conectando al servidor Local (SQL Express): ${e.message}`; - } - - // --- 2. Load Secondary Data (Servidor con TODAS las bases de clientes en 202) --- - try { - // Aquí usamos el servidor PRIMARIO (202) para la segunda sección - const secondary = await db.getPrimary(); - - // Consultar TODAS las bases de datos en el servidor secundario - const queryMainSec = ` - SELECT - d.name AS visible_name, - d.name AS original_name, - CAST(SUM(mf.size * 8.0 / 1024) AS DECIMAL(10,2)) AS size_mb, - d.create_date, - d.state_desc, - d.recovery_model_desc - FROM - sys.databases d - LEFT JOIN - sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0 - WHERE - d.name NOT IN ('master', 'tempdb', 'model', 'msdb') - GROUP BY - d.name, d.create_date, d.state_desc, d.recovery_model_desc - ORDER BY - d.name - `; - - const resultSecondary = await secondary.request().query(queryMainSec); - databaseRowsAZ = resultSecondary.recordset; - - const querySummarySec = ` - SELECT - COUNT(d.database_id) AS total_databases, - CAST(SUM(mf.size * 8.0 / 1024 / 1024) AS DECIMAL(10,2)) AS total_size_gb - FROM - sys.databases d - LEFT JOIN - sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0 - WHERE - d.name NOT IN ('master', 'tempdb', 'model', 'msdb') - `; - const resSummarySec = await secondary.request().query(querySummarySec); - summaryAZ = resSummarySec.recordset[0]; - - } catch (e: any) { - console.error("Error loading Secondary DB data:", e); - errors.secondary = `Error conectando al servidor Secundario (Todas las Bases): ${e.message}`; - } - - // --- 3. Load Azure/ControlDesk Data (Catálogo de Clientes) --- - // Note: We need this connection to hydrate backup info and alerts too - let azure: any = null; - try { - azure = await db.getAzure(); - - // Clients Catalog - desde CONTROLDESK en Azure - const clientsQuery = `SELECT ID, Nombre, NodoSubNodo, CorreoNotificacion, Activo, BDName FROM [CONTROLDESK].[dbo].[BasesdeDatos]`; - const resClients = await azure.request().query(clientsQuery); - clientsData = resClients.recordset; - - // DB Management List - const basesQueries = `SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName FROM [CONTROLDESK].[dbo].[BasesDeDatos]`; - const resBases = await azure.request().query(basesQueries); - basesDeDatosList = resBases.recordset; - - } catch (e: any) { - console.error("Error loading Azure/ControlDesk data:", e); - errors.azure = `Error conectando a Azure/ControlDesk: ${e.message}`; - } - - // --- 4. Process Backups, Hydrate Alerts & Enriquecer databaseRows con datos de BasesDeDatos --- - try { - let files: string[] = []; - try { - files = await fs.readdir(env.BACKUP_PATH); - } catch (e) { - files = []; - errors.backups = "No se pudo acceder a la carpeta de respaldos."; - } - - for (const file of files) { - if (file === '.' || file === '..') continue; - - const filePath = path.join(env.BACKUP_PATH, file); - let stats; - try { - stats = await fs.stat(filePath); - } catch { continue; } - - const nodoName = path.parse(file).name; - let clientData: any = null; - - // Try to fetch metadata if azure DB is available - if (azure) { - try { - const clientQuery = ` - SELECT TOP 1 Nombre, NodoSubNodo, RFC - FROM [CONTROLDESK].[dbo].[BasesDeDatos] - WHERE NodoSubNodo = @nodoName OR BDName = @nodoName - `; - const req = azure.request(); - req.input('nodoName', nodoName); - const res = await req.query(clientQuery); - clientData = res.recordset[0]; - } catch {} - } - - backupFiles.push({ - name: file, - nodo_name: nodoName, - client_name: clientData?.Nombre ?? 'Cliente no identificado', - client_authority: clientData?.RFC ?? 'N/A', - bd_shelter: 'N/A', - date: stats.mtime, - size: (stats.size / 1024 / 1024).toFixed(2) + " MB" - }); - } - - // Ordenar respaldos de más reciente a más antiguo por fecha de modificación - backupFiles.sort((a, b) => { - const da = new Date(a.date).getTime(); - const db = new Date(b.date).getTime(); - return db - da; - }); - - // Hydrate Alerts if possible - if (azure && alertsData.length > 0) { - const hydratedAlerts = []; - for (const alert of alertsData) { - const nodoName = alert.visible_name; - let cData: any = null; - try { - const req = azure.request(); - req.input('nodoName', nodoName); - const res = await req.query(` - SELECT u.ClienteAutoridad, u.Nombre, u.Usuario, bd.CorreoNotificacion - FROM [CONTROLDESK].[dbo].[Usuarios] AS u - LEFT JOIN [CONTROLDESK].[dbo].[BasesDeDatos] AS bd ON u.Usuario = bd.NodoSubNodo - WHERE u.Usuario = @nodoName - `); - cData = res.recordset[0]; - } catch {} - - hydratedAlerts.push({ ...alert, clientData: cData }); - } - alertsData = hydratedAlerts; - } - - // Enriquecer databaseRows (servidor local restaurado) con NodoSubNodo y Nombre desde BasesDeDatos - if (azure && databaseRows.length > 0) { - try { - const req = azure.request(); - const res = await req.query(` - SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName - FROM [CONTROLDESK].[dbo].[BasesDeDatos] - `); - const bases = res.recordset as any[]; - - const mapByBdName = new Map(); - const mapByNodo = new Map(); - for (const bd of bases) { - if (bd.BDName) { - mapByBdName.set(String(bd.BDName).toLowerCase(), bd); - } - if (bd.NodoSubNodo) { - mapByNodo.set(String(bd.NodoSubNodo).toLowerCase(), bd); - } - } - - databaseRows = databaseRows.map((row) => { - const key = String(row.visible_name ?? row.original_name ?? '').toLowerCase(); - // Intentar match por BDName primero, luego por NodoSubNodo - let match = mapByBdName.get(key); - if (!match) { - match = mapByNodo.get(key); - } - if (!match) { - console.log(`No match found for database: ${key}`); - return row; - } - - return { - ...row, - NodoSubNodo: match.NodoSubNodo, - client_name: match.Nombre, - BDName: match.BDName - }; - }); - } catch (e) { - console.error('Error enriching databaseRows with BasesDeDatos info:', e); - } - } - - } catch (e: any) { - console.error("Error processing backups/alerts hydration:", e); - } - - // CALCULAR MÉTRICAS DE RESTAURACIÓN basadas en last_restore_date (ANTES del filtro) - restoredCount = 0; - notRestoredCount = 0; - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - - for (const db of databaseRows) { - // Verificar si tiene una restauración en las últimas 24 horas - if (db.last_restore_date) { - const restoreDate = new Date(db.last_restore_date); - if (restoreDate > oneDayAgo) { - restoredCount++; - } else { - notRestoredCount++; - } - } else { - notRestoredCount++; - } - } - - // Aplicar filtro de permisos de usuario (si no es admin) - if (!currentUser.es_admin) { - databaseRows = await filterDatabasesByUserPermissions(currentUser.id, databaseRows); - alertsData = await filterDatabasesByUserPermissions(currentUser.id, alertsData); - - // Filtrar backups según las bases de datos permitidas (usar NodoSubNodo) - const allowedNodos = new Set(databaseRows.map(db => (db.NodoSubNodo || db.visible_name).toLowerCase())); - backupFiles = backupFiles.filter(backup => { - const backupNodo = (backup.nodo_name || '').toLowerCase(); - return allowedNodos.has(backupNodo); - }); - - // RECALCULAR MÉTRICAS basadas en las bases de datos filtradas - summaryMain.total_size_gb = databaseRows.reduce((sum, db) => sum + (db.total_size_gb || 0), 0); - summaryMain.total_size_gb = Math.round(summaryMain.total_size_gb * 100) / 100; - - restoredCount = 0; - notRestoredCount = 0; - for (const db of databaseRows) { - // Verificar si tiene una restauración en las últimas 24 horas - if (db.last_restore_date) { - const restoreDate = new Date(db.last_restore_date); - if (restoreDate > oneDayAgo) { - restoredCount++; - } else { - notRestoredCount++; - } - } else { - notRestoredCount++; - } - } - } - - return { - databaseRows, - summaryMain, - restoredCount, - notRestoredCount, - - databaseRowsAZ, - summaryAZ, - - backupFiles, - clientsData, - alertsData, - basesDeDatosList, - restoreHistory, - effectivenessByDb, - - errors, // Return the collected errors - currentUser // Añadir usuario actual para la UI - }; -}; - -// Acciones para actualizar estado de clientes (activar/desactivar) y editar nombre de base de datos -export const actions: Actions = { - toggleClient: async ({ request }) => { - try { - const formData = await request.formData(); - const idRaw = formData.get('id'); - const activoRaw = formData.get('activo'); - - if (!idRaw || !activoRaw) { - return { success: false, message: 'Pare1metros incompletos' }; - } - - const id = Number(idRaw); - const activo = activoRaw === 'true'; - - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('activo', activo ? 1 : 0); - - await req.query(` - UPDATE [CONTROLDESK].[dbo].[BasesdeDatos] - SET Activo = @activo - WHERE ID = @id - `); - - return { success: true }; - } catch (e: any) { - console.error('Error updating client active state:', e); - return { success: false, message: e.message }; - } - }, - - updateDatabaseName: async ({ request }) => { - try { - const formData = await request.formData(); - const idRaw = formData.get('id'); - const nombreRaw = formData.get('nombre'); - - if (!idRaw || !nombreRaw) { - return { success: false, message: 'Pare1metros incompletos' }; - } - - const id = Number(idRaw); - const nombre = String(nombreRaw).trim(); - - if (!nombre) { - return { success: false, message: 'El nombre no puede estar vacedo' }; - } - - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('nombre', nombre); - - await req.query(` - UPDATE [CONTROLDESK].[dbo].[BasesdeDatos] - SET Nombre = @nombre - WHERE ID = @id - `); - - return { success: true }; - } catch (e: any) { - console.error('Error updating database name:', e); - return { success: false, message: e.message }; - } - }, - - createDatabase: async ({ request }) => { - try { - const formData = await request.formData(); - const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim(); - const rfc = String(formData.get('RFC') || '').trim(); - const nombre = String(formData.get('Nombre') || '').trim(); - const sucursal = String(formData.get('Sucursal') || '').trim(); - const correo = String(formData.get('CorreoNotificacion') || '').trim(); - const serverName = String(formData.get('ServerName') || '').trim(); - const bdName = String(formData.get('BDName') || '').trim(); - const activo = formData.get('Activo') === 'true' ? 1 : 0; - - if (!nodoSubNodo || !rfc || !nombre || !sucursal || !correo || !serverName || !bdName) { - return { success: false, message: 'Todos los campos son requeridos' }; - } - - const azure = await db.getAzure(); - const req = azure.request(); - req.input('nodoSubNodo', nodoSubNodo); - req.input('rfc', rfc); - req.input('nombre', nombre); - req.input('sucursal', sucursal); - req.input('correo', correo); - req.input('serverName', serverName); - req.input('bdName', bdName); - req.input('activo', activo); - - await req.query(` - INSERT INTO [CONTROLDESK].[dbo].[BasesDeDatos] - (NodoSubNodo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName, Activo) - VALUES (@nodoSubNodo, @rfc, @nombre, @sucursal, @correo, @serverName, @bdName, @activo) - `); - - return { success: true }; - } catch (e: any) { - console.error('Error creating database:', e); - return { success: false, message: e.message }; - } - }, - - updateDatabase: async ({ request }) => { - try { - const formData = await request.formData(); - const id = Number(formData.get('ID')); - const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim(); - const rfc = String(formData.get('RFC') || '').trim(); - const nombre = String(formData.get('Nombre') || '').trim(); - const sucursal = String(formData.get('Sucursal') || '').trim(); - const correo = String(formData.get('CorreoNotificacion') || '').trim(); - const serverName = String(formData.get('ServerName') || '').trim(); - const bdName = String(formData.get('BDName') || '').trim(); - const activo = formData.get('Activo') === 'true' ? 1 : 0; - - if (!id || !nodoSubNodo || !rfc || !nombre || !sucursal || !correo || !serverName || !bdName) { - return { success: false, message: 'Todos los campos son requeridos' }; - } - - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('nodoSubNodo', nodoSubNodo); - req.input('rfc', rfc); - req.input('nombre', nombre); - req.input('sucursal', sucursal); - req.input('correo', correo); - req.input('serverName', serverName); - req.input('bdName', bdName); - req.input('activo', activo); - - await req.query(` - UPDATE [CONTROLDESK].[dbo].[BasesDeDatos] - SET NodoSubNodo = @nodoSubNodo, - RFC = @rfc, - Nombre = @nombre, - Sucursal = @sucursal, - CorreoNotificacion = @correo, - ServerName = @serverName, - BDName = @bdName, - Activo = @activo - WHERE ID = @id - `); - - return { success: true }; - } catch (e: any) { - console.error('Error updating database:', e); - return { success: false, message: e.message }; - } - }, - - deleteDatabase: async ({ request }) => { - try { - const formData = await request.formData(); - const id = Number(formData.get('ID')); - - if (!id) { - return { success: false, message: 'ID requerido' }; - } - - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - - await req.query(` - DELETE FROM [CONTROLDESK].[dbo].[BasesDeDatos] - WHERE ID = @id - `); - - return { success: true }; - } catch (e: any) { - console.error('Error deleting database:', e); - return { success: false, message: e.message }; - } - } -}; +import { env } from '$env/dynamic/private'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users'; +import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; +import { listBackupFiles } from '$lib/server/backup-files'; +import { logger } from '$lib/server/logger'; +import { + listClientsCatalog, + listDatabaseNodes, + listDatabaseNodesForMssql, + listPortalUsers, + matchNodeRowFromBackupStem, + lookupAlertClientData, + updateNodeActive, + updateNodeLegalName, + insertDatabaseNode, + updateDatabaseNode, + deleteDatabaseNode, + insertPortalUser, + updatePortalUser, + deletePortalUser, + listRestoreTargets, + listRestoredRestoreJobLogs, + listFailedRestoreJobLogs +} from '$lib/server/controldesk-pg'; +import { + listAdditionalEmails, + listAuthorityEmails, + additionalEmailExists, + authorityEmailExists, + insertAdditionalEmail, + insertAuthorityEmail, + deleteAdditionalEmail, + deleteAuthorityEmail +} from '$lib/server/notification-emails-pg'; + +// Validación básica de formato de correo (sanitiza la entrada del usuario antes de persistir) +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +function isValidEmail(value: string): boolean { + return value.length <= 100 && EMAIL_REGEX.test(value); +} + +function parseActivoField(formData: FormData): number { + const v = formData.get('Activo'); + return v === 'true' || v === '1' ? 1 : 0; +} + +/** Lee el id del servidor de restauración elegido (radio); null si no se asignó. */ +function parseRestoreTargetId(formData: FormData): number | null { + const v = formData.get('restore_target_id'); + if (v === null || v === undefined || String(v).trim() === '') return null; + const n = Number(v); + return Number.isInteger(n) && n > 0 ? n : null; +} + +/** Verifica que el solicitante sea administrador (autorización en backend, no solo UI). */ +async function isAdmin(cookies: import('@sveltejs/kit').Cookies): Promise { + const token = cookies.get('session_token'); + if (!token) return false; + const session = verifyToken(token); + if (!session) return false; + const user = await getUserById(session.userId); + return !!user?.es_admin; +} + +const DEFAULT_DATABASE_SERVER = '104.192.7.152'; + +function portalUserUniqueViolationMessage(err: unknown): string | null { + const e = err as { code?: string }; + if (e?.code !== '23505') return null; + return 'Ya existe un usuario con ese mismo nombre en este nodo. Puede usar el mismo nombre en otro nodo distinto.'; +} + +// Helper to check disk space (Simple Windows implementation) +// Note: In production, consider a specialized library +async function getDiskSpace(drive: string) { + try { + // Using fs.statfs if available (Node 18.15+) or just mock for now + // Implementing proper disk check via Powershell is safer + return { free: 0, total: 0 }; + } catch { + return { free: 0, total: 0 }; + } +} + + +/** Rechaza si `promise` no resuelve dentro de `ms`; acota cargas que podrían colgarse. */ +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + } + ); + }); +} + +// Tope duro para la carga de métricas SQL Server: aun si varios servidores no responden, +// el dashboard degrada a datos vacíos (con banner) en vez de colgarse. En producción, con +// SQL sano (<2s), nunca se dispara. Configurable por env. +const SQL_LOAD_TIMEOUT_MS = Number(env.PANEL_SQL_LOAD_TIMEOUT_MS) || 12000; + +export const load: PageServerLoad = async ({ cookies }) => { + // 1. Auth Check - Verificar token JWT + const token = cookies.get('session_token'); + if (!token) { + throw redirect(303, '/login'); + } + + const session = verifyToken(token); + if (!session) { + throw redirect(303, '/login'); + } + + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.activo) { + throw redirect(303, '/login'); + } + + // Initialize result containers + let databaseRows: any[] = []; + let summaryMain: any = { total_databases: 0, total_size_gb: 0 }; + let restoredCount = 0; + let notRestoredCount = 0; + + let databaseRowsAZ: any[] = []; + let summaryAZ: any = null; + + let backupFiles: any[] = []; + let restoredBackups: any[] = []; + let failedRestores: any[] = []; + let clientsData: any[] = []; + let alertsData: any[] = []; + let basesDeDatosList: any[] = []; + let usuariosList: any[] = []; + let additionalEmails: any[] = []; + let authorityEmails: any[] = []; + let restoreTargets: any[] = []; + let restoreHistory: Record = {}; + let effectivenessByDb: Record = {}; + + // Connection errors to be passed to UI + let errors = { + primary: null as string | null, + secondary: null as string | null, + azure: null as string | null, + backups: null as string | null, + restores: null as string | null + }; + + // --- 1. SQL Server: una conexión por servidor (master) según a24c.database_nodes --- + let nodesForSql: CatalogNodeRow[] = []; + try { + nodesForSql = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; + } catch (e: any) { + console.error('Error leyendo database_nodes para SQL Server:', e); + errors.primary = `PostgreSQL / database_nodes: ${e.message}`; + } + + // Las métricas de SQL Server (por nodo) y el catálogo de ControlDesk (PostgreSQL) son + // independientes entre sí; se cargan en paralelo para reducir el tiempo total de la página. + // Dentro del catálogo, las 6 consultas también corren en paralelo (antes eran secuenciales). + let controlDeskOk = false; + const [bundleResult, catalogResult] = await Promise.allSettled([ + withTimeout( + loadSqlDashboardFromNodes(nodesForSql), + SQL_LOAD_TIMEOUT_MS, + `SQL Server: la carga de métricas excedió ${SQL_LOAD_TIMEOUT_MS} ms` + ), + Promise.all([ + listClientsCatalog(), + listDatabaseNodes(), + listPortalUsers(), + listAdditionalEmails(), + listAuthorityEmails(), + listRestoreTargets() + ]) + ]); + + if (bundleResult.status === 'fulfilled') { + const bundle = bundleResult.value; + databaseRows = bundle.databaseRows; + summaryMain = bundle.summaryMain; + alertsData = bundle.alertsData; + restoreHistory = bundle.restoreHistory; + effectivenessByDb = bundle.effectivenessByDb; + databaseRowsAZ = [...bundle.databaseRows]; + summaryAZ = { ...bundle.summaryMain }; + } else { + const e: any = bundleResult.reason; + console.error('Error métricas SQL Server por nodo:', e); + errors.primary = `${errors.primary ? errors.primary + ' · ' : ''}SQL Server (nodos): ${e?.message ?? e}`; + } + + if (catalogResult.status === 'fulfilled') { + const [clients, bases, users, addEmails, authEmails, targets] = catalogResult.value; + clientsData = clients; + basesDeDatosList = bases; + usuariosList = users; + additionalEmails = addEmails; + authorityEmails = authEmails; + restoreTargets = targets; + controlDeskOk = true; + } else { + const e: any = catalogResult.reason; + console.error('Error loading ControlDesk (PostgreSQL):', e); + errors.azure = `Error conectando al catálogo ControlDesk (PostgreSQL): ${e?.message ?? e}`; + } + + // --- 4. Process Backups, Hydrate Alerts & Enriquecer databaseRows con datos de BasesDeDatos --- + try { + // El CloudRestoreAS nuevo reubica los respaldos en `Procesados//`; se + // recorre la raíz de forma recursiva (ver listBackupFiles). BACKUP_PATH debe apuntar + // a esa raíz. Las entradas ya vienen ordenadas por fecha descendente y con multipart + // colapsado. + const backupRoot = env.BACKUP_PATH as string | undefined; + if (!backupRoot) { + errors.backups = 'La ruta de respaldos (BACKUP_PATH) no está configurada.'; + } else { + let listing: { files: Awaited>['files']; truncated: boolean }; + try { + listing = await listBackupFiles(backupRoot); + } catch { + listing = { files: [], truncated: false }; + errors.backups = 'No se pudo acceder a la carpeta de respaldos.'; + } + + if (listing.truncated) { + logger.warn({ + message: 'Listado de respaldos recortado por límite de archivos', + context: { backupRoot } + }); + } + + for (const entry of listing.files) { + const nodoName = path.parse(entry.name).name; + let clientData: any = null; + + if (controlDeskOk && basesDeDatosList.length) { + try { + clientData = matchNodeRowFromBackupStem(nodoName, basesDeDatosList); + } catch { + /* ignore */ + } + } + + backupFiles.push({ + name: entry.name, + relPath: entry.relPath, + parts: entry.parts, + nodo_name: (clientData?.NodoSubNodo as string | undefined) || nodoName, + client_name: clientData?.Nombre ?? 'Cliente no identificado', + client_authority: clientData?.RFC ?? 'N/A', + bd_shelter: 'N/A', + date: new Date(entry.mtimeMs), + size: (entry.sizeBytes / 1024 / 1024).toFixed(2) + ' MB' + }); + } + } + + if (controlDeskOk && alertsData.length > 0) { + alertsData = await Promise.all( + alertsData.map(async (alert) => { + let cData: any = null; + try { + cData = await lookupAlertClientData(String(alert.visible_name)); + } catch { + /* ignore */ + } + return { ...alert, clientData: cData }; + }) + ); + } + + // Enriquecer databaseRows con catálogo ControlDesk (PostgreSQL) + if (controlDeskOk && databaseRows.length > 0) { + try { + // Reutiliza el catálogo ya cargado en el bloque anterior (evita re-consultar PostgreSQL). + const bases = basesDeDatosList as any[]; + + const mapByBdName = new Map(); + const mapByNodo = new Map(); + for (const bd of bases) { + if (bd.BDName) { + mapByBdName.set(String(bd.BDName).toLowerCase(), bd); + } + if (bd.NodoSubNodo) { + mapByNodo.set(String(bd.NodoSubNodo).toLowerCase(), bd); + } + } + + databaseRows = databaseRows.map((row) => { + const key = String(row.visible_name ?? row.original_name ?? '').toLowerCase(); + // Intentar match por BDName primero, luego por NodoSubNodo + let match = mapByBdName.get(key); + if (!match) { + match = mapByNodo.get(key); + } + if (!match) { + return row; + } + + return { + ...row, + NodoSubNodo: match.NodoSubNodo, + client_name: match.Nombre, + BDName: match.BDName + }; + }); + } catch (e) { + console.error('Error enriching databaseRows with BasesDeDatos info:', e); + } + } + + } catch (e: any) { + console.error("Error processing backups/alerts hydration:", e); + } + + // Inventario "respaldos restaurados" y "restores fallidos", desde la BD (restore_job_logs, + // poblado por CloudRestoreAS vía POST /api/restore/job-result). Se lee de la BD y no del + // filesystem porque las carpetas Procesados/Fallados viven en los servidores de restauración + // (rutas Windows remotas) que el host del panel no ve. Si la BD falla, se registra en + // `errors.restores` para avisar en la UI en vez de degradar a "sin registros" en silencio. + // Inventario de restauraciones (Respaldos Restaurados / Restores Fallidos): vistas SOLO de + // administrador. No se cargan ni se envían al cliente para usuarios normales (autorización en + // backend, no solo ocultar en la UI — OWASP Broken Access Control). + if (currentUser.es_admin) { + try { + [restoredBackups, failedRestores] = await Promise.all([ + listRestoredRestoreJobLogs(200), + listFailedRestoreJobLogs(200) + ]); + } catch (e: any) { + console.error('Error cargando inventario de restauraciones:', e); + errors.restores = `No se pudo cargar el inventario de restauraciones: ${e?.message ?? e}`; + } + } + + // CALCULAR MÉTRICAS DE RESTAURACIÓN basadas en last_restore_date (ANTES del filtro) + restoredCount = 0; + notRestoredCount = 0; + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + for (const db of databaseRows) { + // Verificar si tiene una restauración en las últimas 24 horas + if (db.last_restore_date) { + const restoreDate = new Date(db.last_restore_date); + if (restoreDate > oneDayAgo) { + restoredCount++; + } else { + notRestoredCount++; + } + } else { + notRestoredCount++; + } + } + + // Aplicar filtro de permisos de usuario (si no es admin) + if (!currentUser.es_admin) { + [databaseRows, alertsData] = await Promise.all([ + filterDatabasesByUserPermissions(currentUser.id, databaseRows), + filterDatabasesByUserPermissions(currentUser.id, alertsData) + ]); + + // Filtrar backups según las bases de datos permitidas (usar NodoSubNodo) + const allowedNodos = new Set(databaseRows.map(db => (db.NodoSubNodo || db.visible_name).toLowerCase())); + backupFiles = backupFiles.filter(backup => { + const backupNodo = (backup.nodo_name || '').toLowerCase(); + return allowedNodos.has(backupNodo); + }); + + // RECALCULAR MÉTRICAS basadas en las bases de datos filtradas + summaryMain.total_size_gb = databaseRows.reduce((sum, db) => sum + (db.total_size_gb || 0), 0); + summaryMain.total_size_gb = Math.round(summaryMain.total_size_gb * 100) / 100; + + restoredCount = 0; + notRestoredCount = 0; + for (const db of databaseRows) { + // Verificar si tiene una restauración en las últimas 24 horas + if (db.last_restore_date) { + const restoreDate = new Date(db.last_restore_date); + if (restoreDate > oneDayAgo) { + restoredCount++; + } else { + notRestoredCount++; + } + } else { + notRestoredCount++; + } + } + } + + return { + databaseRows, + summaryMain, + restoredCount, + notRestoredCount, + + databaseRowsAZ, + summaryAZ, + + backupFiles, + restoredBackups, + failedRestores, + clientsData, + alertsData, + basesDeDatosList, + usuariosList, + additionalEmails, + authorityEmails, + restoreTargets, + restoreHistory, + effectivenessByDb, + + errors, // Return the collected errors + currentUser // Añadir usuario actual para la UI + }; +}; + +// Acciones para actualizar estado de clientes (activar/desactivar) y editar nombre de base de datos +export const actions: Actions = { + toggleClient: async ({ request }) => { + try { + const formData = await request.formData(); + const idRaw = formData.get('id'); + const activoRaw = formData.get('activo'); + + if (!idRaw || !activoRaw) { + return { success: false, message: 'Parámetros incompletos' }; + } + + const id = Number(idRaw); + const activo = activoRaw === 'true'; + + await updateNodeActive(id, activo); + + return { success: true }; + } catch (e: any) { + console.error('Error updating client active state:', e); + return { success: false, message: e.message }; + } + }, + + updateDatabaseName: async ({ request }) => { + try { + const formData = await request.formData(); + const idRaw = formData.get('id'); + const nombreRaw = formData.get('nombre'); + + if (!idRaw || !nombreRaw) { + return { success: false, message: 'Parámetros incompletos' }; + } + + const id = Number(idRaw); + const nombre = String(nombreRaw).trim(); + + if (!nombre) { + return { success: false, message: 'El nombre no puede estar vacío' }; + } + + await updateNodeLegalName(id, nombre); + + return { success: true }; + } catch (e: any) { + console.error('Error updating database name:', e); + return { success: false, message: e.message }; + } + }, + + createDatabase: async ({ request, cookies }) => { + try { + if (!(await isAdmin(cookies))) { + return { success: false, message: 'No autorizado' }; + } + const formData = await request.formData(); + const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim(); + const rfc = String(formData.get('RFC') || '').trim(); + const nombre = String(formData.get('Nombre') || '').trim(); + const sucursal = String(formData.get('Sucursal') || '').trim(); + const correo = String(formData.get('CorreoNotificacion') || '').trim(); + // El servidor de la base se elige por radio (restore_target_id); server_name + // se deriva de su IP. Se conserva un fallback para no dejar server_name vacío. + const restoreTargetId = parseRestoreTargetId(formData); + const serverName = + String(formData.get('ServerName') || '').trim() || DEFAULT_DATABASE_SERVER; + const bdName = nodoSubNodo; + const activo = parseActivoField(formData); + // Fecha de aviso SAT Anexo 24C (T2026-06-111), opcional: vacio = sin fecha. + const anexo24CAvisoFechaRaw = String(formData.get('Anexo24CAvisoFecha') || '').trim(); + const anexo24CAvisoFecha = anexo24CAvisoFechaRaw || null; + + if (!nodoSubNodo || !rfc || !nombre || !correo) { + return { success: false, message: 'Faltan campos requeridos' }; + } + + await insertDatabaseNode({ + nodoSubNodo, + rfc, + nombre, + sucursal, + correo, + serverName, + bdName, + activo, + restoreTargetId, + anexo24CAvisoFecha + }); + + return { success: true }; + } catch (e: any) { + console.error('Error creating database:', e); + return { success: false, message: e.message }; + } + }, + + updateDatabase: async ({ request, cookies }) => { + try { + if (!(await isAdmin(cookies))) { + return { success: false, message: 'No autorizado' }; + } + const formData = await request.formData(); + const id = Number(formData.get('ID')); + const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim(); + const rfc = String(formData.get('RFC') || '').trim(); + const nombre = String(formData.get('Nombre') || '').trim(); + const sucursal = String(formData.get('Sucursal') || '').trim(); + const correo = String(formData.get('CorreoNotificacion') || '').trim(); + const restoreTargetId = parseRestoreTargetId(formData); + const serverName = + String(formData.get('ServerName') || '').trim() || DEFAULT_DATABASE_SERVER; + const bdName = nodoSubNodo; + const activo = parseActivoField(formData); + // Fecha de aviso SAT Anexo 24C (T2026-06-111), opcional: vacio = sin fecha. + const anexo24CAvisoFechaRaw = String(formData.get('Anexo24CAvisoFecha') || '').trim(); + const anexo24CAvisoFecha = anexo24CAvisoFechaRaw || null; + + if (!id || !nodoSubNodo || !rfc || !nombre || !correo) { + return { success: false, message: 'Faltan campos requeridos' }; + } + + await updateDatabaseNode(id, { + nodoSubNodo, + rfc, + nombre, + sucursal, + correo, + serverName, + bdName, + activo, + restoreTargetId, + anexo24CAvisoFecha + }); + + return { success: true }; + } catch (e: any) { + console.error('Error updating database:', e); + return { success: false, message: e.message }; + } + }, + + deleteDatabase: async ({ request }) => { + try { + const formData = await request.formData(); + const id = Number(formData.get('ID')); + + if (!id) { + return { success: false, message: 'ID requerido' }; + } + + await deleteDatabaseNode(id); + + return { success: true }; + } catch (e: any) { + console.error('Error deleting database:', e); + return { success: false, message: e.message }; + } + }, + + // ---- Acciones para a24c.portal_users (antes CONTROLDESK.dbo.Usuarios) ---- + + createUsuario: async ({ request }) => { + try { + const formData = await request.formData(); + const idNodoSubNodo = Number(formData.get('IDNodoSubNodo')); + const clienteAutoridad = Number(formData.get('ClienteAutoridad') ?? 0); + const nombre = String(formData.get('Nombre') || '').trim(); + const usuario = String(formData.get('Usuario') || '').trim(); + const password = String(formData.get('Password') || ''); + const bdShelter = String(formData.get('BD_Shelter') || '').trim() || null; + + if (!Number.isFinite(idNodoSubNodo) || idNodoSubNodo <= 0 || !nombre || !usuario || !password) { + return { success: false, message: 'Todos los campos obligatorios deben completarse' }; + } + if (!Number.isFinite(clienteAutoridad)) { + return { success: false, message: 'Cliente autoridad inválido' }; + } + + // Hash WinDev-compatible: SHA-512(password + "soluciones"), hex UPPERCASE + const passwordHash = crypto + .createHash('sha512') + .update(password + 'soluciones', 'utf8') + .digest('hex') + .toUpperCase(); + + await insertPortalUser({ + databaseNodeId: idNodoSubNodo, + isAuthorityClient: clienteAutoridad, + fullName: nombre, + username: usuario, + passwordHash, + bdShelter + }); + + return { success: true }; + } catch (e: any) { + console.error('Error creating usuario:', e); + const dup = portalUserUniqueViolationMessage(e); + if (dup) return { success: false, message: dup }; + return { success: false, message: e.message }; + } + }, + + updateUsuario: async ({ request }) => { + try { + const formData = await request.formData(); + const id = Number(formData.get('ID')); + const idNodoSubNodo = Number(formData.get('IDNodoSubNodo')); + const clienteAutoridad = Number(formData.get('ClienteAutoridad') ?? 0); + const nombre = String(formData.get('Nombre') || '').trim(); + const usuario = String(formData.get('Usuario') || '').trim(); + const password = String(formData.get('Password') || ''); + const bdShelter = String(formData.get('BD_Shelter') || '').trim() || null; + + if (!id || !Number.isFinite(idNodoSubNodo) || idNodoSubNodo <= 0 || !nombre || !usuario) { + return { success: false, message: 'Todos los campos obligatorios deben completarse' }; + } + if (!Number.isFinite(clienteAutoridad)) { + return { success: false, message: 'Cliente autoridad inválido' }; + } + + if (password) { + const passwordHash = crypto + .createHash('sha512') + .update(password + 'soluciones', 'utf8') + .digest('hex') + .toUpperCase(); + await updatePortalUser(id, { + databaseNodeId: idNodoSubNodo, + isAuthorityClient: clienteAutoridad, + fullName: nombre, + username: usuario, + bdShelter, + passwordHash + }); + } else { + await updatePortalUser(id, { + databaseNodeId: idNodoSubNodo, + isAuthorityClient: clienteAutoridad, + fullName: nombre, + username: usuario, + bdShelter + }); + } + + return { success: true }; + } catch (e: any) { + console.error('Error updating usuario:', e); + const dup = portalUserUniqueViolationMessage(e); + if (dup) return { success: false, message: dup }; + return { success: false, message: e.message }; + } + }, + + deleteUsuario: async ({ request }) => { + try { + const formData = await request.formData(); + const id = Number(formData.get('ID')); + + if (!id) { + return { success: false, message: 'ID requerido' }; + } + + await deletePortalUser(id); + + return { success: true }; + } catch (e: any) { + console.error('Error deleting usuario:', e); + return { success: false, message: e.message }; + } + }, + + // ---- Correos de notificación adicionales (a24c.additional_emails) ---- + + createAdditionalEmail: async ({ request }) => { + try { + const formData = await request.formData(); + const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim(); + const correo = String(formData.get('Correo') || '').trim(); + + if (!nodoSubNodo || !correo) { + return { success: false, message: 'Faltan campos requeridos' }; + } + if (nodoSubNodo.length > 50) { + return { success: false, message: 'El Nodo/SubNodo excede 50 caracteres' }; + } + if (!isValidEmail(correo)) { + return { success: false, message: 'Formato de correo inválido (máx. 100 caracteres)' }; + } + if (await additionalEmailExists(nodoSubNodo, correo)) { + return { success: false, message: 'Ese correo ya está registrado para este nodo' }; + } + + const row = await insertAdditionalEmail(nodoSubNodo, correo); + return { success: true, row }; + } catch (e: any) { + console.error('Error creating additional email:', e); + return { success: false, message: e.message }; + } + }, + + deleteAdditionalEmail: async ({ request }) => { + try { + const formData = await request.formData(); + const id = Number(formData.get('ID')); + + if (!id) { + return { success: false, message: 'ID requerido' }; + } + + await deleteAdditionalEmail(id); + + return { success: true }; + } catch (e: any) { + console.error('Error deleting additional email:', e); + return { success: false, message: e.message }; + } + }, + + // ---- Correos de notificación de autoridad (a24c.authority_notification_emails) ---- + + createAuthorityEmail: async ({ request }) => { + try { + const formData = await request.formData(); + const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim(); + const correo = String(formData.get('Correo') || '').trim(); + + if (!nodoSubNodo || !correo) { + return { success: false, message: 'Faltan campos requeridos' }; + } + if (nodoSubNodo.length > 50) { + return { success: false, message: 'El Nodo/SubNodo excede 50 caracteres' }; + } + if (!isValidEmail(correo)) { + return { success: false, message: 'Formato de correo inválido (máx. 100 caracteres)' }; + } + if (await authorityEmailExists(nodoSubNodo, correo)) { + return { success: false, message: 'Ese correo ya está registrado para este nodo' }; + } + + const row = await insertAuthorityEmail(nodoSubNodo, correo); + return { success: true, row }; + } catch (e: any) { + console.error('Error creating authority email:', e); + return { success: false, message: e.message }; + } + }, + + deleteAuthorityEmail: async ({ request }) => { + try { + const formData = await request.formData(); + const id = Number(formData.get('ID')); + + if (!id) { + return { success: false, message: 'ID requerido' }; + } + + await deleteAuthorityEmail(id); + + return { success: true }; + } catch (e: any) { + console.error('Error deleting authority email:', e); + return { success: false, message: e.message }; + } + } +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9b04e54..0c0be20 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,61 +1,173 @@ -
- - - - -
- -
-
- -
-

Panel de Control

-
-
- -
- -
-
- - -
-
+ +
{#if activeView === 'dashboard'}
@@ -806,7 +1147,9 @@

Almacenamiento Utilizado

{totalSizeGB} GB

-

Tamaño agregado de archivos de datos

+

+ Suma del tamaño asignado en SQL Server (datos, registro y demás archivos de cada base) +

@@ -823,8 +1166,8 @@
-
-
+
+
@@ -835,9 +1178,9 @@
- Mostrando {PAGE_SIZE} por página + Mostrando {Math.min(visibleMain, mainRowsLive.length)} de {mainRowsLive.length} - Total: {filterMainRows().length} +
- -
+ +
+ tryExpandVisibleOnScroll(e.currentTarget, mainRowsLive.length, visibleMain, (n) => { + visibleMain = n; + })} + > - + @@ -883,7 +1235,7 @@ - {#each getPaged(filterMainRows(), pageMain) as row} + {#each sliceVisible(mainRowsLive, visibleMain) as row} { @@ -926,25 +1278,9 @@
Cliente NODO
- - -
- - Página {pageMain} - -
+ {#if mainRowsLive.length > visibleMain} +

Desplázate en la tabla para cargar más filas

+ {/if}
@@ -1159,8 +1495,8 @@
- Mostrando {PAGE_SIZE} por página - Total: {filterBackups().length} + Mostrando {Math.min(visibleBackups, backupRowsLive.length)} de {backupRowsLive.length} +
-
+
+ tryExpandVisibleOnScroll(e.currentTarget, backupRowsLive.length, visibleBackups, (n) => { + visibleBackups = n; + })} + > - + @@ -1183,16 +1528,18 @@ - {#each getPaged(filterBackups(), pageBackups) as file} + {#each sliceVisible(backupRowsLive, visibleBackups) as file} - +
Archivo Cliente
{file.name} + {file.name}{#if file.parts > 1}({file.parts} partes){/if} + {file.client_name} {new Date(file.date).toLocaleString()} {file.size} Descargar @@ -1208,25 +1555,129 @@
+ {#if backupRowsLive.length > visibleBackups} +

Desplázate en la tabla para cargar más filas

+ {/if} +
+
+ {/if} -
- - Página {pageBackups} - + {#if activeView === 'restored' && data.currentUser?.es_admin} +
+
+

Respaldos restaurados

+

+ Historial de restauraciones exitosas por servidor, reportadas por + CloudRestoreAS. +

+
+ {#if data.errors?.restores} +
+ {data.errors.restores}
+ {/if} +
+ + + + + + + + + + + + + {#each restoredBackups as r (r.id)} + + + + + + + + + {:else} + + + + {/each} + +
ServidorNodo / ClienteArchivoFechaTamañoAcciones
{r.server_name ?? '—'} + {r.node_key ?? r.db_name ?? '—'} + {#if r.client_name}{r.client_name}{/if} + {r.filename}{new Date(r.restored_at).toLocaleString()}{formatMb(r.size_bytes)} + {#if r.restore_target_id && r.rel_path} + Descargar + {:else} + + {/if} +
+ No hay respaldos restaurados registrados. +
+
+
+ {/if} + + {#if activeView === 'failed' && data.currentUser?.es_admin} +
+
+

Restores fallidos

+

+ Restauraciones que fallaron, por servidor. Puedes descargar el respaldo + para diagnóstico. +

+
+ {#if data.errors?.restores} +
+ {data.errors.restores} +
+ {/if} +
+ + + + + + + + + + + + {#each failedRestores as f (f.id)} + + + + + + + + {:else} + + + + {/each} + +
ServidorBase / ArchivoFechaErrorAcciones
{f.server_name ?? '—'} + {f.db_name ?? '—'} + {f.filename} + {new Date(f.restored_at).toLocaleString()}{f.error_message ?? '—'} + {#if f.restore_target_id && f.rel_path} + Descargar + {:else} + + {/if} +
+ No hay restores fallidos registrados. +
{/if} @@ -1241,8 +1692,8 @@
- Mostrando {PAGE_SIZE} por página - Total: {filterClients().length} + Mostrando {Math.min(visibleClients, clientsTableRowsLive.length)} de {clientsTableRowsLive.length} +
-
+
+ tryExpandVisibleOnScroll( + e.currentTarget, + clientsTableRowsLive.length, + visibleClients, + (n) => { + visibleClients = n; + } + )} + > - + @@ -1265,7 +1730,7 @@ - {#each getPaged(filterClients(), pageClients) as client} + {#each sliceVisible(clientsTableRowsLive, visibleClients) as client}
ID Nombre
{client.ID} ({})); - if (!res.ok || result.success === false) { - console.error('Error al actualizar cliente', result); + const result = parseKitAction(await res.text()); + const errMsg = kitActionErrorMessage(res, result); + if (errMsg) { + console.error('Error al actualizar cliente', errMsg, result); return; } @@ -1333,25 +1799,9 @@
- -
- - Página {pageClients} - -
+ {#if clientsTableRowsLive.length > visibleClients} +

Desplázate en la tabla para cargar más filas

+ {/if}
{/if} @@ -1434,7 +1884,7 @@
-
+
+ - {getSortedFilteredAlerts().length} alertas + {alertsListLive.length} alertas + {#if alertSendResult} + + {alertSendResult.failed === 0 ? 'check_circle' : 'warning'} + Enviados {alertSendResult.sent} · Sin correo {alertSendResult.failed} + + {/if}
@@ -1465,27 +1934,42 @@ ¡Excelente! No hay alertas críticas pendientes.
{:else} - {@const alertsRows = getPaged(getSortedFilteredAlerts(), pageAlerts)} - -
+
+ tryExpandVisibleOnScroll(e.currentTarget, alertsListLive.length, visibleAlerts, (n) => { + visibleAlerts = n; + })} + > - + + - {#each alertsRows as alert} + {#each alertsRowsVisible as alert} @@ -1500,44 +1984,41 @@ {/if} + {/each}
Base de datos Última restauración Cliente Correo Días sin sincronizarAviso
{alert.visible_name} - {alert.last_restore_date - ? new Date(alert.last_restore_date).toLocaleString() - : 'Nunca'} + {#if alert.not_found} + + error_outline + No encontrada + + {:else} + {alert.last_restore_date + ? new Date(alert.last_restore_date).toLocaleString() + : 'Nunca'} + {/if} {alert.clientData?.Nombre ?? 'N/D'} {alert.clientData?.CorreoNotificacion ?? 'N/D'} + +
-
+
Mostrando - {alertsRows.length} + {alertsRowsVisible.length} de - {getSortedFilteredAlerts().length} + {alertsListLive.length} alertas +
-
- - -
+ {#if alertsListLive.length > visibleAlerts} +

Desplázate hacia abajo en la tabla para cargar más

+ {/if}
{/if}
@@ -1548,159 +2029,300 @@

Gestión de Bases de Datos

-

Vista administrativa de las bases de datos registradas en el sistema.

-
- -
+

Vista administrativa de las bases de datos y usuarios registrados en el sistema.

-
-
-
- Mostrando {PAGE_SIZE} por página - - Total: {filterDatabases().length} -
-
- -
-
-
- - - - - - - - - - - - {#each getPaged(filterDatabases(), pageDatabases) as bd} - - - - - - - - {:else} - - - - {/each} - -
IDNodoNombreServidorAcciones
{bd.ID}{bd.NodoSubNodo} - {#if editingDbId === bd.ID} - - {:else} - - {/if} - {bd.ServerName} - {#if editingDbId === bd.ID} -
- - -
- {:else} -
- - -
- {/if} -
- No hay registros de gestión disponibles. -
-
- -
- - Página {pageDatabases} - -
+ +
+ +
+ + + {#if databasesSubTab === 'bases'} +
+
+ +
+
+
+
+ Mostrando {Math.min(visibleDatabases, dbMgmtRowsLive.length)} de {dbMgmtRowsLive.length} + + +
+
+ +
+
+
+ tryExpandVisibleOnScroll( + e.currentTarget, + dbMgmtRowsLive.length, + visibleDatabases, + (n) => { + visibleDatabases = n; + } + )} + > + + + + + + + + + + + + {#each sliceVisible(dbMgmtRowsLive, visibleDatabases) as bd} + + + + + + + + {:else} + + + + {/each} + +
IDNodoNombreServidorAcciones
{bd.ID}{bd.NodoSubNodo} + {#if editingDbId === bd.ID} + + {:else} + + {/if} + + {restoreTargetNameById.get(bd.RestoreTargetId) ?? (bd.RestoreTargetId ? bd.ServerName : '—')} + + {#if editingDbId === bd.ID} +
+ + +
+ {:else} +
+ + +
+ {/if} +
+ No hay registros de gestión disponibles. +
+
+ {#if dbMgmtRowsLive.length > visibleDatabases} +

Desplázate en la tabla para cargar más filas

+ {/if} +
+
+ {/if} + + + {#if databasesSubTab === 'usuarios'} +
+
+ +
+
+
+
+ Mostrando {Math.min(visibleUsuarios, usuariosTableRowsLive.length)} de {usuariosTableRowsLive.length} + + +
+
+ +
+
+
+ tryExpandVisibleOnScroll( + e.currentTarget, + usuariosTableRowsLive.length, + visibleUsuarios, + (n) => { + visibleUsuarios = n; + } + )} + > + + + + + + + + + + + + + + {#each sliceVisible(usuariosTableRowsLive, visibleUsuarios) as u} + + + + + + + + + + {:else} + + + + {/each} + +
IDNodoTipoNombreUsuarioBD ShelterAcciones
{u.ID}{getNodoName(u.IDNodoSubNodo)} + {#if u.ClienteAutoridad == 1} + Autoridad + {:else} + Cliente + {/if} + {u.Nombre}{u.Usuario}{u.BD_Shelter ?? '—'} +
+ + +
+
+ No hay usuarios registrados. +
+
+ {#if usuariosTableRowsLive.length > visibleUsuarios} +

Desplázate en la tabla para cargar más filas

+ {/if} +
+
+ {/if}
{/if}
-
-
-
+ {#if showDbCrudModal}
{ if (e.target === e.currentTarget) closeDbCrudModal(); }} + onkeydown={(e) => { if (e.key === 'Escape') closeDbCrudModal(); }} > -
-
+
+ +

{isEditingDb ? 'Editar Base de Datos' : 'Nueva Base de Datos'}

-
{e.preventDefault(); submitDbForm();}}> + + {#if isEditingDb} +
+ + + +
+ {/if} + + {#if !isEditingDb || dbModalTab === 'datos'} + {e.preventDefault(); submitDbForm();}}>
-
-
@@ -1846,6 +2544,333 @@
+ {/if} + + + {#if isEditingDb && dbModalTab === 'usuarios'} +
+
+

+ Usuarios vinculados a esta base de datos (ID {formDb.ID}). +

+ +
+ {#each [getUsuariosForDb(formDb.ID)] as dbUsers} + {#if dbUsers.length === 0} +
+ No hay usuarios vinculados a esta base de datos. +
+ {:else} +
+ + + + + + + + + + + + + {#each dbUsers as u (u.ID)} + + + + + + + + + {/each} + +
IDTipoNombreUsuarioBD ShelterAcciones
{u.ID} + {#if u.ClienteAutoridad} + Autoridad + {:else} + Cliente + {/if} + {u.Nombre ?? '—'}{u.Usuario ?? '—'}{u.BD_Shelter ?? '—'} +
+ + +
+
+
+ {/if} + {/each} +
+ {/if} + + + {#if isEditingDb && dbModalTab === 'correos'} +
+

+ Correos que reciben notificaciones además del correo principal + ({formDb.CorreoNotificacion || '—'}) para el nodo + {formDb.NodoSubNodo}. +

+ + {#if emailFormError} +
+ {emailFormError} +
+ {/if} + + +
+
+

Correos adicionales

+

Alertas de respaldos y actividad.

+
+
+
{ e.preventDefault(); submitNotificationEmail('additional'); }}> + + +
+ {#each [getAdditionalEmailsForNode(formDb.NodoSubNodo)] as rows} + {#if rows.length === 0} +

Sin correos adicionales.

+ {:else} +
    + {#each rows as row (row.ID)} +
  • + {row.Correo} + +
  • + {/each} +
+ {/if} + {/each} +
+
+ + +
+
+

Correos de autoridad

+

Aviso cuando la autoridad ingresa al portal web.

+
+
+
{ e.preventDefault(); submitNotificationEmail('authority'); }}> + + +
+ {#each [getAuthorityEmailsForNode(formDb.NodoSubNodo)] as rows} + {#if rows.length === 0} +

Sin correos de autoridad.

+ {:else} +
    + {#each rows as row (row.ID)} +
  • + {row.Correo} + +
  • + {/each} +
+ {/if} + {/each} +
+
+
+ {/if} +
+
+{/if} + + +{#if showUsuarioModal} +
{ if (e.target === e.currentTarget) closeUsuarioModal(); }} + onkeydown={(e) => { if (e.key === 'Escape') closeUsuarioModal(); }} + > +
+
+

+ {isEditingUsuario ? 'Editar Usuario' : 'Nuevo Usuario'} +

+ +
+ +
{ e.preventDefault(); submitUsuarioForm(); }}> + {#if usuarioFormError} +
+ {usuarioFormError} +
+ {/if} +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +

+ Se almacena encriptada. +

+
+ + +
+ + +
+
+ +
+ + +
+
{/if} diff --git a/src/routes/api/alerts/send/+server.ts b/src/routes/api/alerts/send/+server.ts new file mode 100644 index 0000000..0ef63ae --- /dev/null +++ b/src/routes/api/alerts/send/+server.ts @@ -0,0 +1,148 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { pgPool } from '$lib/server/db'; +import { sendSmtpEmail, buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } from '$lib/server/email-service'; + +interface AlertPayload { + /** ID del nodo en a24c.database_nodes; se hace echo en SendResult para reconciliación del cooldown en a24c. */ + database_node_id?: number; + /** Clave del nodo (de clientData.NodoSubNodo). Puede estar vacía si el cliente no fue identificado. */ + node_subnode_key: string; + visible_name: string; + clientName: string | null; + mainEmail: string | null; + last_restore_date: string | null; + daysWithout: number | null; + /** Tipo de notificación. Ausente => 'overdue' por compatibilidad. */ + kind?: 'overdue' | 'resolved'; +} + +interface SendResult { + /** Echo del id recibido (o null si no vino) para que a24c reconcilie el cooldown. */ + database_node_id: number | null; + node_subnode_key: string; + visible_name: string; + /** Echo del kind efectivo aplicado. */ + kind: 'overdue' | 'resolved'; + recipients: number; + status: 'sent' | 'no_recipients' | 'error'; + error?: string; +} + +/** + * Busca correos adicionales por node_subnode_key (de clientData.NodoSubNodo). + * Si nodeSubnodeKey está vacío usa visibleName como fallback resolviendo por database_name. + */ +async function getAdditionalEmails(nodeSubnodeKey: string, visibleName: string): Promise { + // Búsqueda directa por node_subnode_key (caso normal) + if (nodeSubnodeKey.trim()) { + const r = await pgPool.query( + `SELECT email FROM "a24c"."additional_emails" + WHERE LOWER(BTRIM(node_subnode_key)) = LOWER(BTRIM($1::text))`, + [nodeSubnodeKey] + ); + const rows = (r.rows as { email: string }[]).map((row) => row.email.trim()).filter(Boolean); + if (rows.length > 0) return rows; + } + // Fallback: resolver nodo desde database_name = visibleName + const r = await pgPool.query( + `SELECT ae.email + FROM "a24c"."additional_emails" ae + WHERE LOWER(BTRIM(ae.node_subnode_key)) = ( + SELECT LOWER(BTRIM(dn.node_subnode_key)) + FROM "a24c"."database_nodes" dn + WHERE LOWER(BTRIM(dn.database_name)) = LOWER(BTRIM($1::text)) + OR LOWER(BTRIM(dn.node_subnode_key)) = LOWER(BTRIM($1::text)) + LIMIT 1 + )`, + [visibleName] + ); + return (r.rows as { email: string }[]).map((row) => row.email.trim()).filter(Boolean); +} + +function dedupeEmails(emails: (string | null | undefined)[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const e of emails) { + const norm = (e ?? '').trim().toLowerCase(); + if (norm && !seen.has(norm)) { + seen.add(norm); + result.push((e ?? '').trim()); + } + } + return result; +} + +export const POST: RequestHandler = async ({ request }) => { + let body: { alerts: AlertPayload[] }; + try { + body = await request.json(); + } catch { + return json({ error: 'Body inválido' }, { status: 400 }); + } + + const alerts = body?.alerts; + if (!Array.isArray(alerts) || alerts.length === 0) { + return json({ error: 'Se requiere al menos una alerta' }, { status: 400 }); + } + + const details: SendResult[] = []; + let sent = 0; + let failed = 0; + + for (const alert of alerts) { + const kind: 'overdue' | 'resolved' = alert.kind === 'resolved' ? 'resolved' : 'overdue'; + const nodeId = Number.isInteger(alert.database_node_id) ? (alert.database_node_id as number) : null; + try { + const additional = await getAdditionalEmails(alert.node_subnode_key, alert.visible_name); + const toAddrs = dedupeEmails([alert.mainEmail, ...additional]); + + if (toAddrs.length === 0) { + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: 0, status: 'no_recipients' }); + failed++; + continue; + } + + const rowData = { + visible_name: alert.visible_name, + clientName: alert.clientName, + last_restore_date: alert.last_restore_date, + daysWithout: alert.daysWithout + }; + const nombre = alert.clientName ?? alert.visible_name; + const fecha = formatFechaEs(alert.last_restore_date); + + let subject: string; + let htmlBody: string; + let plainBody: string; + + if (kind === 'resolved') { + subject = `Sincronización Restablecida - ${nombre}`; + htmlBody = buildBackupResolvedHtml([rowData]); + plainBody = `Sincronización Restablecida\n\nEstimado ${nombre},\n\nLa base de datos ${alert.visible_name} volvió a sincronizarse correctamente.\nLa última restauración registrada fue: ${fecha}.\n\nConsulta la última sincronización desde SCAIIWeb: https://a24.aduanasoft.com/SCAIIWeb\n\nNo se requiere ninguna acción de su parte.\n\n© 2024 TransmitirAS. Todos los derechos reservados.`; + } else { + subject = `Alerta de Sincronización - ${nombre}`; + htmlBody = buildBackupAlertHtml([rowData]); + plainBody = `Notificación de Sincronización\n\nEstimado ${nombre},\n\nDetectamos que la base de datos ${alert.visible_name} no se ha sincronizado correctamente en las últimas 24 horas.\nLa última restauración registrada fue: ${fecha}.\n\nConsulta la última sincronización desde SCAIIWeb: https://a24.aduanasoft.com/SCAIIWeb\n\nPor favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.\n\n© 2024 TransmitirAS. Todos los derechos reservados.`; + } + + await sendSmtpEmail({ + toAddrs, + subject, + plainBody, + htmlBody, + fromDisplayName: 'TransmitirAS Notificaciones', + highImportance: false + }); + + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: toAddrs.length, status: 'sent' }); + sent++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: 0, status: 'error', error: msg }); + failed++; + } + } + + return json({ sent, failed, details }); +}; diff --git a/src/routes/api/alerts/send/server.test.ts b/src/routes/api/alerts/send/server.test.ts new file mode 100644 index 0000000..f61582f --- /dev/null +++ b/src/routes/api/alerts/send/server.test.ts @@ -0,0 +1,173 @@ +/** + * Pruebas del endpoint POST /api/alerts/send (sin autenticación — el control es de red). + * + * - Ruteo overdue vs resolved (email-service mockeado). + * - Dedupe principal + adicionales (pgPool mockeado). + * - Echo de database_node_id / kind en details (reconciliación de a24c). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.mock se iza al tope del archivo; las factories no pueden ver variables de +// módulo, así que los mocks se crean con vi.hoisted. +const { queryMock, sendSmtpEmail, buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } = vi.hoisted(() => ({ + queryMock: vi.fn(), + sendSmtpEmail: vi.fn(), + buildBackupAlertHtml: vi.fn(() => ''), + buildBackupResolvedHtml: vi.fn(() => ''), + formatFechaEs: vi.fn(() => '28 de junio de 2026, 10:00') +})); + +vi.mock('$lib/server/db', () => ({ pgPool: { query: queryMock } })); +vi.mock('$lib/server/email-service', () => ({ + sendSmtpEmail, + buildBackupAlertHtml, + buildBackupResolvedHtml, + formatFechaEs +})); + +import { POST } from './+server'; + +function makeRequest(bodyObj: unknown): Request { + return new Request('http://localhost/api/alerts/send', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: typeof bodyObj === 'string' ? bodyObj : JSON.stringify(bodyObj) + }); +} + +async function callPost(req: Request): Promise<{ status: number; body: any }> { + // El handler solo usa `request` del RequestEvent. + const res = await POST({ request: req } as any); + return { status: res.status, body: await res.json() }; +} + +const baseAlert = { + database_node_id: 42, + node_subnode_key: '08037NATM001', + visible_name: 'CLIENTE_DB', + clientName: 'ACME', + mainEmail: 'dest@x.com', + last_restore_date: '2026-06-28T10:00:00Z', + daysWithout: 3 +}; + +beforeEach(() => { + vi.clearAllMocks(); + queryMock.mockResolvedValue({ rows: [] }); // sin correos adicionales por defecto + sendSmtpEmail.mockResolvedValue(undefined); +}); + +describe('POST /api/alerts/send — validación de body', () => { + it('400 si el body no es JSON', async () => { + const { status } = await callPost(makeRequest('{no-json')); + expect(status).toBe(400); + }); + + it('400 si alerts está vacío', async () => { + const { status } = await callPost(makeRequest({ alerts: [] })); + expect(status).toBe(400); + }); +}); + +describe('POST /api/alerts/send — envío y ruteo', () => { + it('overdue: usa plantilla de alerta y responde sent=1', async () => { + const { status, body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, kind: 'overdue' }] }) + ); + expect(status).toBe(200); + expect(buildBackupAlertHtml).toHaveBeenCalledTimes(1); + expect(buildBackupResolvedHtml).not.toHaveBeenCalled(); + expect(sendSmtpEmail).toHaveBeenCalledTimes(1); + expect(sendSmtpEmail.mock.calls[0][0].subject).toBe('Alerta de Sincronización - ACME'); + expect(body.sent).toBe(1); + expect(body.failed).toBe(0); + expect(body.details[0]).toMatchObject({ database_node_id: 42, kind: 'overdue', status: 'sent' }); + }); + + it('kind ausente por defecto es overdue', async () => { + await callPost(makeRequest({ alerts: [baseAlert] })); + expect(buildBackupAlertHtml).toHaveBeenCalledTimes(1); + expect(sendSmtpEmail.mock.calls[0][0].subject).toBe('Alerta de Sincronización - ACME'); + }); + + it('resolved: usa plantilla de normalizado, sin alta importancia', async () => { + const { body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, kind: 'resolved', daysWithout: 0 }] }) + ); + expect(buildBackupResolvedHtml).toHaveBeenCalledTimes(1); + expect(buildBackupAlertHtml).not.toHaveBeenCalled(); + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.subject).toBe('Sincronización Restablecida - ACME'); + expect(arg.highImportance).toBe(false); + expect(body.details[0].kind).toBe('resolved'); + }); + + it('deduplica correo principal + adicionales (case-insensitive)', async () => { + // Primera query (por node_subnode_key) devuelve adicionales, uno duplica el principal. + queryMock.mockResolvedValueOnce({ rows: [{ email: 'DEST@x.com' }, { email: 'extra@x.com' }] }); + await callPost(makeRequest({ alerts: [baseAlert] })); + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.toAddrs).toHaveLength(2); + expect(arg.toAddrs.map((s: string) => s.toLowerCase())).toEqual(['dest@x.com', 'extra@x.com']); + }); + + it('no_recipients: sin correos no envía', async () => { + const { body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, mainEmail: null }] }) + ); + expect(sendSmtpEmail).not.toHaveBeenCalled(); + expect(body.details[0]).toMatchObject({ database_node_id: 42, status: 'no_recipients' }); + expect(body.failed).toBe(1); + }); + + it('error SMTP: status error y el bucle continúa', async () => { + sendSmtpEmail.mockRejectedValueOnce(new Error('smtp caído')); + const alerts = [ + { ...baseAlert, database_node_id: 1, visible_name: 'DB1' }, + { ...baseAlert, database_node_id: 2, visible_name: 'DB2' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + const d1 = body.details.find((d: any) => d.database_node_id === 1); + const d2 = body.details.find((d: any) => d.database_node_id === 2); + expect(d1.status).toBe('error'); + expect(d2.status).toBe('sent'); + expect(body.sent).toBe(1); + expect(body.failed).toBe(1); + }); + + it('multi-nodo: mezcla sent / no_recipients / error emparejada por database_node_id', async () => { + sendSmtpEmail + .mockResolvedValueOnce(undefined) // nodo 1 -> sent + .mockRejectedValueOnce(new Error('x')); // nodo 3 -> error + const alerts = [ + { ...baseAlert, database_node_id: 1, visible_name: 'DB1' }, + { ...baseAlert, database_node_id: 2, visible_name: 'DB2', mainEmail: null }, + { ...baseAlert, database_node_id: 3, visible_name: 'DB3' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + const by = (id: number) => body.details.find((d: any) => d.database_node_id === id); + expect(by(1).status).toBe('sent'); + expect(by(2).status).toBe('no_recipients'); + expect(by(3).status).toBe('error'); + }); + + it('node_subnode_key vacío usa el fallback por database_name (2ª query)', async () => { + // Con key vacío, getAdditionalEmails salta la 1ª query y usa el fallback. + queryMock.mockResolvedValueOnce({ rows: [{ email: 'fallback@x.com' }] }); + await callPost(makeRequest({ alerts: [{ ...baseAlert, node_subnode_key: '' }] })); + expect(queryMock).toHaveBeenCalledTimes(1); + expect(String(queryMock.mock.calls[0][0])).toContain('database_nodes'); // SQL de fallback + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.toAddrs.map((s: string) => s.toLowerCase()).sort()).toEqual(['dest@x.com', 'fallback@x.com']); + }); + + it('database_node_id ausente o no-entero => echo null', async () => { + const alerts = [ + { ...baseAlert, database_node_id: undefined }, + { ...baseAlert, database_node_id: 3.5, visible_name: 'DB2' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + expect(body.details[0].database_node_id).toBeNull(); + expect(body.details[1].database_node_id).toBeNull(); + }); +}); diff --git a/src/routes/api/dashboard.json/+server.ts b/src/routes/api/dashboard.json/+server.ts index 7b7b9e8..61b4fa8 100644 --- a/src/routes/api/dashboard.json/+server.ts +++ b/src/routes/api/dashboard.json/+server.ts @@ -1,58 +1,29 @@ import { json } from '@sveltejs/kit'; -import { db } from '$lib/server/db'; +import { listDatabaseNodesForMssql, lookupAlertClientData } from '$lib/server/controldesk-pg'; +import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; export const GET = async () => { try { - const primary = await db.getSecondary(); + const nodes = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; + const bundle = await loadSqlDashboardFromNodes(nodes); + const { databaseRows, summaryMain, alertsData } = bundle; - const queryMain = ` - SELECT - d.name AS visible_name, - REVERSE(SUBSTRING(REVERSE(mf.physical_name), 1, CHARINDEX('\\', REVERSE(mf.physical_name)) - 1)) AS original_name, - SUM(mf.size * 8 / 1024) AS size_mb, - MAX(rh.restore_date) AS last_restore_date - FROM - sys.databases d - LEFT JOIN - sys.master_files mf ON d.database_id = mf.database_id - LEFT JOIN - msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name - WHERE - mf.type = 0 AND d.name != 'tempdb' - GROUP BY - d.name, mf.physical_name - `; - const resultMain = await primary.request().query(queryMain); - const databaseRows = resultMain.recordset; + // Enriquecer las alertas con datos de cliente/correo (mismo criterio que la carga inicial + // en +page.server.ts). Sin esto, el auto-refresh reemplazaba las alertas por versiones sin + // clientData y la tabla mostraba Cliente y Correo como "N/D" tras el primer refresco. + const enrichedAlerts = await Promise.all( + alertsData.map(async (alert) => { + let clientData: any = null; + try { + clientData = await lookupAlertClientData(String(alert.visible_name)); + } catch { + /* ignore */ + } + return { ...alert, clientData }; + }) + ); - const querySummary = ` - SELECT - COUNT(DISTINCT d.database_id) AS total_databases, - SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb - FROM - sys.databases d - LEFT JOIN - sys.master_files mf ON d.database_id = mf.database_id - WHERE - mf.type = 0 AND d.name != 'tempdb' - `; - const resSummary = await primary.request().query(querySummary); - const summaryMain = resSummary.recordset[0]; - - const sqlAlerts = ` - SELECT - d.name AS visible_name, - MAX(rh.restore_date) AS last_restore_date - FROM sys.databases d - LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name - WHERE d.name != 'tempdb' - GROUP BY d.name - HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL - `; - const resAlerts = await primary.request().query(sqlAlerts); - const alertsData = resAlerts.recordset; - - return json({ databaseRows, summaryMain, alertsData }); + return json({ databaseRows, summaryMain, alertsData: enrichedAlerts }); } catch (e: any) { console.error('Error refreshing dashboard data:', e); return json({ error: 'Error refreshing dashboard data' }, { status: 500 }); diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts index 274bd73..571d028 100644 --- a/src/routes/api/health/+server.ts +++ b/src/routes/api/health/+server.ts @@ -1,18 +1,19 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { pgPool } from '$lib/server/db'; +import { tableDashboardUsers } from '$lib/server/dashboard-pg'; export const GET: RequestHandler = async () => { try { - // Test PostgreSQL const client = await pgPool.connect(); - const result = await client.query('SELECT COUNT(*) as count FROM usuarios'); + const t = tableDashboardUsers(); + const result = await client.query(`SELECT COUNT(*)::int AS count FROM ${t}`); client.release(); - + return json({ status: 'ok', postgres: 'connected', - usuarios: result.rows[0].count + dashboard_users: result.rows[0].count }); } catch (error: any) { return json({ diff --git a/src/routes/api/restore/instance-config/+server.ts b/src/routes/api/restore/instance-config/+server.ts new file mode 100644 index 0000000..5baf5c9 --- /dev/null +++ b/src/routes/api/restore/instance-config/+server.ts @@ -0,0 +1,78 @@ +/** + * POST /api/restore/instance-config + * + * CloudRestoreAS reporta la carpeta de entrada vigente. Autenticado por token + * Bearer (CLOUDRESTORE_API_TOKEN). El panel solo consulta este dato en la UI admin; + * no hay escritura desde el navegador. + * + * Body esperado: + * { + * "input_folder": "\\\\servidor\\compartida\\backups", + * "host_name": "WIN-RESTORE-01", + * "app_version": "1.0.0" + * } + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkServiceToken } from '$lib/server/service-auth'; +import { upsertCloudRestoreStatus } from '$lib/server/controldesk-pg'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; + +interface InstanceConfigPayload { + input_folder?: unknown; + host_name?: unknown; + app_version?: unknown; + instance_key?: unknown; +} + +function asOptionalString(value: unknown): string | null { + if (value === null || value === undefined) return null; + const s = String(value).trim(); + return s ? s : null; +} + +export const POST: RequestHandler = async ({ request }) => { + const traceId = newTraceId(); + + const auth = checkServiceToken(request); + if (!auth.ok) { + if (auth.status === 500) { + logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); + return errorJson(500, 'Servicio no configurado', traceId); + } + return errorJson(401, 'Token de servicio ausente o inválido', traceId); + } + + let body: InstanceConfigPayload; + try { + body = (await request.json()) as InstanceConfigPayload; + } catch { + return errorJson(400, 'Body JSON inválido', traceId); + } + + const inputFolder = asOptionalString(body.input_folder); + if (!inputFolder) { + return errorJson(400, 'El campo input_folder es obligatorio', traceId); + } + + const instanceKey = asOptionalString(body.instance_key) ?? 'default'; + + try { + await upsertCloudRestoreStatus({ + inputFolder, + hostName: asOptionalString(body.host_name), + appVersion: asOptionalString(body.app_version), + instanceKey + }); + return json({ ok: true, trace_id: traceId }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error registrando configuración de instancia CloudRestoreAS', + context: { error: message, input_folder: inputFolder } + }); + return errorJson(500, 'Error interno al registrar la configuración', traceId); + } +}; diff --git a/src/routes/api/restore/job-result/+server.ts b/src/routes/api/restore/job-result/+server.ts new file mode 100644 index 0000000..c27e039 --- /dev/null +++ b/src/routes/api/restore/job-result/+server.ts @@ -0,0 +1,97 @@ +/** + * POST /api/restore/job-result + * + * CloudRestoreAS reporta el resultado de cada restauración. Autenticado por token + * Bearer (CLOUDRESTORE_API_TOKEN). Inserta un registro en a24c.restore_job_logs. + * + * Body esperado: + * { + * "filename": "empresa.bak", + * "restore_target_id": 1 | null, + * "db_name": "EMPRESA_DB" | null, + * "status": "completed" | "failed" | "forwarded", + * "duration_ms": 12345 | null, + * "error_message": "..." | null + * } + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkServiceToken } from '$lib/server/service-auth'; +import { insertRestoreJobLog } from '$lib/server/controldesk-pg'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; + +interface JobResultPayload { + filename?: unknown; + restore_target_id?: unknown; + db_name?: unknown; + status?: unknown; + duration_ms?: unknown; + error_message?: unknown; +} + +function asOptionalInt(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null; + const n = Number(value); + return Number.isInteger(n) ? n : null; +} + +function asOptionalString(value: unknown): string | null { + if (value === null || value === undefined) return null; + const s = String(value).trim(); + return s ? s : null; +} + +export const POST: RequestHandler = async ({ request }) => { + const traceId = newTraceId(); + + const auth = checkServiceToken(request); + if (!auth.ok) { + if (auth.status === 500) { + logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); + return errorJson(500, 'Servicio no configurado', traceId); + } + return errorJson(401, 'Token de servicio ausente o inválido', traceId); + } + + let body: JobResultPayload; + try { + body = (await request.json()) as JobResultPayload; + } catch { + return errorJson(400, 'Body JSON inválido', traceId); + } + + const filename = asOptionalString(body.filename); + if (!filename) { + return errorJson(400, 'El campo filename es obligatorio', traceId); + } + + const status = asOptionalString(body.status); + if (status !== 'completed' && status !== 'failed' && status !== 'forwarded') { + return errorJson( + 422, + "El campo status debe ser 'completed', 'failed' o 'forwarded'", + traceId + ); + } + + try { + await insertRestoreJobLog({ + filename, + restoreTargetId: asOptionalInt(body.restore_target_id), + dbName: asOptionalString(body.db_name), + status, + durationMs: asOptionalInt(body.duration_ms), + errorMessage: asOptionalString(body.error_message) + }); + return json({ ok: true, trace_id: traceId }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error registrando resultado de job', + context: { error: message, filename } + }); + return errorJson(500, 'Error interno al registrar el resultado', traceId); + } +}; diff --git a/src/routes/api/restore/resolve-route/+server.ts b/src/routes/api/restore/resolve-route/+server.ts new file mode 100644 index 0000000..90e6b62 --- /dev/null +++ b/src/routes/api/restore/resolve-route/+server.ts @@ -0,0 +1,81 @@ +/** + * GET /api/restore/resolve-route?filename=&instance= + * + * Resuelve nodo/destino desde el nombre del archivo y devuelve la acción: + * restore_local (restaurar en esta instancia) o forward (SFTP ZIP al destino). + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkServiceToken } from '$lib/server/service-auth'; +import { resolveRouteForFilename } from '$lib/server/controldesk-pg'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; + +export const GET: RequestHandler = async ({ request, url }) => { + const traceId = newTraceId(); + + const auth = checkServiceToken(request); + if (!auth.ok) { + if (auth.status === 500) { + logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); + return errorJson(500, 'Servicio no configurado', traceId); + } + return errorJson(401, 'Token de servicio ausente o inválido', traceId); + } + + const filename = (url.searchParams.get('filename') ?? '').trim(); + if (!filename) { + return errorJson(400, 'Falta el parámetro filename', traceId); + } + + const instance = (url.searchParams.get('instance') ?? '').trim() || null; + + try { + const route = await resolveRouteForFilename(filename, instance); + if (!route) { + return errorJson( + 404, + 'No se encontró nodo/base para el archivo o no tiene servidor asignado', + traceId + ); + } + + if (route.action === 'forward' && !route.target.input_folder) { + return errorJson( + 503, + `El servidor '${route.target.name}' aún no reportó su carpeta de entrada`, + traceId + ); + } + + const t = route.target; + return json({ + action: route.action, + db_name: route.db_name, + node_key: route.node_key, + target: { + id: t.id, + name: t.name, + server: t.server_ip, + username: t.sql_username, + password: t.sql_password, + data_folder: t.data_folder, + ssh_host: t.ssh_host, + ssh_port: t.ssh_port, + ssh_username: t.ssh_username, + ssh_password: t.ssh_password, + remote_inbox_path: t.remote_inbox_path, + input_folder: t.input_folder + }, + trace_id: traceId + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error resolviendo ruta de restauración', + context: { error: message, filename } + }); + return errorJson(500, 'Error interno al resolver la ruta', traceId); + } +}; diff --git a/src/routes/api/restore/target-catalog/+server.ts b/src/routes/api/restore/target-catalog/+server.ts new file mode 100644 index 0000000..b439021 --- /dev/null +++ b/src/routes/api/restore/target-catalog/+server.ts @@ -0,0 +1,41 @@ +/** + * GET /api/restore/target-catalog + * + * Catálogo de servidores de restauración (id + name) para que CloudRestoreAS + * llene el selector de instancia. Sin credenciales. Token Bearer (CLOUDRESTORE_API_TOKEN). + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkServiceToken } from '$lib/server/service-auth'; +import { listRestoreTargets } from '$lib/server/controldesk-pg'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; + +export const GET: RequestHandler = async ({ request }) => { + const traceId = newTraceId(); + + const auth = checkServiceToken(request); + if (!auth.ok) { + if (auth.status === 500) { + logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); + return errorJson(500, 'Servicio no configurado', traceId); + } + return errorJson(401, 'Token de servicio ausente o inválido', traceId); + } + + try { + const rows = await listRestoreTargets(); + return json({ + targets: rows.map((rt) => ({ id: rt.id, name: rt.name })), + trace_id: traceId + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error listando catálogo de servidores de restauración', + context: { error: message } + }); + return errorJson(500, 'Error interno al listar servidores', traceId); + } +}; diff --git a/src/routes/api/restore/target-for/+server.ts b/src/routes/api/restore/target-for/+server.ts new file mode 100644 index 0000000..7a15232 --- /dev/null +++ b/src/routes/api/restore/target-for/+server.ts @@ -0,0 +1,72 @@ +/** + * GET /api/restore/target-for?database= + * + * Endpoint servicio-a-servicio que consume CloudRestoreAS por polling. Dado el nombre + * de la base (database_name o node_subnode_key, lo que CloudRestoreAS resuelve del nombre + * de archivo), devuelve el servidor de restauración ASIGNADO a esa base, con la contraseña + * SQL descifrada. Autenticado por token Bearer (CLOUDRESTORE_API_TOKEN). Nunca al navegador. + * + * Query opcional: instance=Alfa|Omega|Gamma — si la base está asignada a otro servidor → 404. + * 404 si la base no existe o no tiene servidor asignado → CloudRestoreAS difiere el job. + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkServiceToken } from '$lib/server/service-auth'; +import { getRestoreTargetForDatabase } from '$lib/server/controldesk-pg'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; + +export const GET: RequestHandler = async ({ request, url }) => { + const traceId = newTraceId(); + + const auth = checkServiceToken(request); + if (!auth.ok) { + if (auth.status === 500) { + logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); + return errorJson(500, 'Servicio no configurado', traceId); + } + return errorJson(401, 'Token de servicio ausente o inválido', traceId); + } + + const database = (url.searchParams.get('database') ?? '').trim(); + if (!database) { + return errorJson(400, 'Falta el parámetro database', traceId); + } + + const instance = (url.searchParams.get('instance') ?? '').trim() || null; + + try { + const target = await getRestoreTargetForDatabase(database, instance); + if (!target) { + const msg = instance + ? `La base no está asignada al servidor '${instance}' o no tiene servidor de restauración` + : 'La base no tiene servidor de restauración asignado'; + return errorJson(404, msg, traceId); + } + + // Contrato consumido por CloudRestoreAS (app/panel/panel_client.py). + // Modo colocado (instance=Alfa|Omega|Gamma): RESTORE local sin SFTP. + // Modo hub (sin instance): .bak por SFTP a remote_inbox_path. + return json({ + id: target.id, + name: target.name, + server: target.server_ip, + username: target.sql_username, + password: target.sql_password, + data_folder: target.data_folder, + ssh_host: target.ssh_host, + ssh_port: target.ssh_port, + ssh_username: target.ssh_username, + ssh_password: target.ssh_password, + remote_inbox_path: target.remote_inbox_path + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error obteniendo servidor para la base', + context: { error: message, database } + }); + return errorJson(500, 'Error interno al obtener el servidor de la base', traceId); + } +}; diff --git a/src/routes/backup/+server.ts b/src/routes/backup/+server.ts index 98b4807..c4ba12e 100644 --- a/src/routes/backup/+server.ts +++ b/src/routes/backup/+server.ts @@ -1,30 +1,94 @@ import { env } from '$env/dynamic/private'; +import { createReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { Readable } from 'node:stream'; +import { logger } from '$lib/server/logger'; +import { openBackupDownload, BackupDownloadError, type BackupKind } from '$lib/server/restore-fetch'; +import type { RequestHandler } from './$types'; -// GET /backup?file=nombre.bak -> descarga el archivo físico desde BACKUP_PATH -export const GET = async ({ url }: { url: URL }) => { +// GET /backup?target=&kind=&file= +// Descarga por restaurador y RELATIVA (sin rutas fijas): la base la resuelve el servidor +// desde lo que reporta ese restaurador; el archivo se transmite por fs local o SFTP. +// GET /backup?file= (legacy): descarga desde BACKUP_PATH local. +export const GET: RequestHandler = async ({ url, request }) => { const fileName = url.searchParams.get('file'); if (!fileName) { return new Response('Missing file parameter', { status: 400 }); } + const targetRaw = url.searchParams.get('target'); + if (targetRaw) { + return handleByTarget(targetRaw, url.searchParams.get('kind'), fileName, request); + } + return handleLegacyBackupPath(fileName, request); +}; + +/** Descarga por restaurador (procesados/fallados) resolviendo la base al vuelo. */ +async function handleByTarget( + targetRaw: string, + kindRaw: string | null, + fileName: string, + request: Request +): Promise { + const targetId = Number(targetRaw); + if (!Number.isInteger(targetId) || targetId <= 0) { + return new Response('Invalid target parameter', { status: 400 }); + } + const kind: BackupKind = kindRaw === 'fallados' ? 'fallados' : 'procesados'; + + try { + const dl = await openBackupDownload(targetId, kind, fileName); + request.signal.addEventListener('abort', () => dl.cleanup()); + const headers = new Headers(); + headers.set('Content-Type', 'application/octet-stream'); + headers.set('Content-Disposition', `attachment; filename="${dl.filename}"`); + if (dl.size != null) headers.set('Content-Length', String(dl.size)); + return new Response(dl.body, { status: 200, headers }); + } catch (e) { + if (e instanceof BackupDownloadError) { + return new Response(e.message, { status: e.status }); + } + const msg = e instanceof Error ? e.message : String(e); + logger.error({ message: 'Error en descarga por restaurador', context: { error: msg, targetId } }); + return new Response('Backup file not found or inaccessible', { status: 404 }); + } +} + +/** Modo legacy: sirve el archivo desde BACKUP_PATH local (carpeta única configurada). */ +async function handleLegacyBackupPath(fileName: string, request: Request): Promise { const basePath = env.BACKUP_PATH; if (!basePath) { - console.error('BACKUP_PATH is not defined in environment'); + logger.error({ message: 'BACKUP_PATH no está definido en el entorno' }); return new Response('Backup path is not configured', { status: 500 }); } + const resolvedBase = path.resolve(basePath); + const filePath = path.resolve(resolvedBase, fileName); + const relativeToBase = path.relative(resolvedBase, filePath); + if (relativeToBase.startsWith('..') || path.isAbsolute(relativeToBase)) { + return new Response('Invalid file parameter', { status: 400 }); + } + try { - const filePath = path.join(basePath, fileName); - console.log('Serving backup file from', filePath); - const data = await fs.readFile(filePath); + const st = await fs.stat(filePath); + if (!st.isFile()) { + return new Response('Backup file not found or inaccessible', { status: 404 }); + } + const downloadName = path.basename(filePath); + const nodeStream = createReadStream(filePath, { highWaterMark: 1024 * 1024 }); + request.signal.addEventListener('abort', () => { + nodeStream.destroy(); + }); + const body = Readable.toWeb(nodeStream) as unknown as ReadableStream; const headers = new Headers(); headers.set('Content-Type', 'application/octet-stream'); - headers.set('Content-Disposition', `attachment; filename="${fileName}"`); - return new Response(data, { status: 200, headers }); - } catch (e: any) { - console.error('Error serving backup file:', e?.message ?? e); + headers.set('Content-Disposition', `attachment; filename="${downloadName}"`); + headers.set('Content-Length', String(st.size)); + return new Response(body, { status: 200, headers }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error({ message: 'Error al servir archivo de respaldo', context: { error: msg } }); return new Response('Backup file not found or inaccessible', { status: 404 }); } -}; +} diff --git a/src/routes/reportes/+page.server.ts b/src/routes/reportes/+page.server.ts new file mode 100644 index 0000000..e9c5525 --- /dev/null +++ b/src/routes/reportes/+page.server.ts @@ -0,0 +1,34 @@ +import { listDatabaseNodesForMssql } from '$lib/server/controldesk-pg'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies }) => { + const token = cookies.get('session_token'); + if (!token) throw redirect(303, '/login'); + + const session = verifyToken(token); + if (!session) throw redirect(303, '/login'); + + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.activo) throw redirect(303, '/login'); + // Reportes es solo para administradores (autorización en backend, no solo ocultar el enlace). + if (!currentUser.es_admin) throw redirect(303, '/'); + + // Obtener lista de bases activas para mostrar en UI + let bases: { ID: number; Nombre: string; NodoSubNodo: string; BDName: string }[] = []; + let errorBases: string | null = null; + + try { + bases = await listDatabaseNodesForMssql(); + } catch (e: any) { + errorBases = `No se pudo cargar el catálogo de bases: ${e.message}`; + } + + return { + user: { username: currentUser.username, es_admin: currentUser.es_admin }, + bases, + errorBases + }; +}; diff --git a/src/routes/reportes/+page.svelte b/src/routes/reportes/+page.svelte new file mode 100644 index 0000000..045a2a5 --- /dev/null +++ b/src/routes/reportes/+page.svelte @@ -0,0 +1,484 @@ + + + + Reportes – TransmitirAS + + + + + +
+ + +
+

Reportes

+

+ Genera un Excel consolidado con el conteo de pedimentos de importación + (tipo = 'I') + por año, mes y clave de pedimento. +

+
+ + +
+ + +
+
+ assessment +
+
+

Pedimentos de Importación por Nodo

+

SPedimentos WHERE tipo = 'I' — agrupado por año / mes / CLAVEPED

+
+
+ +
+ + +
+

+ Bases de datos a incluir +

+ + +
+ + + + + + {#if dropdownOpen} +
e.stopPropagation()} + > + +
+
+ search + + {#if busqueda} + + {/if} +
+
+ + +
+ +
+ + +
    + {#each basesFiltradas as base} + {@const checked = seleccionadas.has(base.BDName)} +
  • + +
  • + {:else} +
  • + Sin resultados para "{busqueda}" +
  • + {/each} +
+ + +
+ + {seleccionadas.size === 0 ? 'Se incluirán todas' : `${seleccionadas.size} de ${data.bases.length} seleccionadas`} + + +
+
+ {/if} +
+ + + {#if seleccionadas.size > 0 && seleccionadas.size < data.bases.length} +
+ {#each [...seleccionadas] as bd} + {@const base = data.bases.find(b => b.BDName === bd)} + + {base?.NodoSubNodo ?? bd} + + + {/each} + +
+ {/if} +
+ + +
+

Filtros de fecha (opcionales)

+
+
+ + +
+ +
+ + +
+
+
+ + + {#if data.errorBases} +
+ warning + {data.errorBases} +
+ {/if} + {#if msgError} +
+ error_outline + {msgError} +
+ {/if} + + +
+ +
+
+
+ + + {#if data.user?.es_admin} +
+ + +
+
+
+ groups +
+
+

Clientes (Administrativos)

+

Catálogo completo de clientes con nodo, RFC, correo y estatus

+
+
+
+ +
+
+ + +
+
+
+ badge +
+
+

Nodos y Usuarios (Asesores)

+

Usuarios cliente y autoridad por nodo

+
+
+
+
+ lock + No incluye contraseñas: se almacenan con hash bcrypt (irreversible) y son confidenciales. +
+ +
+
+ +
+ {/if} + +
+
diff --git a/src/routes/reportes/clientes/+server.ts b/src/routes/reportes/clientes/+server.ts new file mode 100644 index 0000000..2442d07 --- /dev/null +++ b/src/routes/reportes/clientes/+server.ts @@ -0,0 +1,73 @@ +/** + * Reporte administrativo: catálogo completo de clientes (a24c.database_nodes). + * Solo admin. Sin datos sensibles (no incluye credenciales). + */ +import { listClientsCatalog } from '$lib/server/controldesk-pg'; +import { getAdminFromCookies, styleHeader, styleDataRow } from '$lib/server/report-excel'; +import type { RequestHandler } from './$types'; +import ExcelJS from 'exceljs'; + +export const GET: RequestHandler = async ({ cookies }) => { + const admin = await getAdminFromCookies(cookies); + if (!admin) { + return new Response(JSON.stringify({ error: 'No autorizado: requiere sesión de administrador.' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + let clientes: any[]; + try { + clientes = await listClientsCatalog(); + } catch (e: any) { + return new Response(JSON.stringify({ error: `Error obteniendo catálogo de clientes: ${e.message}` }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Orden estable por nodo para lectura administrativa. + clientes.sort((a, b) => + String(a.NodoSubNodo ?? '').localeCompare(String(b.NodoSubNodo ?? '')) + ); + + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'TransmitirAS - Panel CPANEL'; + workbook.created = new Date(); + workbook.modified = new Date(); + + const ws = workbook.addWorksheet('Clientes', { views: [{ state: 'frozen', ySplit: 1 }] }); + ws.columns = [ + { header: 'Nodo/SubNodo', key: 'NodoSubNodo', width: 22 }, + { header: 'Cliente', key: 'Nombre', width: 36 }, + { header: 'Correo de Notificación', key: 'CorreoNotificacion', width: 34 }, + { header: 'Base de Datos', key: 'BDName', width: 24 }, + { header: 'Estatus', key: 'Estatus', width: 12 } + ]; + styleHeader(ws.getRow(1)); + + clientes.forEach((c, i) => { + const row = ws.addRow({ + NodoSubNodo: c.NodoSubNodo ?? '', + Nombre: c.Nombre ?? '', + CorreoNotificacion: c.CorreoNotificacion ?? '', + BDName: c.BDName ?? '', + // is_active puede venir como 1/0 o booleano según el origen. + Estatus: Number(c.Activo) === 1 || c.Activo === true ? 'Activo' : 'Inactivo' + }); + styleDataRow(row, i); + }); + + ws.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } }; + + const buffer = await workbook.xlsx.writeBuffer(); + const fechaHoy = new Date().toISOString().slice(0, 10); + + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="reporte_clientes_${fechaHoy}.xlsx"`, + 'Cache-Control': 'no-store' + } + }); +}; diff --git a/src/routes/reportes/excel/+server.ts b/src/routes/reportes/excel/+server.ts new file mode 100644 index 0000000..dd8d46c --- /dev/null +++ b/src/routes/reportes/excel/+server.ts @@ -0,0 +1,255 @@ +import { listDatabaseNodesForMssql } from '$lib/server/controldesk-pg'; +import { getMssqlPoolMaster, resolveNodeSqlPassword } from '$lib/server/mssql-nodes'; +import { getAdminFromCookies } from '$lib/server/report-excel'; +import type { RequestHandler } from './$types'; +import ExcelJS from 'exceljs'; + +const QUERY_PEDIMENTOS = ` + SELECT + YEAR(FECHA_PAGO_ISO) AS anio, + MONTH(FECHA_PAGO_ISO) AS mes, + CLAVEPED, + COUNT(*) AS total_pedimentos + FROM SPedimentos + WHERE tipo = 'I' + GROUP BY + YEAR(FECHA_PAGO_ISO), + MONTH(FECHA_PAGO_ISO), + CLAVEPED + ORDER BY + anio, + mes, + CLAVEPED +`; + +const MONTH_NAMES = [ + '', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre' +]; + +function styleHeader(row: ExcelJS.Row) { + row.eachCell((cell) => { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E293B' } }; + cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 }; + cell.alignment = { vertical: 'middle', horizontal: 'center' }; + cell.border = { + bottom: { style: 'thin', color: { argb: 'FF94A3B8' } } + }; + }); + row.height = 20; +} + +export const GET: RequestHandler = async ({ cookies, url }) => { + // Reporte solo para administradores (autorización en backend, no solo ocultar el enlace). + const admin = await getAdminFromCookies(cookies); + if (!admin) { + return new Response(JSON.stringify({ error: 'No autorizado: requiere sesión de administrador.' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Parámetros de filtro opcionales + const filterAnio = url.searchParams.get('anio') || ''; + const filterMes = url.searchParams.get('mes') || ''; + // Bases seleccionadas (array de BDName); vacío = todas + const filterBases = url.searchParams.getAll('bd'); + + // 1. Catálogo de bases activas (PostgreSQL a24c.database_nodes) + let bases: { ID: number; Nombre: string; NodoSubNodo: string; BDName: string; ServerName: string }[] = []; + try { + const all = await listDatabaseNodesForMssql(); + if (filterBases.length > 0) { + bases = all.filter((b: any) => filterBases.includes(b.BDName)); + } else { + bases = all; + } + } catch (e: any) { + return new Response(JSON.stringify({ error: `Error obteniendo catálogo de bases: ${e.message}` }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + type ResultRow = { + nodo: string; + nombre: string; + bdName: string; + anio: number; + mes: number; + mesNombre: string; + claveped: string; + total: number; + }; + + const consolidado: ResultRow[] = []; + const errores: { nodo: string; bdName: string; error: string }[] = []; + + for (const base of bases) { + try { + const pool = await getMssqlPoolMaster( + String(base.ServerName || ''), + resolveNodeSqlPassword((base as { sql_password?: string | null }).sql_password) + ); + let query = ` + SELECT + YEAR(FECHA_PAGO_ISO) AS anio, + MONTH(FECHA_PAGO_ISO) AS mes, + CLAVEPED, + COUNT(*) AS total_pedimentos + FROM [${base.BDName}].[dbo].[SPedimentos] + WHERE tipo = 'I' + `; + + if (filterAnio) query += ` AND YEAR(FECHA_PAGO_ISO) = ${parseInt(filterAnio)}`; + if (filterMes) query += ` AND MONTH(FECHA_PAGO_ISO) = ${parseInt(filterMes)}`; + + query += ` + GROUP BY + YEAR(FECHA_PAGO_ISO), + MONTH(FECHA_PAGO_ISO), + CLAVEPED + ORDER BY + anio, mes, CLAVEPED + `; + + const result = await pool.request().query(query); + + for (const row of result.recordset) { + consolidado.push({ + nodo: base.NodoSubNodo, + nombre: base.Nombre, + bdName: base.BDName, + anio: row.anio, + mes: row.mes, + mesNombre: MONTH_NAMES[row.mes] ?? String(row.mes), + claveped: row.CLAVEPED, + total: row.total_pedimentos + }); + } + } catch (e: any) { + errores.push({ nodo: base.NodoSubNodo, bdName: base.BDName, error: e.message }); + } + } + + // 3. Generar Excel + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'TransmitirAS - Panel CPANEL'; + workbook.created = new Date(); + workbook.modified = new Date(); + + // ── Hoja: Consolidado ────────────────────────────────────────────────── + const wsConsolidado = workbook.addWorksheet('Consolidado', { + views: [{ state: 'frozen', ySplit: 1 }] + }); + + wsConsolidado.columns = [ + { header: 'Nodo/SubNodo', key: 'nodo', width: 22 }, + { header: 'Cliente', key: 'nombre', width: 28 }, + { header: 'Base de Datos', key: 'bdName', width: 22 }, + { header: 'Año', key: 'anio', width: 8 }, + { header: 'Mes (Núm)', key: 'mes', width: 10 }, + { header: 'Mes', key: 'mesNombre', width: 14 }, + { header: 'Clave Pedimento', key: 'claveped', width: 18 }, + { header: 'Total Pedimentos', key: 'total', width: 18 } + ]; + + styleHeader(wsConsolidado.getRow(1)); + + consolidado.forEach((row, i) => { + const excelRow = wsConsolidado.addRow(row); + const fill: ExcelJS.FillPattern = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: i % 2 === 0 ? 'FFF8FAFC' : 'FFFFFFFF' } + }; + excelRow.eachCell((cell) => { + cell.fill = fill; + cell.alignment = { vertical: 'middle' }; + cell.border = { bottom: { style: 'hair', color: { argb: 'FFE2E8F0' } } }; + }); + excelRow.height = 16; + }); + + // Totales por nodo (fila al final) + wsConsolidado.addRow([]); + const totalRow = wsConsolidado.addRow({ + nodo: 'TOTAL GENERAL', + total: consolidado.reduce((sum, r) => sum + r.total, 0) + }); + totalRow.getCell('nodo').font = { bold: true }; + totalRow.getCell('total').font = { bold: true }; + totalRow.getCell('total').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDBEAFE' } }; + + // Auto-filter en consolidado + wsConsolidado.autoFilter = { + from: { row: 1, column: 1 }, + to: { row: 1, column: 8 } + }; + + // ── Hoja por base de datos ───────────────────────────────────────────── + const baseNames = [...new Set(consolidado.map(r => r.bdName))]; + + for (const bdName of baseNames) { + const filas = consolidado.filter(r => r.bdName === bdName); + const nodo = filas[0]?.nodo ?? bdName; + const sheetName = (nodo.length > 28 ? nodo.slice(0, 28) : nodo).replace(/[\\/*?[\]]/g, '_'); + + const ws = workbook.addWorksheet(sheetName, { + views: [{ state: 'frozen', ySplit: 1 }] + }); + + ws.columns = [ + { header: 'Año', key: 'anio', width: 8 }, + { header: 'Mes (Núm)', key: 'mes', width: 10 }, + { header: 'Mes', key: 'mesNombre', width: 14 }, + { header: 'Clave Pedimento', key: 'claveped', width: 18 }, + { header: 'Total Pedimentos', key: 'total', width: 18 } + ]; + + styleHeader(ws.getRow(1)); + + filas.forEach((row, i) => { + const excelRow = ws.addRow(row); + const fill: ExcelJS.FillPattern = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: i % 2 === 0 ? 'FFF8FAFC' : 'FFFFFFFF' } + }; + excelRow.eachCell((cell) => { + cell.fill = fill; + cell.alignment = { vertical: 'middle' }; + cell.border = { bottom: { style: 'hair', color: { argb: 'FFE2E8F0' } } }; + }); + excelRow.height = 16; + }); + + ws.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } }; + } + + // ── Hoja: Errores ────────────────────────────────────────────────────── + if (errores.length > 0) { + const wsErr = workbook.addWorksheet('Errores'); + wsErr.columns = [ + { header: 'Nodo/SubNodo', key: 'nodo', width: 22 }, + { header: 'Base de Datos', key: 'bdName', width: 22 }, + { header: 'Error', key: 'error', width: 60 } + ]; + styleHeader(wsErr.getRow(1)); + errores.forEach(e => wsErr.addRow(e)); + } + + // ── Serializar y devolver ────────────────────────────────────────────── + const buffer = await workbook.xlsx.writeBuffer(); + + const fechaHoy = new Date().toISOString().slice(0, 10); + const filename = `reporte_pedimentos_${fechaHoy}.xlsx`; + + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store' + } + }); +}; diff --git a/src/routes/reportes/usuarios/+server.ts b/src/routes/reportes/usuarios/+server.ts new file mode 100644 index 0000000..34d491e --- /dev/null +++ b/src/routes/reportes/usuarios/+server.ts @@ -0,0 +1,121 @@ +/** + * Reporte para asesores: nodos con sus usuarios de portal (cliente y autoridad). + * Solo admin. NO incluye contraseñas: portal_users.password_hash es bcrypt + * (irreversible) y las credenciales de cliente son confidenciales. + * + * Dos hojas: + * - "Usuarios": una fila por usuario (cliente/autoridad) con su nodo. + * - "Nodos": catálogo de nodos con el conteo de usuarios cliente/autoridad. + */ +import { listPortalUsersWithNode, listClientsCatalog } from '$lib/server/controldesk-pg'; +import { getAdminFromCookies, styleHeader, styleDataRow } from '$lib/server/report-excel'; +import type { RequestHandler } from './$types'; +import ExcelJS from 'exceljs'; + +// is_authority_client == 1 → Autoridad; cualquier otro valor → Cliente (paridad con la UI). +function tipoUsuario(clienteAutoridad: unknown): 'Autoridad' | 'Cliente' { + return Number(clienteAutoridad) === 1 ? 'Autoridad' : 'Cliente'; +} + +export const GET: RequestHandler = async ({ cookies }) => { + const admin = await getAdminFromCookies(cookies); + if (!admin) { + return new Response(JSON.stringify({ error: 'No autorizado: requiere sesión de administrador.' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + let usuarios: any[]; + let nodos: any[]; + try { + [usuarios, nodos] = await Promise.all([listPortalUsersWithNode(), listClientsCatalog()]); + } catch (e: any) { + return new Response(JSON.stringify({ error: `Error obteniendo usuarios/nodos: ${e.message}` }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'TransmitirAS - Panel CPANEL'; + workbook.created = new Date(); + workbook.modified = new Date(); + + // ── Hoja: Usuarios (cliente/autoridad) ───────────────────────────────── + const wsUsuarios = workbook.addWorksheet('Usuarios', { views: [{ state: 'frozen', ySplit: 1 }] }); + wsUsuarios.columns = [ + { header: 'Nodo/SubNodo', key: 'NodoSubNodo', width: 22 }, + { header: 'Cliente', key: 'Cliente', width: 32 }, + { header: 'Tipo', key: 'Tipo', width: 12 }, + { header: 'Usuario', key: 'Usuario', width: 24 }, + { header: 'Nombre', key: 'Nombre', width: 30 }, + { header: 'BD Shelter', key: 'BD_Shelter', width: 20 }, + { header: 'Base de Datos', key: 'BDName', width: 22 } + ]; + styleHeader(wsUsuarios.getRow(1)); + + usuarios.forEach((u, i) => { + const row = wsUsuarios.addRow({ + NodoSubNodo: u.NodoSubNodo ?? '—', + Cliente: u.Cliente ?? '', + Tipo: tipoUsuario(u.ClienteAutoridad), + Usuario: u.Usuario ?? '', + Nombre: u.Nombre ?? '', + BD_Shelter: u.BD_Shelter ?? '', + BDName: u.BDName ?? '' + }); + styleDataRow(row, i); + }); + wsUsuarios.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 7 } }; + + // ── Hoja: Nodos (con conteo de usuarios por tipo) ─────────────────────── + const conteo = new Map(); + for (const u of usuarios) { + // Agrupar por nodo usando la clave de nodo (los usuarios sin nodo quedan fuera del conteo por nodo). + const key = String(u.NodoSubNodo ?? ''); + if (!key) continue; + const acc = conteo.get(key) ?? { cliente: 0, autoridad: 0 }; + if (tipoUsuario(u.ClienteAutoridad) === 'Autoridad') acc.autoridad += 1; + else acc.cliente += 1; + conteo.set(key, acc); + } + + nodos.sort((a, b) => String(a.NodoSubNodo ?? '').localeCompare(String(b.NodoSubNodo ?? ''))); + + const wsNodos = workbook.addWorksheet('Nodos', { views: [{ state: 'frozen', ySplit: 1 }] }); + wsNodos.columns = [ + { header: 'Nodo/SubNodo', key: 'NodoSubNodo', width: 22 }, + { header: 'Cliente', key: 'Nombre', width: 36 }, + { header: 'Base de Datos', key: 'BDName', width: 24 }, + { header: 'Estatus', key: 'Estatus', width: 12 }, + { header: 'Usuarios Cliente', key: 'UsuariosCliente', width: 16 }, + { header: 'Usuarios Autoridad', key: 'UsuariosAutoridad', width: 18 } + ]; + styleHeader(wsNodos.getRow(1)); + + nodos.forEach((n, i) => { + const c = conteo.get(String(n.NodoSubNodo ?? '')) ?? { cliente: 0, autoridad: 0 }; + const row = wsNodos.addRow({ + NodoSubNodo: n.NodoSubNodo ?? '', + Nombre: n.Nombre ?? '', + BDName: n.BDName ?? '', + Estatus: Number(n.Activo) === 1 || n.Activo === true ? 'Activo' : 'Inactivo', + UsuariosCliente: c.cliente, + UsuariosAutoridad: c.autoridad + }); + styleDataRow(row, i); + }); + wsNodos.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 6 } }; + + const buffer = await workbook.xlsx.writeBuffer(); + const fechaHoy = new Date().toISOString().slice(0, 10); + + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="reporte_nodos_usuarios_${fechaHoy}.xlsx"`, + 'Cache-Control': 'no-store' + } + }); +}; diff --git a/src/routes/servidores-restauracion/+page.server.ts b/src/routes/servidores-restauracion/+page.server.ts new file mode 100644 index 0000000..830974b --- /dev/null +++ b/src/routes/servidores-restauracion/+page.server.ts @@ -0,0 +1,270 @@ +/** + * Configuración de los servidores de restauración (Alfa/Omega/Gamma). Solo administradores. + * Aquí se editan IP, credenciales y rutas de cada servidor. La asignación servidor↔base + * se hace en "Gestión de Bases de Datos" (radio por base); aquí NO se elige un destino global. + */ +import { redirect, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { + listRestoreTargets, + listCloudRestoreStatuses, + createRestoreTarget, + updateRestoreTarget, + deleteRestoreTarget, + listNodesForAssignment, + assignNodesToRestoreTarget, + applyNodeAssignments, + listRestoreJobLogSummaries, + type RestoreTargetInput +} from '$lib/server/controldesk-pg'; + +async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) { + const token = cookies.get('session_token'); + if (!token) throw redirect(303, '/login'); + + const session = verifyToken(token); + if (!session) throw redirect(303, '/login'); + + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) throw redirect(303, '/'); + return currentUser; +} + +export const load: PageServerLoad = async ({ cookies }) => { + const currentUser = await requireAdmin(cookies); + let dbWarning: string | null = null; + + let targets: Awaited> = []; + try { + targets = await listRestoreTargets(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error('Error listando restore_targets:', e); + dbWarning = `No se pudo cargar servidores de restauración: ${msg}`; + } + + let cloudRestoreStatuses: Awaited> = []; + try { + cloudRestoreStatuses = await listCloudRestoreStatuses(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error('Error leyendo cloudrestore_status:', e); + dbWarning = dbWarning ?? `No se pudo leer el estado de CloudRestoreAS: ${msg}`; + } + + // Nodos (bases) para el checklist y la distribución. Sin tamaños: esos se cargan bajo + // demanda desde /servidores-restauracion/node-sizes al abrir la distribución automática. + let nodes: Awaited> = []; + try { + nodes = await listNodesForAssignment(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error('Error listando nodos para asignación:', e); + dbWarning = dbWarning ?? `No se pudieron cargar los nodos: ${msg}`; + } + + // Resumen de bitácora por servidor (30 días + última exitosa); vacío si no hay tabla. + let restoreLogSummaries: Awaited> = []; + try { + restoreLogSummaries = await listRestoreJobLogSummaries(); + } catch (e: unknown) { + console.error('Error leyendo resumen de restore_job_logs:', e); + } + + return { targets, currentUser, cloudRestoreStatuses, nodes, restoreLogSummaries, dbWarning }; +}; + +/** Extrae y valida los campos del formulario. Devuelve el input o un mensaje de error. */ +function parseTargetForm( + data: FormData, + { requirePassword }: { requirePassword: boolean } +): { input: RestoreTargetInput } | { error: string } { + const name = data.get('name')?.toString().trim(); + const server_ip = data.get('server_ip')?.toString().trim(); + const sql_username = data.get('sql_username')?.toString().trim(); + const sql_password = data.get('sql_password')?.toString() ?? ''; + const data_folder = data.get('data_folder')?.toString().trim(); + const ssh_host = data.get('ssh_host')?.toString().trim(); + const ssh_port_raw = data.get('ssh_port')?.toString().trim(); + const ssh_username = data.get('ssh_username')?.toString().trim(); + const ssh_password = data.get('ssh_password')?.toString() ?? ''; + const remote_inbox_path = data.get('remote_inbox_path')?.toString().trim(); + const notes = data.get('notes')?.toString().trim() || null; + + // Características de hardware opcionales (para distribuir bases por capacidad). + const os = data.get('os')?.toString().trim() || null; + const location = data.get('location')?.toString().trim() || null; + const ram_raw = data.get('ram_gb')?.toString().trim(); + const disk_raw = data.get('disk_gb')?.toString().trim(); + const size_category_raw = data.get('size_category')?.toString().trim() || null; + + if (!name || !server_ip || !sql_username || !data_folder || !ssh_host || !ssh_username || !remote_inbox_path) { + return { error: 'Todos los campos salvo notas, hardware y contraseñas (en edición) son obligatorios.' }; + } + const ssh_port = ssh_port_raw ? parseInt(ssh_port_raw, 10) : 22; + if (!Number.isInteger(ssh_port) || ssh_port < 1 || ssh_port > 65535) { + return { error: 'El puerto SSH debe estar entre 1 y 65535.' }; + } + if (requirePassword && (!sql_password || !ssh_password)) { + return { error: 'Las contraseñas SQL y SSH son obligatorias al crear un servidor.' }; + } + + // ram/disk: enteros opcionales, sanos (0 = sin capturar -> null). + let ram_gb: number | null = null; + if (ram_raw) { + const n = parseInt(ram_raw, 10); + if (!Number.isInteger(n) || n < 0 || n > 100000) return { error: 'RAM (GB) inválida.' }; + ram_gb = n > 0 ? n : null; + } + let disk_gb: number | null = null; + if (disk_raw) { + const n = parseInt(disk_raw, 10); + if (!Number.isInteger(n) || n < 0 || n > 1000000) return { error: 'Disco (GB) inválido.' }; + disk_gb = n > 0 ? n : null; + } + const ALLOWED_CATEGORIES = ['Chico', 'Mediano', 'Grande']; + if (size_category_raw && !ALLOWED_CATEGORIES.includes(size_category_raw)) { + return { error: 'Categoría de tamaño inválida.' }; + } + const size_category = size_category_raw; + + const input: RestoreTargetInput = { + name, + server_ip, + sql_username, + data_folder, + ssh_host, + ssh_port, + ssh_username, + remote_inbox_path, + notes, + os, + ram_gb, + disk_gb, + location, + size_category + }; + // Solo incluir cada contraseña si se proporcionó (en edición vacía = no cambiar). + if (sql_password) input.sql_password = sql_password; + if (ssh_password) input.ssh_password = ssh_password; + return { input }; +} + +export const actions: Actions = { + create: async ({ request, cookies }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const parsed = parseTargetForm(data, { requirePassword: true }); + if ('error' in parsed) return fail(400, { error: parsed.error }); + + try { + await createRestoreTarget(parsed.input); + return { success: true }; + } catch (err: any) { + return fail(500, { error: err?.message || 'Error al crear el servidor.' }); + } + }, + + update: async ({ request, cookies }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseInt(data.get('id')?.toString() || '0'); + if (!id) return fail(400, { error: 'ID de servidor requerido.' }); + + const parsed = parseTargetForm(data, { requirePassword: false }); + if ('error' in parsed) return fail(400, { error: parsed.error }); + + try { + await updateRestoreTarget(id, parsed.input); + return { success: true }; + } catch (err: any) { + return fail(500, { error: err?.message || 'Error al actualizar el servidor.' }); + } + }, + + delete: async ({ request, cookies }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseInt(data.get('id')?.toString() || '0'); + if (!id) return fail(400, { error: 'ID de servidor requerido.' }); + + // Las bases asignadas a este servidor quedan sin asignar (FK ON DELETE SET NULL). + try { + await deleteRestoreTarget(id); + return { success: true }; + } catch (err: any) { + return fail(500, { error: err?.message || 'Error al eliminar el servidor.' }); + } + }, + + /** Guarda el checklist de un restaurador (asignar marcados, quitar desmarcados de este server). */ + assignNodes: async ({ request, cookies }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const targetId = parseInt(data.get('targetId')?.toString() || '0'); + if (!targetId) return fail(400, { error: 'ID de servidor requerido.' }); + + let nodeIds: number[]; + try { + const raw = data.get('nodeIds')?.toString() || '[]'; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) throw new Error('nodeIds no es un arreglo'); + nodeIds = parsed.map((n) => Number(n)).filter((n) => Number.isInteger(n) && n > 0); + } catch { + return fail(400, { error: 'Lista de nodos inválida.' }); + } + + try { + await assignNodesToRestoreTarget(targetId, nodeIds); + return { success: true }; + } catch (err: any) { + console.error('Error asignando nodos:', err); + return fail(500, { error: err?.message || 'Error al guardar la asignación.' }); + } + }, + + /** Aplica una distribución (global o automática): pares [{nodeId, targetId|null}]. */ + applyDistribution: async ({ request, cookies }) => { + await requireAdmin(cookies); + const data = await request.formData(); + + let pairs: { nodeId: number; targetId: number | null }[]; + try { + const raw = data.get('pairs')?.toString() || '[]'; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) throw new Error('pairs no es un arreglo'); + pairs = parsed + .map((p: any) => ({ + nodeId: Number(p?.nodeId), + targetId: p?.targetId == null ? null : Number(p.targetId) + })) + .filter((p) => Number.isInteger(p.nodeId) && p.nodeId > 0); + } catch { + return fail(400, { error: 'Distribución inválida.' }); + } + if (pairs.length === 0) return fail(400, { error: 'No hay asignaciones para aplicar.' }); + + // Defensa: la distribución solo puede apuntar a servidores existentes CON disco capturado + // (aunque el cliente esté desincronizado). Los pares a target null (desasignar) se permiten. + try { + const targets = await listRestoreTargets(); + const distributableIds = new Set( + targets.filter((t) => t.disk_gb != null && t.disk_gb > 0).map((t) => t.id) + ); + const valid = pairs.filter((p) => p.targetId == null || distributableIds.has(p.targetId)); + const dropped = pairs.length - valid.length; + if (valid.length === 0) { + return fail(400, { + error: 'Ninguna asignación apunta a un servidor con capacidad (Disco) capturada.' + }); + } + await applyNodeAssignments(valid); + return { success: true, applied: valid.length, dropped }; + } catch (err: any) { + console.error('Error aplicando distribución:', err); + return fail(500, { error: err?.message || 'Error al aplicar la distribución.' }); + } + } +}; diff --git a/src/routes/servidores-restauracion/+page.svelte b/src/routes/servidores-restauracion/+page.svelte new file mode 100644 index 0000000..ca82276 --- /dev/null +++ b/src/routes/servidores-restauracion/+page.svelte @@ -0,0 +1,1636 @@ + + + + + +
+
+
+

Servidores de Restauración

+

+ Configura IP, credenciales, rutas y capacidad de cada servidor, y asigna qué bases + restaura cada uno (checklist o distribución automática por capacidad). +

+
+ +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + {#if form?.success} +
+ Operación realizada correctamente. +
+ {/if} + {#if successMsg} +
+ check_circle + {successMsg} +
+ {/if} + {#if data.dbWarning} +
+ {data.dbWarning} +
+ {/if} + + +
+

Servidores

+ {#if targets.length === 0} +

+ Crea un servidor con + Nuevo servidor. +

+ {:else} +
+ {#each targets as t (t.id)} + {@const cra = statusForTarget(t.name)} + {@const summary = logSummaryByTarget.get(t.id)} + {@const stats = assignedStats(t)} +
+
+ {t.name} + {#if cra && !isStaleStatus(cra)} + + Reportada + + {:else} + + Sin reportar + + {/if} +
+ {#if !hasCapacity(t)} +

+ Sin capacidad — captura Disco (GB) para distribuir +

+ {/if} +
+
+
Sistema
+
{t.os ?? '—'}
+
+
+
RAM
+
{t.ram_gb != null ? `${t.ram_gb} GB` : '—'}
+
+
+
Disco
+
{t.disk_gb != null ? `${t.disk_gb} GB` : '—'}
+
+
+
Ubicación
+
{t.location ?? '—'}
+
+
+
Carpeta entrada
+
{cra?.input_folder ?? '—'}
+
+
+
Actualizado
+
{formatReportedAt(cra?.reported_at)}
+
+
+
CloudRestore
+
+ {cra?.app_version ? `v${cra.app_version}` : '—'}{cra?.host_name ? ` · ${cra.host_name}` : ''} +
+
+
+
Última rest. OK
+
{formatReportedAt(summary?.last_completed_at)}
+
+
+
30 días
+
+ {#if summary} + {summary.completed} ok + · 0 ? 'text-red-600' : 'text-slate-500'}>{summary.failed} err + {:else} + — + {/if} +
+
+
+
Bases asignadas
+
+ {stats.count} + {#if stats.totalMb != null && hasCapacity(t)} + · ≈{formatSize(stats.totalMb)} de {t.disk_gb} GB + ({Math.round((stats.totalMb / ((t.disk_gb ?? 1) * 1024)) * 100)}%) + {:else if stats.count > 0 && !sizesLoaded} + · + {/if} +
+
+
+
+ + + +
+
+ {/each} +
+ {/if} +
+ + +
+
+
+

Distribución automática (todos los servidores)

+

+ Reparte las bases entre los servidores de forma balanceada y proporcional a su disco. + Revisa la propuesta antes de aplicar. +

+
+ +
+ + {#if showGlobal} +
+
+ + + + + + + {#if sizesLoadedAt} + + Tamaños de las {sizesLoadedAt.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })} + + {/if} +
+ + {#if distributableServers.length === 0} +
+ Ningún servidor tiene Disco (GB) capturado. Captura la capacidad en + Editar → Datos para habilitar la distribución. +
+ {/if} + + {#if globalError} +
{globalError}
+ {/if} + {#if globalSuccess} +
{globalSuccess}
+ {/if} + + {#if globalPreview} + {#if globalPreview.omitted.length > 0} +
+ Servidores omitidos (sin Disco capturado): + {globalPreview.omitted.map((t) => t.name).join(', ')} +
+ {/if} + +
+ {#each globalByServer as s (s.name)} +
+ {s.name}: + {s.count} base(s), {formatSize(s.totalMb)} +
+ {/each} +
+ +
+ + + + + + + + + + + + + {#each globalPreview.rows as r (r.node.ID)} + {@const kind = rowKind(r)} + {@const excluded = excludedRows.has(r.node.ID)} + + + + + + + + + {/each} + +
IncluirBase / NodoServidorTamañoCategoríaCambio
+ toggleExcluded(r.node.ID)} + class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" /> + + {r.node.BDName || r.node.NodoSubNodo} + · {r.node.NodoSubNodo} + {r.serverName}{formatSize(r.sizeMb)}{r.categoria} + {#if kind === 'nueva'} + Nueva + {:else if kind === 'reasignar'} + + Reasignar + + {:else} + Sin cambio + {/if} +
+
+ + {#if globalPreview.skippedNoSize > 0} +
+ {globalPreview.skippedNoSize} base(s) sin tamaño disponible (SQL no accesible o sin métrica) se ignoraron. +
+ {/if} + {#if globalPreview.unplaced.length > 0} +
+ {globalPreview.unplaced.length} base(s) no caben en ningún servidor con la capacidad actual. +
+ {/if} + +
+ + {globalCounts.nuevas} nuevas · + {globalCounts.reasignadas} reasignadas · + {globalCounts.sinCambio} sin cambio + {#if globalCounts.excluidas > 0}· {globalCounts.excluidas} excluida(s){/if} + + {#if !globalConfirming} + + {:else} + + Se asignarán {globalCounts.nuevas} base(s) nuevas y se moverán {globalCounts.reasignadas} de otro servidor. ¿Continuar? + + + + {/if} +
+ {/if} +
+ {/if} +
+ + +
+ + + + + + + + + + + + {#each targets as t (t.id)} + {@const configured = !!(t.server_ip && t.sql_username)} + {@const assignedCount = nodes.filter((n) => n.RestoreTargetId === t.id).length} + + + + + + + + {:else} + + + + {/each} + +
NombreServidor SQLDiscoBases asignadasAcciones
+ {t.name} + {#if !configured} + + sin configurar + + {/if} + {#if !hasCapacity(t)} + + sin capacidad + + {/if} + {t.server_ip ?? '—'}{t.disk_gb != null ? `${t.disk_gb} GB` : '—'}{assignedCount} +
+ + + +
+
+ No hay servidores de restauración registrados. +
+
+
+
+ +{#if showModal} + +{/if} + + +{#if confirmDelete} + +{/if} + + +{#if showLogsFor} + +{/if} diff --git a/src/routes/servidores-restauracion/depuracion/+page.server.ts b/src/routes/servidores-restauracion/depuracion/+page.server.ts new file mode 100644 index 0000000..bfac5f3 --- /dev/null +++ b/src/routes/servidores-restauracion/depuracion/+page.server.ts @@ -0,0 +1,116 @@ +/** + * Depuración de bases duplicadas (solo administradores). Tras mover bases a un servidor nuevo, + * las copias siguen en el viejo. Aquí se escanea un servidor viejo (restore_target), se muestra + * qué bases ya están bien en el nuevo y se borran las copias del viejo de forma segura. + */ +import { redirect, fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { PageServerLoad, Actions } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { listRestoreTargets } from '$lib/server/controldesk-pg'; +import { scanDuplicates, dropDuplicates } from '$lib/server/dedup-databases'; +import { moveDatabaseToNewServer } from '$lib/server/db-move'; +import { logger } from '$lib/server/logger'; + +async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) { + const token = cookies.get('session_token'); + if (!token) throw redirect(303, '/login'); + + const session = verifyToken(token); + if (!session) throw redirect(303, '/login'); + + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) throw redirect(303, '/'); + return currentUser; +} + +export const load: PageServerLoad = async ({ cookies }) => { + const currentUser = await requireAdmin(cookies); + let dbWarning: string | null = null; + + let targets: Awaited> = []; + try { + targets = await listRestoreTargets(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error({ message: 'dedup: error listando restore_targets', context: { error: msg } }); + dbWarning = `No se pudo cargar servidores: ${msg}`; + } + + return { targets, currentUser, dbWarning }; +}; + +function parseTargetId(data: FormData): number { + const id = parseInt(data.get('targetId')?.toString() || '0', 10); + return Number.isInteger(id) && id > 0 ? id : 0; +} + +export const actions: Actions = { + /** Escanea un servidor viejo y reconcilia sus bases contra el destino nuevo del catálogo. */ + scan: async ({ request, cookies }) => { + await requireAdmin(cookies); + const traceId = randomUUID(); + const data = await request.formData(); + const targetId = parseTargetId(data); + if (!targetId) return fail(400, { error: 'Selecciona un servidor válido.', traceId }); + + try { + const result = await scanDuplicates(targetId); + return { scan: result }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ trace_id: traceId, message: 'dedup: fallo al escanear', context: { targetId, error: msg } }); + return fail(500, { error: `No se pudo escanear el servidor: ${msg}`, traceId }); + } + }, + + /** Borra del servidor viejo las bases seleccionadas (re-verifica seguridad en el servidor). */ + drop: async ({ request, cookies }) => { + const currentUser = await requireAdmin(cookies); + const traceId = randomUUID(); + const data = await request.formData(); + const targetId = parseTargetId(data); + if (!targetId) return fail(400, { error: 'Selecciona un servidor válido.', traceId }); + + let names: string[]; + try { + const raw = data.get('names')?.toString() || '[]'; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) throw new Error('names no es un arreglo'); + names = parsed.map((n) => String(n).trim()).filter(Boolean); + } catch { + return fail(400, { error: 'Lista de bases inválida.', traceId }); + } + if (names.length === 0) return fail(400, { error: 'No hay bases seleccionadas.', traceId }); + + try { + const outcomes = await dropDuplicates(targetId, names, currentUser.username); + return { drop: { targetId, outcomes } }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ trace_id: traceId, message: 'dedup: fallo al borrar', context: { targetId, error: msg } }); + return fail(500, { error: `No se pudieron borrar las bases: ${msg}`, traceId }); + } + }, + + /** Manda una base que solo está en el viejo hacia su servidor nuevo (vía CRA) y, al confirmar, la borra del viejo. */ + move: async ({ request, cookies }) => { + const currentUser = await requireAdmin(cookies); + const traceId = randomUUID(); + const data = await request.formData(); + const targetId = parseTargetId(data); + const name = data.get('name')?.toString().trim(); + if (!targetId) return fail(400, { error: 'Selecciona un servidor válido.', traceId }); + if (!name) return fail(400, { error: 'Base requerida.', traceId }); + + try { + const result = await moveDatabaseToNewServer(targetId, name, currentUser.username); + return { move: result }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ trace_id: traceId, message: 'dedup: fallo al mover', context: { targetId, name, error: msg } }); + return fail(500, { error: `No se pudo mandar la base al nuevo: ${msg}`, traceId }); + } + } +}; diff --git a/src/routes/servidores-restauracion/depuracion/+page.svelte b/src/routes/servidores-restauracion/depuracion/+page.svelte new file mode 100644 index 0000000..18c059a --- /dev/null +++ b/src/routes/servidores-restauracion/depuracion/+page.svelte @@ -0,0 +1,407 @@ + + + +
+ + +
+

Depuración de bases duplicadas

+

+ Al mover bases a un servidor nuevo, las copias quedaron también en el viejo. Elige el + servidor viejo, revisa qué bases ya están bien en su servidor nuevo y borra las copias + del viejo. +

+
+ +
+ warning + + El borrado hace DROP DATABASE en el servidor viejo y cierra las + conexiones activas (SINGLE_USER). Es irreversible. Solo se habilita el borrado de bases + que ya existen en el nuevo con un tamaño coherente. + +
+ + {#if data.dbWarning} +
+ {data.dbWarning} +
+ {/if} + {#if errorMsg} +
+ {errorMsg} +
+ {/if} + + +
+
+ + +
+
+ + + {#if outcomes.length > 0} +
+

Resultado del borrado

+
    + {#each outcomes as o (o.name)} +
  • + + {o.ok ? 'check_circle' : 'error'} + + {o.name} + + {o.ok ? 'borrada del servidor viejo' : o.message || o.status} + +
  • + {/each} +
+
+ {/if} + + {#if scannedTarget} +
+

+ Servidor viejo: {scannedTarget.name} + ({scannedTarget.server_ip}) + · {rows.length} base{rows.length === 1 ? '' : 's'} · {deletableRows.length} segura{deletableRows.length === + 1 + ? '' + : 's'} +

+
+ + +
+
+ + {#if rows.length === 0} +

+ No hay bases de usuario en este servidor. +

+ {:else} +
+ + + + + + + + + + + + + + + + {#each rows as row (row.name)} + {@const meta = STATUS_META[row.status]} + + + + + + + + + + + + {/each} + +
BaseViejo: tamañoViejo: últ. restoreServidor nuevoNuevo: tamañoNuevo: últ. restoreEstadoAcciones
+ toggle(row.name)} + title={row.deletable + ? 'Marcar para borrar del servidor viejo' + : 'Solo se pueden borrar bases seguras'} + class="h-4 w-4 rounded border-slate-300 disabled:opacity-40" + /> + {row.name}{formatSize(row.oldSizeMb)}{formatDate(row.oldLastRestore)} + {#if row.newServer} + {row.newServer} + {#if row.newServerLabel} + {row.newServerLabel} + {/if} + {:else} + — + {/if} + {formatSize(row.newSizeMb)}{formatDate(row.newLastRestore)} + + {meta.icon} + {meta.label} + + + {#if row.movable} +
+ + + +
+ {/if} + {#if moveNotes[row.name]} +

{moveNotes[row.name].message}

+ {/if} +
+
+ {/if} + {/if} +
+ + + {#if confirmOpen} +
+
+
+ delete_forever +

Confirmar borrado

+
+

+ Se hará DROP DATABASE en + {scannedTarget?.name} + ({scannedTarget?.server_ip}) + de estas {selected.size} base{selected.size === 1 ? '' : 's'}. Esta acción es + irreversible. +

+
    + {#each selectedNames as name (name)} +
  • {name}
  • + {/each} +
+
+ +
+ + + +
+
+
+
+ {/if} +
diff --git a/src/routes/servidores-restauracion/node-sizes/+server.ts b/src/routes/servidores-restauracion/node-sizes/+server.ts new file mode 100644 index 0000000..7855471 --- /dev/null +++ b/src/routes/servidores-restauracion/node-sizes/+server.ts @@ -0,0 +1,45 @@ +/** + * Tamaños por nodo para la distribución de bases entre restauradores. Admin-only. + * Reusa la carga paralela del dashboard (loadSqlDashboardFromNodes): el `size_mb` es el + * tamaño de cada base en su servidor actual, así se puede planear aunque aún no esté en el + * destino. Es una carga pesada (varios SQL Server) → se llama de forma lazy desde la UI. + */ +import { json } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { RequestHandler } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { listDatabaseNodesForMssql } from '$lib/server/controldesk-pg'; +import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; + +function errorResponse(code: number, message: string, traceId: string) { + return json({ error: { code, message, trace_id: traceId } }, { status: code }); +} + +export const GET: RequestHandler = async ({ cookies }) => { + const traceId = randomUUID(); + + const token = cookies.get('session_token'); + const session = token ? verifyToken(token) : null; + if (!session) return errorResponse(401, 'No autenticado.', traceId); + + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) { + return errorResponse(403, 'Requiere permisos de administrador.', traceId); + } + + try { + const nodes = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; + const bundle = await loadSqlDashboardFromNodes(nodes); + const sizes = bundle.databaseRows.map((row: Record) => ({ + nodeId: Number(row._node_id), + BDName: String(row.BDName ?? row.visible_name ?? ''), + sizeMb: Number(row.size_mb) || 0 + })); + return json({ sizes }, { headers: { 'cache-control': 'no-store' } }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error(JSON.stringify({ level: 'error', trace_id: traceId, message: 'node-sizes', context: msg })); + return errorResponse(500, 'No se pudieron obtener los tamaños por nodo.', traceId); + } +}; diff --git a/src/routes/servidores-restauracion/restore-logs/+server.ts b/src/routes/servidores-restauracion/restore-logs/+server.ts new file mode 100644 index 0000000..513a2ad --- /dev/null +++ b/src/routes/servidores-restauracion/restore-logs/+server.ts @@ -0,0 +1,42 @@ +/** + * Bitácora de restauraciones de un servidor (a24c.restore_job_logs). Admin-only. + * Alimenta el modal "Bitácora" de la página de Servidores de Restauración. + */ +import { json } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { RequestHandler } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { listRecentRestoreJobLogs } from '$lib/server/controldesk-pg'; + +function errorResponse(code: number, message: string, traceId: string) { + return json({ error: { code, message, trace_id: traceId } }, { status: code }); +} + +export const GET: RequestHandler = async ({ cookies, url }) => { + const traceId = randomUUID(); + + const token = cookies.get('session_token'); + const session = token ? verifyToken(token) : null; + if (!session) return errorResponse(401, 'No autenticado.', traceId); + + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) { + return errorResponse(403, 'Requiere permisos de administrador.', traceId); + } + + const targetId = parseInt(url.searchParams.get('targetId') || '0', 10); + if (!Number.isInteger(targetId) || targetId <= 0) { + return errorResponse(400, 'targetId requerido.', traceId); + } + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + + try { + const logs = await listRecentRestoreJobLogs(targetId, Number.isInteger(limit) ? limit : 20); + return json({ logs }, { headers: { 'cache-control': 'no-store' } }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error(JSON.stringify({ level: 'error', trace_id: traceId, message: 'restore-logs', context: msg })); + return errorResponse(500, 'No se pudo cargar la bitácora.', traceId); + } +}; diff --git a/src/routes/usuarios/+page.server.ts b/src/routes/usuarios/+page.server.ts index 636783a..a3e948f 100644 --- a/src/routes/usuarios/+page.server.ts +++ b/src/routes/usuarios/+page.server.ts @@ -10,7 +10,7 @@ import { assignDatabaseToUser, removeDatabaseFromUser } from '$lib/server/users'; -import { db } from '$lib/server/db'; +import { listDatabaseNodes } from '$lib/server/controldesk-pg'; export const load: PageServerLoad = async ({ cookies }) => { // Verificar autenticación @@ -32,56 +32,22 @@ export const load: PageServerLoad = async ({ cookies }) => { // Obtener lista de usuarios const usuarios = await listUsers(); - // Obtener lista de todas las bases de datos disponibles + // Bases disponibles = catálogo a24c.database_nodes (PostgreSQL) try { - // Primero obtener las bases de datos del servidor secundario - const secondary = await db.getSecondary(); - const resultDB = await secondary.request().query(` - SELECT - d.name AS visible_name - FROM sys.databases d - WHERE d.name NOT IN ('master', 'tempdb', 'model', 'msdb') - ORDER BY d.name - `); - - let allDatabases: any[] = resultDB.recordset; - - // Enriquecer con datos de CONTROLDESK (nombre del cliente, nodo, etc.) - try { - const azure = await db.getAzure(); - const resultControlDesk = await azure.request().query(` - SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName - FROM [CONTROLDESK].[dbo].[BasesDeDatos] - `); - - const basesControlDesk = resultControlDesk.recordset as any[]; - const mapByBdName = new Map(); - - for (const bd of basesControlDesk) { - if (bd.BDName) { - mapByBdName.set(String(bd.BDName).toLowerCase(), bd); - } - } - - // Enriquecer allDatabases con la información de CONTROLDESK - allDatabases = allDatabases.map((db: any) => { - const key = String(db.visible_name || '').toLowerCase(); - const match = mapByBdName.get(key); - - if (!match) return db; - - return { - ...db, - NodoSubNodo: match.NodoSubNodo, - Nombre: match.Nombre, // Nombre del cliente - RFC: match.RFC, - Sucursal: match.Sucursal, - BDName: match.BDName - }; - }); - } catch (error) { - console.error('Error enriqueciendo bases de datos con CONTROLDESK:', error); - } + const basesControlDesk = (await listDatabaseNodes()) as any[]; + const allDatabases = basesControlDesk + .filter((bd) => bd.BDName) + .map((bd) => ({ + visible_name: bd.BDName, + NodoSubNodo: bd.NodoSubNodo, + Nombre: bd.Nombre, + RFC: bd.RFC, + Sucursal: bd.Sucursal, + BDName: bd.BDName + })) + .sort((a, b) => + String(a.visible_name || '').localeCompare(String(b.visible_name || '')) + ); return { usuarios, diff --git a/src/routes/usuarios/+page.svelte b/src/routes/usuarios/+page.svelte index 8d16c5b..03f985a 100644 --- a/src/routes/usuarios/+page.svelte +++ b/src/routes/usuarios/+page.svelte @@ -1,5 +1,6 @@ @@ -187,98 +204,8 @@ Gestión de Usuarios - Aduanasoft -
- - - - -
- -
-
- -
-

Gestión de Usuarios

-
-
- -
- -
- -
-
-
- - -
-
+ +
@@ -407,9 +334,7 @@
-
-
-
+ {#if showCreateModal} @@ -561,7 +486,13 @@

Permisos de: {selectedUser.username}

- + + {#if permissionError} +
+ {permissionError} +
+ {/if} + {#if selectedUser.es_admin}

Este usuario es administrador y tiene acceso a todas las bases de datos.

diff --git a/src/routes/usuarios/api/permissions/+server.ts b/src/routes/usuarios/api/permissions/+server.ts index 5063a81..d73f5c5 100644 --- a/src/routes/usuarios/api/permissions/+server.ts +++ b/src/routes/usuarios/api/permissions/+server.ts @@ -11,7 +11,7 @@ export const GET: RequestHandler = async ({ url }) => { try { const permissions = await getUserDatabasePermissions(userId); - return json({ permissions }); + return json({ permissions }, { headers: { 'cache-control': 'no-store' } }); } catch (error) { console.error('Error obteniendo permisos:', error); return json({ error: 'Error al obtener permisos' }, { status: 500 }); diff --git a/src/test/env-private-stub.ts b/src/test/env-private-stub.ts new file mode 100644 index 0000000..76cb71b --- /dev/null +++ b/src/test/env-private-stub.ts @@ -0,0 +1,8 @@ +/** + * Stub de `$env/dynamic/private` para pruebas Vitest (alias en vitest.config.ts). + * Varios módulos de servidor (p. ej. controldesk-pg.ts, mssql-nodes.ts) importan + * `env` de este módulo virtual de SvelteKit, que no existe fuera del build; el alias + * lo resuelve a este stub para que sus tests puedan cargarse. En build real, + * SvelteKit provee el módulo auténtico y este archivo no se usa. + */ +export const env: Record = {}; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a7c7965 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +// Configuración de Vitest para pruebas unitarias de lógica del servidor (cifrado, +// helpers, endpoints). Los aliases resuelven los especificadores de SvelteKit +// ($lib, $env/dynamic/private) SOLO en pruebas; el build real usa los módulos +// virtuales auténticos de SvelteKit y no toca esta config. +export default defineConfig({ + resolve: { + alias: { + $lib: fileURLToPath(new URL('./src/lib', import.meta.url)), + // Módulos de servidor importan `$env/dynamic/private` (virtual de SvelteKit); + // se resuelve a un stub para poder cargarlos en pruebas unitarias. + '$env/dynamic/private': fileURLToPath( + new URL('./src/test/env-private-stub.ts', import.meta.url) + ) + } + }, + test: { + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'] + } +});