fix fack location complated

This commit is contained in:
Warunee Tamkoo 2026-03-11 17:44:39 +07:00
parent 859d74056a
commit e8ddd43209
4 changed files with 287 additions and 115 deletions

View file

@ -16,7 +16,32 @@ const mapElement = ref<HTMLElement | null>(null)
const emit = defineEmits(['update:location', 'locationStatus', 'mockDetected']) const emit = defineEmits(['update:location', 'locationStatus', 'mockDetected'])
const $q = useQuasar() const $q = useQuasar()
const { validateLocation, showMockWarning } = useLocationValidation() const { validateLocation } = useLocationValidation()
const MOCK_CHECK_DELAY_MS = 800
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getCurrentPositionAsync(options?: PositionOptions) {
return new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options)
})
}
async function getPositionForValidation(fallback: GeolocationPosition) {
await wait(MOCK_CHECK_DELAY_MS)
try {
return await getCurrentPositionAsync({
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
})
} catch (error) {
// Keep the original reading if the second sampling fails.
return fallback
}
}
function updateLocation(latitude: number, longitude: number, namePOI: string) { function updateLocation(latitude: number, longitude: number, namePOI: string) {
// event parent component props // event parent component props
@ -24,6 +49,7 @@ function updateLocation(latitude: number, longitude: number, namePOI: string) {
} }
const poiPlaceName = ref<string>('') // const poiPlaceName = ref<string>('') //
const isMapReady = ref<boolean>(false)
// Replace ArcGIS api key // Replace ArcGIS api key
const apiKey = ref<string>( const apiKey = ref<string>(
@ -68,6 +94,7 @@ async function initializeMap(position: GeolocationPosition) {
components: [], // Empty array to remove all default UI components components: [], // Empty array to remove all default UI components
}, },
}) })
isMapReady.value = true
// //
const userPoint = new Point({ longitude, latitude }) const userPoint = new Point({ longitude, latitude })
@ -180,7 +207,9 @@ async function initializeMap(position: GeolocationPosition) {
updateLocation(latitude, longitude, poiPlaceName.value) updateLocation(latitude, longitude, poiPlaceName.value)
}) })
.catch((error) => { .catch((error) => {
// console.error('Error fetching points of interest:', error) // Keep map visible even when POI lookup fails.
poiPlaceName.value = 'ไม่พบข้อมูล'
updateLocation(latitude, longitude, poiPlaceName.value)
}) })
}) })
}) })
@ -213,13 +242,16 @@ const requestLocationPermission = () => {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
async (position) => { async (position) => {
// Validate location first const sampledPosition = await getPositionForValidation(position)
const validationResult = validateLocation(position)
// Always emit mockDetected event (regardless of result) // Validate location first
const validationResult = validateLocation(sampledPosition)
// Do not block map preview on initial mock detection.
// The hard-stop warning is enforced at submit time in HomeView.
if (validationResult.isMockDetected) { if (validationResult.isMockDetected) {
showMockWarning(validationResult) locationGranted.value = true
emit('mockDetected', validationResult) emit('locationStatus', true)
} }
// Check for critical errors (invalid coordinates) that prevent showing location // Check for critical errors (invalid coordinates) that prevent showing location
@ -234,11 +266,11 @@ const requestLocationPermission = () => {
return return
} }
// Permission granted based on mock detection // Permission granted for map preview state.
locationGranted.value = !validationResult.isMockDetected locationGranted.value = true
emit('locationStatus', !validationResult.isMockDetected) emit('locationStatus', true)
const { latitude, longitude } = position.coords const { latitude, longitude } = sampledPosition.coords
// console.log('Current position:', latitude, longitude) // console.log('Current position:', latitude, longitude)
if (!latitude || !longitude) { if (!latitude || !longitude) {
@ -248,7 +280,7 @@ const requestLocationPermission = () => {
// Center map on user's location if map is initialized // Center map on user's location if map is initialized
if (privacyStore.isAccepted) { if (privacyStore.isAccepted) {
await initializeMap(position) await initializeMap(sampledPosition)
} }
}, },
(error) => { (error) => {
@ -279,7 +311,7 @@ const requestLocationPermission = () => {
break break
} }
}, },
{ enableHighAccuracy: true } { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
) )
} }
@ -291,7 +323,7 @@ defineExpose({
<template> <template>
<!-- Loading skeleton --> <!-- Loading skeleton -->
<div v-if="!poiPlaceName" class="col-12"> <div v-if="!isMapReady" class="col-12">
<q-skeleton <q-skeleton
:height="$q.screen.gt.xs ? '35vh' : '45px'" :height="$q.screen.gt.xs ? '35vh' : '45px'"
width="100%" width="100%"
@ -301,7 +333,7 @@ defineExpose({
</div> </div>
<q-card <q-card
v-show="poiPlaceName" v-show="isMapReady"
bordered bordered
flat flat
class="col-12 bg-grey-2 shadow-0" class="col-12 bg-grey-2 shadow-0"
@ -323,7 +355,7 @@ defineExpose({
> >
นทใกลเคยง นทใกลเคยง
<span class="q-px-sm">:</span> <span class="q-px-sm">:</span>
{{ poiPlaceName }} {{ poiPlaceName || 'ไม่พบข้อมูล' }}
</div> </div>
</div> </div>
@ -340,7 +372,7 @@ defineExpose({
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
{{ poiPlaceName }} {{ poiPlaceName || 'ไม่พบข้อมูล' }}
</q-item-section> </q-item-section>
</template> </template>

View file

@ -24,6 +24,13 @@ export const VALIDATION_CONFIG = {
POSITION_HISTORY_SIZE: 5, // number of positions to keep for pattern detection POSITION_HISTORY_SIZE: 5, // number of positions to keep for pattern detection
MOCK_INDICATOR_THRESHOLD: 3, // threshold for mock detection (indicators >= 3 = mock) MOCK_INDICATOR_THRESHOLD: 3, // threshold for mock detection (indicators >= 3 = mock)
SUSPICIOUS_ACCURACY_MAX: 5, // accuracy ≤ 5m AND integer = suspicious (real GPS never rounds to whole number) SUSPICIOUS_ACCURACY_MAX: 5, // accuracy ≤ 5m AND integer = suspicious (real GPS never rounds to whole number)
// Geographic service area (Thailand) to reduce spoofing risk from remote fake coordinates.
SERVICE_AREA: {
MIN_LAT: 5.0,
MAX_LAT: 21.0,
MIN_LON: 97.0,
MAX_LON: 106.0,
},
} as const } as const
export function useLocationValidation() { export function useLocationValidation() {
@ -41,6 +48,8 @@ export function useLocationValidation() {
'ตรวจพบการเคลื่อนที่ด้วยความเร็วผิดปกติ อาจเป็นการจำลองตำแหน่ง', 'ตรวจพบการเคลื่อนที่ด้วยความเร็วผิดปกติ อาจเป็นการจำลองตำแหน่ง',
SUSPICIOUS_ACCURACY: 'ตรวจพบค่าความแม่นยำที่ผิดปกติ อาจเป็นการจำลองตำแหน่ง', SUSPICIOUS_ACCURACY: 'ตรวจพบค่าความแม่นยำที่ผิดปกติ อาจเป็นการจำลองตำแหน่ง',
DUPLICATE_POSITION: 'ตรวจพบพิกัดตำแหน่งซ้ำกัน อาจเป็นการจำลองตำแหน่ง', DUPLICATE_POSITION: 'ตรวจพบพิกัดตำแหน่งซ้ำกัน อาจเป็นการจำลองตำแหน่ง',
OUT_OF_SERVICE_AREA:
'ตรวจพบตำแหน่งนอกพื้นที่ใช้งานของระบบ กรุณาปิดแอปจำลองตำแหน่งและลองใหม่',
} }
const previousPositions = ref<PositionSnapshot[]>([]) const previousPositions = ref<PositionSnapshot[]>([])
@ -132,6 +141,16 @@ export function useLocationValidation() {
) )
} }
const isWithinServiceArea = (lat: number, lon: number): boolean => {
const area = VALIDATION_CONFIG.SERVICE_AREA
return (
lat >= area.MIN_LAT &&
lat <= area.MAX_LAT &&
lon >= area.MIN_LON &&
lon <= area.MAX_LON
)
}
// Main validation function // Main validation function
const validateLocation = ( const validateLocation = (
position: GeolocationPosition position: GeolocationPosition
@ -149,6 +168,12 @@ export function useLocationValidation() {
mockIndicators += 3 mockIndicators += 3
} }
// 1.1 Service-area validation (critical for remote spoofed coordinates)
if (!isWithinServiceArea(latitude, longitude)) {
errors.push(errorMessages.OUT_OF_SERVICE_AREA)
mockIndicators += 3
}
// 2. Timestamp validation // 2. Timestamp validation
if (!validateTimestamp(timestamp)) { if (!validateTimestamp(timestamp)) {
errors.push(errorMessages.STALE_TIMESTAMP) errors.push(errorMessages.STALE_TIMESTAMP)

View file

@ -5,6 +5,56 @@ import { logout } from '@/plugins/auth'
import { format, utcToZonedTime } from 'date-fns-tz' import { format, utcToZonedTime } from 'date-fns-tz'
export const useCounterMixin = defineStore('mixin', () => { export const useCounterMixin = defineStore('mixin', () => {
let activeErrorDialog: any = null
const clearActiveErrorDialog = () => {
if (!activeErrorDialog) return
try {
if (typeof activeErrorDialog.hide === 'function') {
activeErrorDialog.hide()
} else if (typeof activeErrorDialog.destroy === 'function') {
activeErrorDialog.destroy()
}
} finally {
activeErrorDialog = null
}
}
const openSingleErrorDialog = (
q: any,
message: string,
onCancel?: () => void | Promise<void>
) => {
clearActiveErrorDialog()
const dialog = q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
activeErrorDialog = dialog
dialog.onDismiss(() => {
if (activeErrorDialog === dialog) {
activeErrorDialog = null
}
})
if (onCancel) {
dialog.onCancel(() => {
void onCancel()
})
}
return dialog
}
function date2Thai(srcDate: Date, isFullMonth = false, isTime = false) { function date2Thai(srcDate: Date, isFullMonth = false, isTime = false) {
if (srcDate == null) { if (srcDate == null) {
return null return null
@ -133,53 +183,30 @@ export const useCounterMixin = defineStore('mixin', () => {
} }
const messageError = (q: any, e: any = '', msg: string = '') => { const messageError = (q: any, e: any = '', msg: string = '') => {
// q.dialog.hide(); // Keep only one active warning popup to prevent dialog overlap.
if (e.response !== undefined) { if (e.response !== undefined) {
if (e.response.data.status !== undefined) { if (e.response.data.status !== undefined) {
if (e.response.data.status == 401) { if (e.response.data.status == 401) {
//invalid_token //invalid_token
q.dialog({ openSingleErrorDialog(
component: CustomComponent, q,
componentProps: { 'ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง',
title: `พบข้อผิดพลาด`, async () => {
message: `ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง`, showLoader()
icon: 'warning', await logout()
color: 'red', setTimeout(() => {
onlycancel: true, hideLoader()
}, }, 1000)
}).onCancel(async () => { }
showLoader() )
await logout()
setTimeout(() => {
hideLoader()
}, 1000)
})
} else { } else {
const message = e.response.data.result ?? e.response.data.message const message = e.response.data.result ?? e.response.data.message
q.dialog({ openSingleErrorDialog(q, `${message}`)
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `${message}`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
} }
} else { } else {
if (e.response.status == 401) { if (e.response.status == 401) {
if (msg !== '') { if (msg !== '') {
q.dialog({ openSingleErrorDialog(q, msg, async () => {
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: msg,
icon: 'warning',
color: 'red',
onlycancel: true,
},
}).onCancel(async () => {
showLoader() showLoader()
await logout() await logout()
setTimeout(() => { setTimeout(() => {
@ -188,70 +215,35 @@ export const useCounterMixin = defineStore('mixin', () => {
}) })
} else { } else {
//invalid_token //invalid_token
q.dialog({ openSingleErrorDialog(
component: CustomComponent, q,
componentProps: { 'ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง',
title: `พบข้อผิดพลาด`, async () => {
message: `ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง`, showLoader()
icon: 'warning', await logout()
color: 'red', setTimeout(() => {
onlycancel: true, hideLoader()
}, }, 1000)
}).onCancel(async () => { }
showLoader() )
await logout()
setTimeout(() => {
hideLoader()
}, 1000)
})
} }
} else if (e.response.data.successful === false) { } else if (e.response.data.successful === false) {
q.dialog({ openSingleErrorDialog(q, e.response.data.message)
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: e.response.data.message,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
} else { } else {
q.dialog({ openSingleErrorDialog(
component: CustomComponent, q,
componentProps: { 'ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์'
title: `พบข้อผิดพลาด`, )
message: `ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
} }
} }
} else { } else {
if (msg !== '') { if (msg !== '') {
return q.dialog({ return openSingleErrorDialog(q, msg)
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: msg,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
} }
q.dialog({ openSingleErrorDialog(
component: CustomComponent, q,
componentProps: { 'ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์'
title: `พบข้อผิดพลาด`, )
message: `ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
} }
} }

View file

@ -8,6 +8,7 @@ import config from '@/app.config'
import http from '@/plugins/http' import http from '@/plugins/http'
import { useCounterMixin } from '@/stores/mixin' import { useCounterMixin } from '@/stores/mixin'
import { usePermissions } from '@/composables/usePermissions' import { usePermissions } from '@/composables/usePermissions'
import { useLocationValidation } from '@/composables/useLocationValidation'
import { usePrivacyStore } from '@/stores/privacy' import { usePrivacyStore } from '@/stores/privacy'
import type { FormRef, OptionReason } from '@/interface/response/checkin' import type { FormRef, OptionReason } from '@/interface/response/checkin'
@ -18,7 +19,10 @@ const mixin = useCounterMixin()
const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin
const $q = useQuasar() const $q = useQuasar()
const { checkPrivacyAccepted } = usePermissions() const { checkPrivacyAccepted } = usePermissions()
const { validateLocation, showMockWarning, resetValidation } =
useLocationValidation()
const privacyStore = usePrivacyStore() const privacyStore = usePrivacyStore()
const MOCK_CHECK_DELAY_MS = 800
const modalTime = ref<boolean>(false) // Dailog const modalTime = ref<boolean>(false) // Dailog
const checkStatus = ref<string>('') const checkStatus = ref<string>('')
@ -120,14 +124,110 @@ async function updateLocation(
*/ */
function onLocationStatus(status: boolean) { function onLocationStatus(status: boolean) {
locationGranted.value = status locationGranted.value = status
if (status) {
isMockLocationDetected.value = false
}
} }
/** /**
* บค mock location detection จาก AscGISMap * บค mock location detection จาก AscGISMap
*/ */
function onMockDetected(result: any) { function onMockDetected(result: any) {
isMockLocationDetected.value = true isMockLocationDetected.value = !!result?.isMockDetected
disabledBtn.value = true disabledBtn.value = false
}
function resetLocationForRetry() {
locationGranted.value = false
formLocation.lat = 0
formLocation.lng = 0
formLocation.POI = ''
}
function getCurrentPositionAsync(options?: PositionOptions) {
return new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options)
})
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function getDelayedFreshPosition() {
const firstPosition = await getCurrentPositionAsync({
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
})
await wait(MOCK_CHECK_DELAY_MS)
try {
return await getCurrentPositionAsync({
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
})
} catch (error) {
return firstPosition
}
}
async function revalidateLocationBeforeSubmit() {
if (!navigator.geolocation) {
messageError(
$q,
'',
'ไม่สามารถระบุตำแหน่งปัจจุบันได้ เบราว์เซอร์ของคุณไม่รองรับ Geolocation'
)
return false
}
try {
// If previous attempt was mock, clear history so fresh GPS is not compared
// against spoofed coordinates and incorrectly flagged as impossible speed.
if (isMockLocationDetected.value) {
resetValidation()
}
const position = await getDelayedFreshPosition()
const validationResult = validateLocation(position)
if (validationResult.isMockDetected) {
isMockLocationDetected.value = true
disabledBtn.value = false
resetValidation()
resetLocationForRetry()
showMockWarning(validationResult)
mapRef.value?.requestLocationPermission()
return false
}
if (validationResult.errors.length > 0) {
disabledBtn.value = false
resetValidation()
resetLocationForRetry()
messageError($q, '', validationResult.errors[0])
mapRef.value?.requestLocationPermission()
return false
}
locationGranted.value = true
isMockLocationDetected.value = false
return true
} catch (error) {
disabledBtn.value = false
resetLocationForRetry()
messageError(
$q,
'',
'ไม่สามารถตรวจสอบตำแหน่งล่าสุดก่อนลงเวลาได้ กรุณาลองใหม่อีกครั้ง'
)
mapRef.value?.requestLocationPermission()
return false
}
} }
const location = ref<string>('') // const location = ref<string>('') //
@ -348,7 +448,7 @@ const objectRef: FormRef = {
} }
/** function ตรวจสอบค่าว่างของ input*/ /** function ตรวจสอบค่าว่างของ input*/
function validateForm() { async function validateForm() {
const hasError = [] const hasError = []
for (const key in objectRef) { for (const key in objectRef) {
if (Object.prototype.hasOwnProperty.call(objectRef, key)) { if (Object.prototype.hasOwnProperty.call(objectRef, key)) {
@ -360,6 +460,11 @@ function validateForm() {
} }
} }
if (hasError.every((result) => result === true)) { if (hasError.every((result) => result === true)) {
const isLocationValid = await revalidateLocationBeforeSubmit()
if (!isLocationValid) {
return
}
if (statusCheckin.value == false) { if (statusCheckin.value == false) {
getCheck() getCheck()
} else if (statusCheckin.value) { } else if (statusCheckin.value) {
@ -397,6 +502,12 @@ async function confirm() {
mapRef.value?.requestLocationPermission() mapRef.value?.requestLocationPermission()
return return
} }
const isLocationValid = await revalidateLocationBeforeSubmit()
if (!isLocationValid) {
return
}
disabledBtn.value = true disabledBtn.value = true
showLoader() showLoader()
const isLocation = workplace.value === 'in-place' //*true , false const isLocation = workplace.value === 'in-place' //*true , false
@ -1016,7 +1127,13 @@ watch(
push push
size="18px" size="18px"
:class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'" :class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'"
:disable="disabledBtn || !locationGranted || isMockLocationDetected ? true : camera && img ? false : true" :disable="
disabledBtn || !locationGranted || isMockLocationDetected
? true
: camera && img
? false
: true
"
@click="validateForm" @click="validateForm"
:loading="inQueue" :loading="inQueue"
/> />
@ -1131,7 +1248,13 @@ watch(
push push
size="18px" size="18px"
:class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'" :class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'"
:disable="disabledBtn || !locationGranted || isMockLocationDetected ? true : camera && img ? false : true" :disable="
disabledBtn || !locationGranted || isMockLocationDetected
? true
: camera && img
? false
: true
"
@click="validateForm" @click="validateForm"
:loading="inQueue" :loading="inQueue"
/> />