feat: enhance point registration and location validation features

- Refactored the RegistroPonto component to improve the layout and user experience, including a new section for displaying standard hours.
- Updated RelogioSincronizado to include GMT offset adjustments for accurate time display.
- Introduced new location validation logic in the backend to ensure point registrations are within allowed geofenced areas.
- Enhanced the device information schema to capture additional GPS data, improving the reliability of location checks.
- Added new endpoints for managing allowed marking addresses, facilitating better control over where points can be registered.
This commit is contained in:
2025-11-21 05:12:27 -03:00
parent 3da364fb02
commit d6aaa15cf4
17 changed files with 4347 additions and 568 deletions

View File

@@ -747,55 +747,13 @@
</div>
{/if}
<!-- Relógio Sincronizado -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body items-center">
<RelogioSincronizado />
</div>
</div>
<!-- Mapa de Horários -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<Clock class="h-5 w-5" />
Horários do Dia
</h2>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each mapaHorarios as horario (horario.tipo)}
<div
class="card {horario.registrado
? 'bg-success/10 border-success'
: 'bg-base-200'} border-2"
>
<div class="card-body p-4">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold">{horario.label}</span>
{#if horario.registrado}
{#if horario.dentroDoPrazo}
<CheckCircle2 class="text-success h-5 w-5" />
{:else}
<XCircle class="text-error h-5 w-5" />
{/if}
{/if}
</div>
<div class="text-2xl font-bold">{horario.horario}</div>
{#if horario.registrado}
<div class="text-base-content/70 text-sm">
Registrado: {horario.horarioRegistrado}
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Botões de Registro -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body items-center">
<h2 class="card-title mb-4">Registrar Ponto</h2>
<div class="mb-6 w-full">
<RelogioSincronizado />
</div>
<div class="flex w-full flex-col items-center gap-4">
{#if sucesso}
<div class="alert alert-success w-full">
@@ -858,6 +816,78 @@
</div>
</div>
<!-- Mapa de Horários -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-6">
<Clock class="h-5 w-5" />
Horário Padrão
</h2>
<!-- Linha horizontal com espaçamento uniforme -->
<div class="flex flex-wrap items-stretch justify-between gap-4 md:gap-6">
{#each mapaHorarios as horario (horario.tipo)}
<div class="flex-1 min-w-[140px] max-w-[220px] mx-auto">
<div
class="relative h-full rounded-xl border-2 transition-all duration-300 hover:shadow-lg {horario.registrado
? horario.dentroDoPrazo
? 'bg-gradient-to-br from-success/20 to-success/10 border-success shadow-md'
: 'bg-gradient-to-br from-error/20 to-error/10 border-error shadow-md'
: 'bg-gradient-to-br from-base-200 to-base-300 border-base-300'} p-5"
>
<!-- Status Icon -->
<div class="absolute top-3 right-3">
{#if horario.registrado}
{#if horario.dentroDoPrazo}
<CheckCircle2 class="h-5 w-5 text-success" />
{:else}
<XCircle class="h-5 w-5 text-error" />
{/if}
{:else}
<Clock class="h-5 w-5 text-base-content/30" />
{/if}
</div>
<!-- Label -->
<div class="mb-3">
<span class="text-sm font-semibold text-base-content/80 uppercase tracking-wide">
{horario.label}
</span>
</div>
<!-- Horário Padrão -->
<div class="mb-2">
<div class="text-3xl font-bold text-primary font-mono">
{horario.horario}
</div>
</div>
<!-- Horário Registrado (se houver) -->
{#if horario.registrado}
<div class="mt-3 pt-3 border-t border-base-content/10">
<div class="flex items-center gap-2">
<div class="text-xs font-medium text-base-content/60">
Registrado:
</div>
<div class="text-sm font-bold text-base-content">
{horario.horarioRegistrado}
</div>
</div>
</div>
{:else}
<div class="mt-3 pt-3 border-t border-base-content/10">
<div class="text-xs text-base-content/40 italic">
Aguardando registro
</div>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Histórico e Saldo do Dia -->
{#if historicoSaldo && registrosOrdenados.length > 0}
<div class="card bg-base-100 shadow-xl">
@@ -888,57 +918,224 @@
</div>
</div>
<!-- Lista de Registros -->
<!-- Timeline de Registros -->
<div class="divider"></div>
<div class="space-y-2">
<h3 class="font-semibold">Registros Realizados</h3>
<div class="space-y-3">
{#each registrosOrdenados as registro (registro._id)}
<div class="card bg-base-200">
<div class="card-body p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="mb-1 flex items-center gap-2">
<span class="font-semibold">
{config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo)}
</span>
{#if registro.dentroDoPrazo}
<CheckCircle2 class="h-4 w-4 text-success" />
{:else}
<XCircle class="h-4 w-4 text-error" />
{/if}
</div>
<p class="text-lg font-bold">
{formatarHoraPonto(registro.hora, registro.minuto)}
</p>
{#if registro.justificativa}
<div class="mt-2 rounded bg-base-300 p-2">
<p class="text-xs font-semibold opacity-70">Justificativa:</p>
<p class="text-sm">{registro.justificativa}</p>
<div class="space-y-4">
<h3 class="font-semibold">Timeline do Dia</h3>
<!-- Timeline Visual com horários padrão e registros reais -->
<div class="relative">
<!-- Linha vertical central da timeline -->
<div class="absolute left-1/2 top-0 bottom-0 w-1 bg-gradient-to-b from-primary/20 via-base-300 to-secondary/20 transform -translate-x-1/2"></div>
<!-- Container com duas colunas -->
<div class="grid grid-cols-2 gap-4 relative">
<!-- Coluna Entrada -->
<div class="space-y-4 pr-2">
<div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-primary/20">
<h4 class="text-lg font-bold text-primary text-center flex items-center justify-center gap-2">
<LogIn class="h-5 w-5" />
Entradas
</h4>
</div>
{#each registrosOrdenados.filter(r => r.tipo === 'entrada' || r.tipo === 'retorno_almoco') as registro (registro._id)}
<div class="relative">
<!-- Linha horizontal conectando à timeline -->
<div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
<!-- Card do registro -->
<div class="card {registro.dentroDoPrazo ? 'bg-success/5 border-success/30' : 'bg-error/5 border-error/30'} border-2 shadow-md hover:shadow-lg transition-all">
<div class="card-body p-4">
<!-- Tipo de registro e status -->
<div class="flex items-center gap-2 mb-2">
{#if registro.dentroDoPrazo}
<CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
{:else}
<XCircle class="h-4 w-4 text-error flex-shrink-0" />
{/if}
<span class="text-sm font-semibold text-base-content/80">
{config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
})
: getTipoRegistroLabel(registro.tipo)}
</span>
</div>
{/if}
</div>
<div class="flex-shrink-0">
<button
class="btn btn-sm btn-outline btn-primary gap-2"
onclick={() => imprimirComprovante(registro._id)}
title="Imprimir Comprovante"
>
<Printer class="h-4 w-4" />
Imprimir Comprovante
</button>
<!-- Horário registrado -->
<p class="text-3xl font-bold text-primary mb-1">
{formatarHoraPonto(registro.hora, registro.minuto)}
</p>
<!-- Comparação com horário esperado -->
{#if config}
{@const horarioEsperado = registro.tipo === 'entrada' ? config.horarioEntrada : config.horarioRetornoAlmoco}
{@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)}
{@const minutosEsperados = horaEsperada * 60 + minutoEsperado}
{@const minutosRegistrados = registro.hora * 60 + registro.minuto}
{@const diferenca = minutosRegistrados - minutosEsperados}
{@const diferencaAbs = Math.abs(diferenca)}
{@const diferencaTexto = diferencaAbs >= 60
? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min`
: `${diferencaAbs}min`}
<div class="flex items-center gap-2 text-xs mb-3">
<span class="text-base-content/50">Esperado:</span>
<span class="font-semibold">{horarioEsperado}</span>
{#if diferencaAbs > 0}
<span class="badge badge-xs {diferenca > 0 ? 'badge-warning' : 'badge-info'}">
{diferenca > 0 ? '+' : '-'}{diferencaTexto}
</span>
{/if}
</div>
{/if}
{#if registro.justificativa}
<div class="mt-2 rounded-lg bg-base-300/50 p-2 text-xs mb-3">
<p class="font-semibold opacity-70 mb-1">Justificativa:</p>
<p class="text-base-content/80">{registro.justificativa}</p>
</div>
{/if}
<button
class="btn btn-sm btn-outline btn-primary gap-2 w-full"
onclick={() => imprimirComprovante(registro._id)}
title="Imprimir Comprovante"
>
<Printer class="h-4 w-4" />
Imprimir Comprovante
</button>
</div>
</div>
</div>
</div>
{/each}
<!-- Mostrar horários esperados que não foram registrados -->
{#if config}
{#each [
{ tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' },
{ tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' }
] as horarioEsperado}
{#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)}
<div class="relative opacity-50">
<div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/30 border-dashed" style="width: calc(100% - 0.5rem);"></div>
<div class="card bg-base-200/50 border border-dashed border-base-300">
<div class="card-body p-3">
<p class="text-xs text-base-content/50 mb-1">{horarioEsperado.label} (não registrado)</p>
<p class="text-xl font-bold text-base-content/40">{horarioEsperado.horario}</p>
</div>
</div>
</div>
{/if}
{/each}
{/if}
</div>
{/each}
<!-- Coluna Saída -->
<div class="space-y-4 pl-2">
<div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-secondary/20">
<h4 class="text-lg font-bold text-secondary text-center flex items-center justify-center gap-2">
<LogOut class="h-5 w-5" />
Saídas
</h4>
</div>
{#each registrosOrdenados.filter(r => r.tipo === 'saida_almoco' || r.tipo === 'saida') as registro (registro._id)}
<div class="relative">
<!-- Linha horizontal conectando à timeline -->
<div class="absolute left-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
<!-- Card do registro -->
<div class="card {registro.dentroDoPrazo ? 'bg-success/5 border-success/30' : 'bg-error/5 border-error/30'} border-2 shadow-md hover:shadow-lg transition-all">
<div class="card-body p-4">
<!-- Tipo de registro e status -->
<div class="flex items-center gap-2 mb-2 justify-end">
<span class="text-sm font-semibold text-base-content/80">
{config
? getTipoRegistroLabel(registro.tipo, {
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo)}
</span>
{#if registro.dentroDoPrazo}
<CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
{:else}
<XCircle class="h-4 w-4 text-error flex-shrink-0" />
{/if}
</div>
<!-- Horário registrado -->
<p class="text-3xl font-bold text-secondary mb-1 text-right">
{formatarHoraPonto(registro.hora, registro.minuto)}
</p>
<!-- Comparação com horário esperado -->
{#if config}
{@const horarioEsperado = registro.tipo === 'saida_almoco' ? config.horarioSaidaAlmoco : config.horarioSaida}
{@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)}
{@const minutosEsperados = horaEsperada * 60 + minutoEsperado}
{@const minutosRegistrados = registro.hora * 60 + registro.minuto}
{@const diferenca = minutosRegistrados - minutosEsperados}
{@const diferencaAbs = Math.abs(diferenca)}
{@const diferencaTexto = diferencaAbs >= 60
? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min`
: `${diferencaAbs}min`}
<div class="flex items-center gap-2 text-xs mb-3 justify-end">
{#if diferencaAbs > 0}
<span class="badge badge-xs {diferenca > 0 ? 'badge-warning' : 'badge-info'}">
{diferenca > 0 ? '+' : '-'}{diferencaTexto}
</span>
{/if}
<span class="font-semibold">{horarioEsperado}</span>
<span class="text-base-content/50">Esperado:</span>
</div>
{/if}
{#if registro.justificativa}
<div class="mt-2 rounded-lg bg-base-300/50 p-2 text-xs mb-3">
<p class="font-semibold opacity-70 mb-1">Justificativa:</p>
<p class="text-base-content/80">{registro.justificativa}</p>
</div>
{/if}
<button
class="btn btn-sm btn-outline btn-primary gap-2 w-full"
onclick={() => imprimirComprovante(registro._id)}
title="Imprimir Comprovante"
>
<Printer class="h-4 w-4" />
Imprimir Comprovante
</button>
</div>
</div>
</div>
{/each}
<!-- Mostrar horários esperados que não foram registrados -->
{#if config}
{#each [
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: config.nomeSaidaAlmoco || 'Saída 1' },
{ tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' }
] as horarioEsperado}
{#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)}
<div class="relative opacity-50">
<div class="absolute left-0 top-6 w-full h-0.5 bg-base-300/30 border-dashed" style="width: calc(100% - 0.5rem);"></div>
<div class="card bg-base-200/50 border border-dashed border-base-300">
<div class="card-body p-3">
<p class="text-xs text-base-content/50 mb-1 text-right">{horarioEsperado.label} (não registrado)</p>
<p class="text-xl font-bold text-base-content/40 text-right">{horarioEsperado.horario}</p>
</div>
</div>
</div>
{/if}
{/each}
{/if}
</div>
</div>
</div>
</div>
</div>

View File

@@ -17,36 +17,45 @@
async function atualizarTempo() {
try {
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
const gmtOffset = config.gmtOffset ?? 0;
let timestampBase: number;
if (config.usarServidorExterno) {
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso && resultado.timestamp) {
tempoAtual = new Date(resultado.timestamp);
timestampBase = resultado.timestamp;
sincronizado = true;
usandoServidorExterno = resultado.usandoServidorExterno || false;
offsetSegundos = resultado.offsetSegundos || 0;
erro = null;
} else {
throw new Error('Falha ao sincronizar');
}
} catch (error) {
console.warn('Erro ao sincronizar:', error);
if (config.fallbackParaPC) {
tempoAtual = new Date(obterTempoPC());
timestampBase = obterTempoPC();
sincronizado = false;
usandoServidorExterno = false;
erro = 'Usando relógio do PC (falha na sincronização)';
} else {
erro = 'Falha ao sincronizar tempo';
throw error;
}
}
} else {
// Usar tempo do servidor Convex
const tempoServidor = await obterTempoServidor(client);
tempoAtual = new Date(tempoServidor);
timestampBase = await obterTempoServidor(client);
sincronizado = true;
usandoServidorExterno = false;
erro = null;
}
// Aplicar GMT offset ao timestamp
// O timestamp está em UTC, adicionar o offset em horas
const timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
tempoAtual = new Date(timestampAjustado);
} catch (error) {
console.error('Erro ao obter tempo:', error);
tempoAtual = new Date(obterTempoPC());

View File

@@ -15,6 +15,13 @@ export interface InformacoesDispositivo {
latitude?: number;
longitude?: number;
precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
confiabilidadeGPS?: number; // 0-1
suspeitaSpoofing?: boolean;
motivoSuspeita?: string;
endereco?: string;
cidade?: string;
estado?: string;
@@ -230,12 +237,289 @@ function obterInformacoesMemoria(): string {
}
/**
* Obtém localização via GPS com múltiplas tentativas
* Calcula distância entre duas coordenadas (fórmula de Haversine)
* Retorna distância em metros
*/
function calcularDistancia(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371000; // Raio da Terra em metros
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Obtém timezone aproximado por coordenadas
*/
function obterTimezonePorCoordenadas(latitude: number, longitude: number): string {
// Pernambuco está em UTC-3 (America/Recife)
if (longitude >= -45 && longitude <= -30 && latitude >= -10 && latitude <= 5) {
return 'America/Recife'; // UTC-3
}
// Fallback: usar timezone do sistema
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'America/Recife'; // Default
}
}
/**
* Captura uma única leitura de localização com todas as propriedades disponíveis
*/
async function capturarLocalizacaoUnica(
enableHighAccuracy: boolean = true,
timeout: number = 10000
): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
timestamp?: number;
confiabilidade: number; // 0-1
}> {
return new Promise((resolve) => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
resolve({ confiabilidade: 0 });
return;
}
const timeoutId = setTimeout(() => {
resolve({ confiabilidade: 0 });
}, timeout + 1000);
navigator.geolocation.getCurrentPosition(
(position) => {
clearTimeout(timeoutId);
const coords = position.coords;
const { latitude, longitude, accuracy } = coords;
// Validar coordenadas básicas
if (
isNaN(latitude) ||
isNaN(longitude) ||
latitude === 0 ||
longitude === 0 ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
resolve({ confiabilidade: 0 });
return;
}
// Calcular score de confiabilidade baseado em propriedades do GPS real
const sinaisGPSReal = {
temAltitude: coords.altitude !== null && coords.altitude !== 0,
temAltitudeAccuracy: coords.altitudeAccuracy !== null && coords.altitudeAccuracy > 0,
temHeading: coords.heading !== null && !isNaN(coords.heading),
temSpeed: coords.speed !== null && !isNaN(coords.speed),
precisaoBoa: accuracy < 20, // GPS real geralmente < 20m
precisaoMedia: accuracy >= 20 && accuracy < 100,
timestampPreciso: position.timestamp > 0
};
// Calcular confiabilidade: cada sinal adiciona pontos
let pontos = 0;
const maxPontos = 7;
if (sinaisGPSReal.temAltitude) pontos += 1;
if (sinaisGPSReal.temAltitudeAccuracy) pontos += 1;
if (sinaisGPSReal.temHeading) pontos += 0.5;
if (sinaisGPSReal.temSpeed) pontos += 0.5;
if (sinaisGPSReal.precisaoBoa) pontos += 2;
if (sinaisGPSReal.precisaoMedia) pontos += 1;
if (sinaisGPSReal.timestampPreciso) pontos += 1;
const confiabilidade = Math.min(pontos / maxPontos, 1);
resolve({
latitude,
longitude,
precisao: accuracy,
altitude: coords.altitude ?? null,
altitudeAccuracy: coords.altitudeAccuracy ?? null,
heading: coords.heading ?? null,
speed: coords.speed ?? null,
timestamp: position.timestamp,
confiabilidade
});
},
(error) => {
clearTimeout(timeoutId);
console.warn('Erro ao obter localização:', error.code, error.message);
resolve({ confiabilidade: 0 });
},
{
enableHighAccuracy,
timeout,
maximumAge: 0 // Sempre obter nova leitura
}
);
});
}
/**
* Obtém localização via GPS com múltiplas leituras para detectar spoofing
* Apps de spoofing geralmente retornam valores idênticos em todas as leituras
*/
async function obterLocalizacaoMultipla(): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
confiabilidade: number; // 0-1
suspeitaSpoofing: boolean;
motivoSuspeita?: string;
}> {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Geolocalização não suportada' };
}
// Capturar 3 leituras com intervalo de 2 segundos entre elas
const leituras: Array<{
lat: number;
lon: number;
precisao: number;
altitude: number | null;
confiabilidade: number;
}> = [];
for (let i = 0; i < 3; i++) {
const leitura = await capturarLocalizacaoUnica(true, 8000);
if (leitura.latitude && leitura.longitude && leitura.confiabilidade > 0) {
leituras.push({
lat: leitura.latitude,
lon: leitura.longitude,
precisao: leitura.precisao || 999,
altitude: leitura.altitude ?? null,
confiabilidade: leitura.confiabilidade
});
}
// Aguardar 2 segundos entre leituras (exceto na última)
if (i < 2) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
if (leituras.length === 0) {
return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Não foi possível obter localização' };
}
// Se tivermos menos de 2 leituras, usar única leitura com baixa confiança
if (leituras.length < 2) {
const unica = leituras[0];
return {
latitude: unica.lat,
longitude: unica.lon,
precisao: unica.precisao,
altitude: unica.altitude,
altitudeAccuracy: null,
heading: null,
speed: null,
confiabilidade: unica.confiabilidade * 0.5, // Reduzir confiança por ter apenas 1 leitura
suspeitaSpoofing: true,
motivoSuspeita: 'Apenas uma leitura obtida'
};
}
// Verificar se todas as leituras são idênticas (suspeito de spoofing)
const primeiraLeitura = leituras[0];
const todasIguais = leituras.every(
(l) =>
Math.abs(l.lat - primeiraLeitura.lat) < 0.00001 && // ~1 metro
Math.abs(l.lon - primeiraLeitura.lon) < 0.00001
);
if (todasIguais && leituras.length === 3) {
// GPS real varia alguns metros, se todas são idênticas pode ser spoofing
return {
latitude: primeiraLeitura.lat,
longitude: primeiraLeitura.lon,
precisao: primeiraLeitura.precisao,
altitude: primeiraLeitura.altitude,
altitudeAccuracy: null,
heading: null,
speed: null,
confiabilidade: primeiraLeitura.confiabilidade * 0.4, // Reduzir drasticamente confiança
suspeitaSpoofing: true,
motivoSuspeita: 'Todas as leituras são idênticas (GPS real varia alguns metros)'
};
}
// Calcular média das leituras e variância
const mediaLat = leituras.reduce((sum, l) => sum + l.lat, 0) / leituras.length;
const mediaLon = leituras.reduce((sum, l) => sum + l.lon, 0) / leituras.length;
const mediaConfianca = leituras.reduce((sum, l) => sum + l.confiabilidade, 0) / leituras.length;
// Calcular distância máxima entre leituras
let distanciaMaxima = 0;
for (let i = 0; i < leituras.length; i++) {
for (let j = i + 1; j < leituras.length; j++) {
const dist = calcularDistancia(
leituras[i].lat,
leituras[i].lon,
leituras[j].lat,
leituras[j].lon
);
distanciaMaxima = Math.max(distanciaMaxima, dist);
}
}
// Se distância máxima for muito grande (> 100m), pode indicar problemas
const suspeitoPorDistancia = distanciaMaxima > 100;
return {
latitude: mediaLat,
longitude: mediaLon,
precisao: primeiraLeitura.precisao,
altitude: primeiraLeitura.altitude,
altitudeAccuracy: null,
heading: null,
speed: null,
confiabilidade: suspeitoPorDistancia ? mediaConfianca * 0.6 : mediaConfianca,
suspeitaSpoofing: suspeitoPorDistancia,
motivoSuspeita: suspeitoPorDistancia
? `Variação muito grande entre leituras (${Math.round(distanciaMaxima)}m)`
: undefined
};
}
/**
* Obtém localização via GPS com múltiplas tentativas e validações anti-spoofing
*/
async function obterLocalizacao(): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
confiabilidadeGPS?: number;
suspeitaSpoofing?: boolean;
motivoSuspeita?: string;
endereco?: string;
cidade?: string;
estado?: string;
@@ -246,127 +530,95 @@ async function obterLocalizacao(): Promise<{
return {};
}
// Tentar múltiplas estratégias
const estrategias = [
// Estratégia 1: Alta precisão (mais lento, mas mais preciso)
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
},
// Estratégia 2: Precisão média (balanceado)
{
enableHighAccuracy: false,
timeout: 8000,
maximumAge: 30000
},
// Estratégia 3: Rápido (usa cache)
{
enableHighAccuracy: false,
timeout: 5000,
maximumAge: 60000
}
];
// Usar múltiplas leituras para detectar spoofing
const localizacaoMultipla = await obterLocalizacaoMultipla();
for (const options of estrategias) {
try {
const resultado = await new Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}>((resolve) => {
const timeout = setTimeout(() => {
resolve({});
}, options.timeout + 1000);
if (!localizacaoMultipla.latitude || !localizacaoMultipla.longitude) {
console.warn('Não foi possível obter localização');
return {
confiabilidadeGPS: 0,
suspeitaSpoofing: true,
motivoSuspeita: 'Não foi possível obter localização'
};
}
navigator.geolocation.getCurrentPosition(
async (position) => {
clearTimeout(timeout);
const { latitude, longitude, accuracy } = position.coords;
const { latitude, longitude, precisao, altitude, altitudeAccuracy, heading, speed, confiabilidade, suspeitaSpoofing, motivoSuspeita } = localizacaoMultipla;
// Validar coordenadas
if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) {
resolve({});
return;
}
// Tentar obter endereço via reverse geocoding
let endereco = '';
let cidade = '';
let estado = '';
let pais = '';
// Tentar obter endereço via reverse geocoding
let endereco = '';
let cidade = '';
let estado = '';
let pais = '';
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
{
headers: {
'User-Agent': 'SGSE-App/1.0'
}
}
);
if (response.ok) {
const data = (await response.json()) as {
address?: {
road?: string;
house_number?: string;
city?: string;
town?: string;
state?: string;
country?: string;
};
};
if (data.address) {
const addr = data.address;
if (addr.road) {
endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`;
}
cidade = addr.city || addr.town || '';
estado = addr.state || '';
pais = addr.country || '';
}
}
} catch (error) {
console.warn('Erro ao obter endereço:', error);
}
resolve({
latitude,
longitude,
precisao: accuracy,
endereco,
cidade,
estado,
pais,
});
},
(error) => {
clearTimeout(timeout);
console.warn('Erro ao obter localização:', error.code, error.message);
resolve({});
},
options
);
});
// Se obteve localização, retornar
if (resultado.latitude && resultado.longitude) {
console.log('Localização obtida com sucesso:', resultado);
return resultado;
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
{
headers: {
'User-Agent': 'SGSE-App/1.0'
}
}
} catch (error) {
console.warn('Erro na estratégia de geolocalização:', error);
continue;
);
if (response.ok) {
const data = (await response.json()) as {
address?: {
road?: string;
house_number?: string;
city?: string;
town?: string;
state?: string;
country?: string;
};
};
if (data.address) {
const addr = data.address;
if (addr.road) {
endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`;
}
cidade = addr.city || addr.town || '';
estado = addr.state || '';
pais = addr.country || '';
}
}
} catch (error) {
console.warn('Erro ao obter endereço:', error);
}
// Validar timezone vs localização
if (typeof navigator !== 'undefined') {
const timezoneAtual = Intl.DateTimeFormat().resolvedOptions().timeZone;
const timezoneEsperado = obterTimezonePorCoordenadas(latitude, longitude);
// Se timezone é muito diferente, pode ser suspeito
if (timezoneAtual !== timezoneEsperado && timezoneAtual !== 'America/Recife' && timezoneEsperado !== 'America/Recife') {
console.warn(`Timezone inconsistente: esperado ${timezoneEsperado}, atual ${timezoneAtual}`);
}
}
// Se todas as estratégias falharam, retornar vazio
console.warn('Não foi possível obter localização após todas as tentativas');
return {};
console.log('Localização obtida com validações:', {
latitude,
longitude,
confiabilidade: confiabilidade.toFixed(2),
suspeitaSpoofing,
motivoSuspeita
});
return {
latitude,
longitude,
precisao,
altitude,
altitudeAccuracy,
heading,
speed,
confiabilidadeGPS: confiabilidade,
suspeitaSpoofing: suspeitaSpoofing || false,
motivoSuspeita,
endereco,
cidade,
estado,
pais
};
}
/**
@@ -439,6 +691,13 @@ export async function obterInformacoesDispositivo(): Promise<InformacoesDisposit
informacoes.latitude = localizacao.latitude;
informacoes.longitude = localizacao.longitude;
informacoes.precisao = localizacao.precisao;
informacoes.altitude = localizacao.altitude ?? null;
informacoes.altitudeAccuracy = localizacao.altitudeAccuracy ?? null;
informacoes.heading = localizacao.heading ?? null;
informacoes.speed = localizacao.speed ?? null;
informacoes.confiabilidadeGPS = localizacao.confiabilidadeGPS;
informacoes.suspeitaSpoofing = localizacao.suspeitaSpoofing;
informacoes.motivoSuspeita = localizacao.motivoSuspeita;
informacoes.endereco = localizacao.endereco;
informacoes.cidade = localizacao.cidade;
informacoes.estado = localizacao.estado;