-- EL FRENO POR DIRECCION NO SERVIA: LA DIRECCION CAMBIA EN CADA PETICION
-- (2026-07-29, lo cazo la prueba, no la teoria)
--
-- La prueba del freno fallo en un solo punto: hacer 15 intentos seguidos desde
-- la misma computadora no trababa nada. Mirando lo que quedo anotado:
--
--   pruebaip1  2a09:bac1:1880:f18::425:9
--   pruebaip2  2a09:bac5:1ec7:299b::425:9
--   pruebaip3  2a09:bac5:1ec0:299b::425:9
--   pruebaip4  2a09:bac5:1ec1:299b::425:9
--
-- Cuatro peticiones, cuatro direcciones distintas. En IPv6 esto es lo normal,
-- no la excepcion: al cliente no le dan UNA direccion sino un bloque entero, y
-- va cambiando la parte final (y con Cloudflare WARP, que es lo que tiene la
-- maquina de Gilberto, cambia hasta la parte del medio). Contar por direccion
-- exacta es contar por algo que no se repite nunca.
--
-- LO QUE SE HACE. Se cuenta por BLOQUE, no por direccion: /64 en IPv6 (que es
-- lo que le toca a un cliente completo) y la direccion entera en IPv4. Se
-- guarda en una columna aparte, `grupo`, para que el indice sirva; la direccion
-- exacta se sigue guardando en `desde` porque para mirar un ataque uno quiere
-- la de verdad.
--
-- LO QUE HAY QUE SABER IGUAL. Con una conexion que rota de bloque en cada
-- peticion (WARP, algunas VPN, redes moviles grandes) el freno por direccion
-- va a atrapar poco, y eso no se arregla contando distinto: quien manda ahi es
-- el freno POR USUARIO, que ese si funciona igual venga de donde venga. El
-- freno por direccion es la segunda linea, contra el que prueba usuarios
-- distintos desde un lado fijo.
begin;

alter table fiscal.intentos add column if not exists grupo text;
comment on column fiscal.intentos.grupo is 'bloque de la direccion (/64 en IPv6): es por lo que se cuenta, porque la direccion exacta cambia en cada peticion';

-- La direccion exacta ya no se indexa: no sirve para contar.
drop index if exists fiscal.intentos_fallos_desde;
drop index if exists fiscal.intentos_entradas_desde;
create index if not exists intentos_fallos_grupo on fiscal.intentos (grupo, en desc) where not entro;
create index if not exists intentos_entradas_grupo on fiscal.intentos (grupo, en desc) where entro;

create or replace function fiscal.grupo_de(p_desde text)
returns text
language plpgsql
immutable
as $fn$
declare v inet;
begin
  if p_desde is null or btrim(p_desde) = '' then
    return null;
  end if;
  begin
    v := btrim(p_desde)::inet;
  exception when others then
    -- Si llega algo que no es una direccion, se cuenta tal cual antes que
    -- perder el freno por completo.
    return left(btrim(p_desde), 50);
  end;
  return (network(set_masklen(v, case when family(v) = 6 then 64 else 32 end)))::text;
end;
$fn$;

-- Lo ya anotado se reparte en sus bloques, para no arrancar el conteo en cero.
update fiscal.intentos set grupo = fiscal.grupo_de(desde) where desde is not null and grupo is null;

create or replace function public.fn_registrar_intento(
  p_quien text,
  p_motivo text,
  p_desde text default null,
  p_tipo text default 'estacion'
)
returns void
language sql
security definer
set search_path to 'public', 'fiscal'
as $fn$
  insert into fiscal.intentos (quien, entro, motivo, desde, tipo, grupo)
  values (
    left(coalesce(p_quien, '?'), 20),
    false,
    left(coalesce(p_motivo, 'no autorizado'), 100),
    p_desde,
    case when p_tipo = 'panel' then 'panel' else 'estacion' end,
    fiscal.grupo_de(p_desde)
  );
$fn$;

revoke execute on function public.fn_registrar_intento(text, text, text, text) from anon, authenticated, public;
grant execute on function public.fn_registrar_intento(text, text, text, text) to service_role;

create or replace function fiscal.emitir_con_llave(
  p_llave text,
  p_rif text,
  p_serie text,
  p_tipo text,
  p_numero_documento bigint,
  p_hash text,
  p_fecha timestamptz,
  p_base_bs numeric,
  p_iva_bs numeric,
  p_total_bs numeric,
  p_documento jsonb,
  p_desde text default null
)
returns table(numero_control bigint, token text, ya_estaba boolean)
language plpgsql
security definer
set search_path to 'fiscal', 'public', 'extensions'
as $fn$
declare v_emisor uuid;
begin
  v_emisor := fiscal.autenticar(p_llave, p_rif, coalesce(p_desde, 'emision'));
  insert into fiscal.intentos (quien, entro, motivo, desde, tipo, grupo)
  values (p_rif, true, 'emision', p_desde, 'estacion', fiscal.grupo_de(p_desde));

  return query select * from fiscal.recibir_documento(
    p_rif, p_serie, p_tipo, p_numero_documento, p_hash, p_fecha,
    p_base_bs, p_iva_bs, p_total_bs, p_documento
  );
end;
$fn$;

drop function if exists fiscal.entrar_panel(text, text, text);

create function fiscal.entrar_panel(p_usuario text, p_clave text, p_desde text default null)
returns table(entro boolean, nombre text, espera_seg integer)
language plpgsql
security definer
set search_path to 'fiscal', 'public', 'extensions'
as $fn$
declare
  -- La ventana y los topes en un solo lugar, para no buscarlos por el cuerpo.
  c_ventana    constant interval := interval '15 minutes';
  c_tope_usu   constant int := 8;   -- fallos con el mismo usuario
  c_tope_ip    constant int := 15;  -- fallos desde el mismo bloque de direcciones
  c_tope_usu_c constant int := 30;  -- lo mismo, desde un bloque conocido
  c_tope_ip_c  constant int := 40;
  -- Hash de una clave que no existe. Sirve para gastar el mismo tiempo cuando
  -- el usuario no existe, y que el reloj no delate cuales nombres son reales.
  c_senuelo    constant text := '$2a$10$IHrhLAMm.gVpUS2Qwhf4DOze.yYhk8xdN3iOEi8owQJdcSX.kFSjG';

  v_usuario  text := left(lower(btrim(coalesce(p_usuario, ''))), 20);
  v_desde    text := nullif(btrim(coalesce(p_desde, '')), '');
  v_grupo    text := fiscal.grupo_de(p_desde);
  v_op       record;
  v_conocida boolean := false;
  v_n        int;
  v_viejo    timestamptz;
  v_tope_usu int;
  v_tope_ip  int;
begin
  -- OJO con los alias: esta funcion devuelve una columna que se llama `entro`,
  -- asi que sin el `i.` de adelante Postgres no sabe si uno habla de la columna
  -- de la tabla o de lo que va a devolver, y truena con "entro is ambiguous".
  --
  -- Un bloque desde el que ya se entro bien alguna vez en el ultimo mes es "la
  -- casa": se le sube el tope en vez de trancarle la puerta.
  if v_grupo is not null then
    select exists(
      select 1 from fiscal.intentos i
       where i.tipo = 'panel' and i.entro and i.grupo = v_grupo and i.en > now() - interval '30 days'
    ) into v_conocida;
  end if;
  v_tope_usu := case when v_conocida then c_tope_usu_c else c_tope_usu end;
  v_tope_ip  := case when v_conocida then c_tope_ip_c else c_tope_ip end;

  -- FRENO POR USUARIO. Se mira antes de gastar bcrypt: probar claves tiene que
  -- costar barato para nosotros y caro para el que prueba. Este es el que de
  -- verdad protege la clave, porque no depende de por donde venga.
  select count(*), min(t.en) into v_n, v_viejo from (
    select i.en from fiscal.intentos i
     where i.tipo = 'panel' and i.quien = v_usuario and not i.entro and i.en > now() - c_ventana
     order by i.en desc limit v_tope_usu
  ) t;
  if v_n >= v_tope_usu then
    -- No se anota este intento: si se anotara, el que ataca mantendria la
    -- puerta trancada para siempre nada mas insistiendo.
    return query select false, null::text,
      greatest(1, ceil(extract(epoch from (v_viejo + c_ventana - now()))))::int;
    return;
  end if;

  -- FRENO POR BLOQUE DE DIRECCIONES. Contra el que prueba usuarios distintos
  -- desde un lado fijo (probando "admin", "gilberto", "imprenta"...).
  if v_grupo is not null then
    select count(*), min(t.en) into v_n, v_viejo from (
      select i.en from fiscal.intentos i
       where i.tipo = 'panel' and i.grupo = v_grupo and not i.entro and i.en > now() - c_ventana
       order by i.en desc limit v_tope_ip
    ) t;
    if v_n >= v_tope_ip then
      return query select false, null::text,
        greatest(1, ceil(extract(epoch from (v_viejo + c_ventana - now()))))::int;
      return;
    end if;
  end if;

  select * into v_op from fiscal.operadores where usuario = v_usuario and activo;

  if v_op.id is null then
    -- Se gasta el bcrypt igual, contra el señuelo, para que un usuario que no
    -- existe tarde lo mismo que uno que si.
    perform extensions.crypt(coalesce(p_clave, ''), c_senuelo);
    insert into fiscal.intentos (quien, entro, motivo, desde, tipo, grupo)
    values (v_usuario, false, 'usuario que no existe', v_desde, 'panel', v_grupo);
    return query select false, null::text, 0;
    return;
  end if;

  if v_op.clave_hash is distinct from extensions.crypt(coalesce(p_clave, ''), v_op.clave_hash) then
    -- Aca el insert SI queda: esta funcion responde que no, no revienta con
    -- `raise`, y por eso no se revierte la transaccion (ver 20260729060000).
    insert into fiscal.intentos (quien, entro, motivo, desde, tipo, grupo)
    values (v_usuario, false, 'clave incorrecta', v_desde, 'panel', v_grupo);
    return query select false, null::text, 0;
    return;
  end if;

  update fiscal.operadores set ultimo_acceso = now() where id = v_op.id;
  insert into fiscal.intentos (quien, entro, motivo, desde, tipo, grupo)
  values (v_usuario, true, 'entro al panel', v_desde, 'panel', v_grupo);
  return query select true, v_op.nombre, 0;
end;
$fn$;

commit;
