5 Commits

Author SHA1 Message Date
edreitega f86c4d4f2a Merge pull request 'SPI-007: optimizar count de préstamos activos con vista ligera' (#4) from feature/SPI-007-prestamos-activos-timeout into dev
Reviewed-on: #4
2026-06-29 17:31:33 +00:00
Edrei Téllez ffb2a1d64a SPI-007: optimizar count de préstamos activos con vista ligera
Separar find (full view) del count (informacion_prestamo_view) en el path
activo=true con paginación para evitar timeout por COUNT sobre 8 JOINs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 11:06:27 -06:00
edreitega 8e74802de7 Merge pull request 'SEC-001: migrar HcaptchaGuard a TurnstileGuard con siteverify Cloudflare.' (#2) from feature/SEC-001-turnstile into dev
Reviewed-on: #2
2026-06-23 10:37:03 -06:00
Edrei Téllez ce1d1396e0 SEC-001: migrar HcaptchaGuard a TurnstileGuard con siteverify Cloudflare.
Bypass solo en desarrollo; pruebas y producción validan header turnstile vía HTTPS nativo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 10:29:57 -06:00
edreitega e5076603fd Merge pull request 'alinear dev con master (prod)' (#1) from master into dev
Reviewed-on: #1
2026-06-04 18:25:37 -06:00
7 changed files with 114 additions and 63 deletions
-11
View File
@@ -25,7 +25,6 @@
"class-validator": "^0.13.2",
"csvtojson": "^2.0.10",
"dotenv": "^16.0.3",
"hcaptcha": "^0.1.1",
"moment": "^2.29.4",
"mysql2": "^2.3.3",
"nodemailer": "^6.7.8",
@@ -5049,11 +5048,6 @@
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="
},
"node_modules/hcaptcha": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/hcaptcha/-/hcaptcha-0.1.1.tgz",
"integrity": "sha512-iMrDmH2VpIEKOrcKWidVjI89FdDKTEdZ7PfPWkP27sTazIIkob8YfdY2ezaufAnWBiUUcvzsn0qF+dyXtBH2Vw=="
},
"node_modules/hexoid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
@@ -13436,11 +13430,6 @@
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="
},
"hcaptcha": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/hcaptcha/-/hcaptcha-0.1.1.tgz",
"integrity": "sha512-iMrDmH2VpIEKOrcKWidVjI89FdDKTEdZ7PfPWkP27sTazIIkob8YfdY2ezaufAnWBiUUcvzsn0qF+dyXtBH2Vw=="
},
"hexoid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
-1
View File
@@ -37,7 +37,6 @@
"class-validator": "^0.13.2",
"csvtojson": "^2.0.10",
"dotenv": "^16.0.3",
"hcaptcha": "^0.1.1",
"moment": "^2.29.4",
"mysql2": "^2.3.3",
"nodemailer": "^6.7.8",
+4 -4
View File
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Serealize } from '../interceptors/serialize.interceptor';
import { HcaptchaGuard } from '../guards/hcaptcha.guard';
import { TurnstileGuard } from '../guards/turnstile.guard';
import { AuthService } from './auth.service';
import { LoginAdminDto } from './dto/input/login-admin.dto';
import { LoginOperadorDto } from './dto/input/login-operador.dto';
@@ -15,7 +15,7 @@ export class AuthController {
constructor(private authService: AuthService) {}
@Serealize(AuthTokenOutputDto)
@UseGuards(HcaptchaGuard)
@UseGuards(TurnstileGuard)
@Post('login-admin')
@ApiOperation({ description: 'Login del admin.' })
@ApiBody({
@@ -27,7 +27,7 @@ export class AuthController {
}
@Serealize(AuthTokenOutputDto)
@UseGuards(HcaptchaGuard)
@UseGuards(TurnstileGuard)
@Post('login-operador')
@ApiOperation({ description: 'Login del operador.' })
@ApiBody({
@@ -45,7 +45,7 @@ export class AuthController {
}
@Serealize(AuthTokenOutputDto)
@UseGuards(HcaptchaGuard)
@UseGuards(TurnstileGuard)
@Post('login-usuario')
@ApiOperation({ description: 'Login del usuario.' })
@ApiBody({
-37
View File
@@ -1,37 +0,0 @@
const { verify } = require('hcaptcha');
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Observable } from 'rxjs';
@Injectable()
export class HcaptchaGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const secret = this.configService.get<string>('HCAPTCHA_KEY');
const token = context.switchToHttp().getRequest().headers.hcaptcha;
if (this.configService.get<string>('STATE') !== 'produccion') return true;
if (!token)
throw new BadRequestException('No se mando un token de hcaptcha.');
return verify(secret, token)
.then((data) => {
if (data.success === true) return true;
else throw new ForbiddenException('El token de hcaptcha no es válido.');
})
.catch((err) => {
throw new InternalServerErrorException(
'Ocurrió un error con el hcaptcha.',
);
});
}
}
+80
View File
@@ -0,0 +1,80 @@
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as https from 'https';
import { Observable } from 'rxjs';
function verifyTurnstile(
secret: string,
token: string,
): Promise<{ success: boolean }> {
const body = new URLSearchParams({ secret, response: token }).toString();
return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: 'challenges.cloudflare.com',
path: '/turnstile/v0/siteverify',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (err) {
reject(err);
}
});
},
);
req.on('error', reject);
req.write(body);
req.end();
});
}
@Injectable()
export class TurnstileGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (this.configService.get<string>('STATE') === 'desarrollo') return true;
const token = context.switchToHttp().getRequest().headers.turnstile;
if (!token)
throw new BadRequestException('No se envió un token de turnstile.');
const secret = this.configService.get<string>('TURNSTILE_SECRET_KEY');
return verifyTurnstile(secret, token)
.then((data) => {
if (data.success === true) return true;
throw new ForbiddenException('El token de turnstile no es válido.');
})
.catch((err) => {
if (
err instanceof ForbiddenException ||
err instanceof BadRequestException
)
throw err;
throw new InternalServerErrorException(
'Ocurrió un error con turnstile.',
);
});
}
}
+28 -8
View File
@@ -450,15 +450,35 @@ export class PrestamoService {
if (filtros.pagina) {
options.take = 25;
options.skip = (parseInt(filtros.pagina) - 1) * 25;
return this.fullInformacionPrestamoView
.findAndCount(options)
.then((infoPrestamos) => {
const prestamos: Prestamo[] = [];
for (let i = 0; i < infoPrestamos[0].length; i++)
prestamos.push(this.fullViewToPrestamo(infoPrestamos[0][i]));
return [prestamos, infoPrestamos[1]];
});
const activoTrue =
(typeof filtros.activo === 'boolean' && filtros.activo) ||
(typeof filtros.activo === 'string' && filtros.activo === 'true');
const canUseLightCount =
activoTrue && !filtros.usuario && !filtros.id_tipo_usuario;
if (canUseLightCount) {
const { usuario: _u, id_tipo_usuario: _tu, ...countWhere } =
busqueda;
const [rows, total] = await Promise.all([
this.fullInformacionPrestamoView.find(options),
this.informacionPrestamoView.count({
where: countWhere as FindOptionsWhere<InformacionPrestamoView>,
}),
]);
const prestamos = rows.map((row) => this.fullViewToPrestamo(row));
return [prestamos, total];
}
const infoPrestamos = await this.fullInformacionPrestamoView.findAndCount(
options,
);
const prestamos: Prestamo[] = [];
for (let i = 0; i < infoPrestamos[0].length; i++)
prestamos.push(this.fullViewToPrestamo(infoPrestamos[0][i]));
return [prestamos, infoPrestamos[1]];
}
options.order = { id_institucion: 'ASC', id_prestamo: 'ASC' };
return this.fullInformacionPrestamoView.find(options);
+2 -2
View File
@@ -17,7 +17,7 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { Serealize } from '../interceptors/serialize.interceptor';
import { HcaptchaGuard } from '../guards/hcaptcha.guard';
import { TurnstileGuard } from '../guards/turnstile.guard';
import { UsuarioService } from './usuario.service';
import { ValidarUsuarioService } from '../validar-usuario/validar-usuario.service';
import { Operador } from '../operador/entity/operador.entity';
@@ -39,7 +39,7 @@ export class UsuarioController {
@Serealize(MessageOutputDto)
@Post('registrar')
@UseGuards(HcaptchaGuard)
@UseGuards(TurnstileGuard)
@ApiOperation({ description: 'Registro de usuario.' })
@ApiBody({
description: 'Variables que necesita el endpoint.',