diff --git a/apps/web/CONFIGURACAO_ENV.md b/apps/web/CONFIGURACAO_ENV.md new file mode 100644 index 0000000..91f3551 --- /dev/null +++ b/apps/web/CONFIGURACAO_ENV.md @@ -0,0 +1,29 @@ +# ⚙️ Configuração de Variáveis de Ambiente + +## 📁 Arquivo .env + +Crie um arquivo `.env` na pasta `apps/web/` com as seguintes variáveis: + +```env +# Google Maps API Key (opcional) +# Obtenha sua chave em: https://console.cloud.google.com/ +# Ative a "Geocoding API" para buscar coordenadas por endereço +# Deixe vazio para usar OpenStreetMap (gratuito, sem necessidade de chave) +VITE_GOOGLE_MAPS_API_KEY= + +# VAPID Public Key para Push Notifications (opcional) +VITE_VAPID_PUBLIC_KEY= +``` + +## 📖 Documentação Completa + +Para instruções detalhadas sobre como obter e configurar a Google Maps API Key, consulte: + +📄 **[GOOGLE_MAPS_SETUP.md](./GOOGLE_MAPS_SETUP.md)** + +## ⚠️ Importante + +- O arquivo `.env` não deve ser commitado no Git (já está no .gitignore) +- Variáveis de ambiente começam com `VITE_` para serem acessíveis no frontend +- Reinicie o servidor de desenvolvimento após alterar o arquivo `.env` + diff --git a/apps/web/GOOGLE_MAPS_SETUP.md b/apps/web/GOOGLE_MAPS_SETUP.md new file mode 100644 index 0000000..7ccfb87 --- /dev/null +++ b/apps/web/GOOGLE_MAPS_SETUP.md @@ -0,0 +1,174 @@ +# 📍 Configuração do Google Maps API para Busca de Coordenadas + +Este guia explica como configurar a API do Google Maps para obter coordenadas GPS de forma automática e precisa no sistema de Endereços de Marcação. + +## 🎯 Por que usar Google Maps? + +- ✅ **Maior Precisão**: Resultados mais exatos para endereços brasileiros +- ✅ **Melhor Cobertura**: Banco de dados mais completo e atualizado +- ✅ **Geocoding Avançado**: Entende melhor endereços incompletos ou parciais + +> **Nota**: O sistema funciona perfeitamente sem a API key do Google Maps, usando OpenStreetMap (gratuito). A configuração do Google Maps é opcional. + +--- + +## 📋 Passo a Passo + +### 1. Criar Projeto no Google Cloud Platform + +1. Acesse [Google Cloud Console](https://console.cloud.google.com/) +2. Clique em **"Criar Projeto"** ou selecione um projeto existente +3. Preencha o nome do projeto (ex: "SGSE-App") +4. Clique em **"Criar"** + +### 2. Ativar a Geocoding API + +1. No menu lateral, vá em **"APIs e Serviços"** > **"Biblioteca"** +2. Procure por **"Geocoding API"** +3. Clique no resultado e depois em **"Ativar"** +4. Aguarde alguns segundos para a ativação + +### 3. Criar Chave de API + +1. Ainda em **"APIs e Serviços"**, vá em **"Credenciais"** +2. Clique em **"Criar Credenciais"** > **"Chave de API"** +3. Copie a chave gerada (você precisará dela depois) + +### 4. Configurar Restrições de Segurança (Recomendado) + +Para proteger sua chave de API: + +1. Clique na chave criada para editá-la +2. Em **"Restrições de API"**: + - Selecione **"Restringir chave"** + - Escolha **"Geocoding API"** +3. Em **"Restrições de aplicativo"**: + - Para desenvolvimento local: escolha **"Referenciadores de sites HTTP"** + - Adicione: `http://localhost:*` e `http://127.0.0.1:*` + - Para produção: adicione o domínio do seu site +4. Clique em **"Salvar"** + +### 5. Configurar no Projeto + +1. No diretório `apps/web/`, copie o arquivo de exemplo: + ```bash + cp .env.example .env + ``` + +2. Abra o arquivo `.env` e adicione sua chave: + ```env + VITE_GOOGLE_MAPS_API_KEY=sua_chave_aqui + ``` + +3. Reinicie o servidor de desenvolvimento: + ```bash + npm run dev + ``` + +### 6. Verificar se está funcionando + +1. Acesse a página de **Endereços de Marcação** (`/ti/configuracoes-ponto/enderecos`) +2. Clique em **"Novo Endereço"** +3. Preencha um endereço e clique em **"Buscar GPS"** +4. Se configurado corretamente, verá a mensagem: *"Coordenadas encontradas via Google Maps!"* + +--- + +## 💰 Custos + +### Google Maps Geocoding API + +- **$5.00 por 1.000 requisições** (primeiros 40.000 são gratuitos por mês) +- **$0.005 por requisição** após os 40.000 gratuitos + +> 💡 Para a maioria dos casos de uso, os 40.000 gratuitos são suficientes! + +### OpenStreetMap (Fallback) + +- **100% Gratuito** e ilimitado +- Sem necessidade de configuração +- Precisão levemente menor, mas ainda muito boa + +--- + +## 🔄 Como funciona o sistema + +O sistema foi projetado para usar uma estratégia de **fallback inteligente**: + +1. **Primeiro**: Tenta buscar via Google Maps (se API key configurada) +2. **Se falhar ou não tiver API key**: Usa automaticamente OpenStreetMap +3. **Feedback**: Informa qual serviço foi usado na mensagem de sucesso + +Isso garante que o sistema sempre funcione, mesmo sem a API key do Google Maps. + +--- + +## 🔒 Segurança + +### ⚠️ Importante + +- **Nunca** commite o arquivo `.env` no Git (já está no .gitignore) +- **Nunca** compartilhe sua chave de API publicamente +- Configure **restrições de API** no Google Cloud Console +- Para produção, use variáveis de ambiente seguras no seu provedor de hospedagem + +### Configuração em Produção + +Para ambientes de produção (Vercel, Netlify, etc.): + +1. Acesse as configurações do projeto no seu provedor +2. Vá em **"Environment Variables"** ou **"Variáveis de Ambiente"** +3. Adicione: `VITE_GOOGLE_MAPS_API_KEY` com o valor da sua chave +4. Faça o deploy novamente + +--- + +## ❓ Solução de Problemas + +### A busca não está usando Google Maps + +- Verifique se a variável `VITE_GOOGLE_MAPS_API_KEY` está no arquivo `.env` +- Reinicie o servidor de desenvolvimento +- Verifique no console do navegador se há erros + +### Erro: "This API project is not authorized to use this API" + +- Verifique se a **Geocoding API** está ativada no projeto +- Aguarde alguns minutos após a ativação (pode levar até 5 minutos) + +### Erro: "API key not valid" + +- Verifique se copiou a chave corretamente +- Verifique se as restrições de API permitem o uso da Geocoding API +- Verifique se as restrições de aplicativo permitem seu domínio/endereço + +### Mensagem: "Coordenadas encontradas via OpenStreetMap" + +- Isso é normal se: + - Não há API key configurada + - A API key não é válida + - O Google Maps falhou na busca +- O sistema continua funcionando normalmente com OpenStreetMap + +--- + +## 📚 Recursos Úteis + +- [Google Cloud Console](https://console.cloud.google.com/) +- [Documentação Geocoding API](https://developers.google.com/maps/documentation/geocoding) +- [Preços Google Maps](https://developers.google.com/maps/billing-and-pricing/pricing) +- [OpenStreetMap Nominatim](https://nominatim.org/) + +--- + +## ✅ Resumo + +1. ✅ Crie projeto no Google Cloud +2. ✅ Ative Geocoding API +3. ✅ Crie chave de API +4. ✅ Configure restrições (recomendado) +5. ✅ Adicione `VITE_GOOGLE_MAPS_API_KEY` no `.env` +6. ✅ Reinicie o servidor + +**Pronto!** O sistema agora usará Google Maps para busca de coordenadas com maior precisão. + diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index 6ea6ff4..ed3bfb4 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -747,55 +747,13 @@ {/if} - -
-
- -
-
- - -
-
-

- - Horários do Dia -

-
- {#each mapaHorarios as horario (horario.tipo)} -
-
-
- {horario.label} - {#if horario.registrado} - {#if horario.dentroDoPrazo} - - {:else} - - {/if} - {/if} -
-
{horario.horario}
- {#if horario.registrado} -
- Registrado: {horario.horarioRegistrado} -
- {/if} -
-
- {/each} -
-
-
-

Registrar Ponto

+
+ +
{#if sucesso}
@@ -858,6 +816,78 @@
+ +
+
+

+ + Horário Padrão +

+ + +
+ {#each mapaHorarios as horario (horario.tipo)} +
+
+ +
+ {#if horario.registrado} + {#if horario.dentroDoPrazo} + + {:else} + + {/if} + {:else} + + {/if} +
+ + +
+ + {horario.label} + +
+ + +
+
+ {horario.horario} +
+
+ + + {#if horario.registrado} +
+
+
+ Registrado: +
+
+ {horario.horarioRegistrado} +
+
+
+ {:else} +
+
+ Aguardando registro +
+
+ {/if} +
+
+ {/each} +
+
+
+ {#if historicoSaldo && registrosOrdenados.length > 0}
@@ -888,57 +918,224 @@
- +
-
-

Registros Realizados

-
- {#each registrosOrdenados as registro (registro._id)} -
-
-
-
-
- - {config - ? getTipoRegistroLabel(registro.tipo, { - nomeEntrada: config.nomeEntrada, - nomeSaidaAlmoco: config.nomeSaidaAlmoco, - nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida, - }) - : getTipoRegistroLabel(registro.tipo)} - - {#if registro.dentroDoPrazo} - - {:else} - - {/if} -
-

- {formatarHoraPonto(registro.hora, registro.minuto)} -

- {#if registro.justificativa} -
-

Justificativa:

-

{registro.justificativa}

+
+

Timeline do Dia

+ + +
+ +
+ + +
+ +
+
+

+ + Entradas +

+
+ + {#each registrosOrdenados.filter(r => r.tipo === 'entrada' || r.tipo === 'retorno_almoco') as registro (registro._id)} +
+ +
+ + +
+
+ +
+ {#if registro.dentroDoPrazo} + + {:else} + + {/if} + + {config + ? getTipoRegistroLabel(registro.tipo, { + nomeEntrada: config.nomeEntrada, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + }) + : getTipoRegistroLabel(registro.tipo)} +
- {/if} -
-
- + + +

+ {formatarHoraPonto(registro.hora, registro.minuto)} +

+ + + {#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`} + +
+ Esperado: + {horarioEsperado} + {#if diferencaAbs > 0} + + {diferenca > 0 ? '+' : '-'}{diferencaTexto} + + {/if} +
+ {/if} + + {#if registro.justificativa} +
+

Justificativa:

+

{registro.justificativa}

+
+ {/if} + + +
-
+ {/each} + + + {#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)} +
+
+
+
+

{horarioEsperado.label} (não registrado)

+

{horarioEsperado.horario}

+
+
+
+ {/if} + {/each} + {/if}
- {/each} + + +
+
+

+ + Saídas +

+
+ + {#each registrosOrdenados.filter(r => r.tipo === 'saida_almoco' || r.tipo === 'saida') as registro (registro._id)} +
+ +
+ + +
+
+ +
+ + {config + ? getTipoRegistroLabel(registro.tipo, { + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeSaida: config.nomeSaida, + }) + : getTipoRegistroLabel(registro.tipo)} + + {#if registro.dentroDoPrazo} + + {:else} + + {/if} +
+ + +

+ {formatarHoraPonto(registro.hora, registro.minuto)} +

+ + + {#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`} + +
+ {#if diferencaAbs > 0} + + {diferenca > 0 ? '+' : '-'}{diferencaTexto} + + {/if} + {horarioEsperado} + Esperado: +
+ {/if} + + {#if registro.justificativa} +
+

Justificativa:

+

{registro.justificativa}

+
+ {/if} + + +
+
+
+ {/each} + + + {#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)} +
+
+
+
+

{horarioEsperado.label} (não registrado)

+

{horarioEsperado.horario}

+
+
+
+ {/if} + {/each} + {/if} +
+
diff --git a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte index aaf2e5b..1bb6e23 100644 --- a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte +++ b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte @@ -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()); diff --git a/apps/web/src/lib/utils/deviceInfo.ts b/apps/web/src/lib/utils/deviceInfo.ts index f0f2c0f..ed3526a 100644 --- a/apps/web/src/lib/utils/deviceInfo.ts +++ b/apps/web/src/lib/utils/deviceInfo.ts @@ -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 8; -
+
-