diff --git a/src/components/AscGISMap.vue b/src/components/AscGISMap.vue index d78b1bc..7ed48a7 100644 --- a/src/components/AscGISMap.vue +++ b/src/components/AscGISMap.vue @@ -16,7 +16,32 @@ const mapElement = ref(null) const emit = defineEmits(['update:location', 'locationStatus', 'mockDetected']) 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((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) { // ส่ง event ไปยัง parent component เพื่ออัพเดทค่า props @@ -24,6 +49,7 @@ function updateLocation(latitude: number, longitude: number, namePOI: string) { } const poiPlaceName = ref('') // ชื่อพื้นที่ใกล้เคียง +const isMapReady = ref(false) // Replace ArcGIS api key const apiKey = ref( @@ -68,6 +94,7 @@ async function initializeMap(position: GeolocationPosition) { components: [], // Empty array to remove all default UI components }, }) + isMapReady.value = true // ตำแหน่งของผู้ใช้ const userPoint = new Point({ longitude, latitude }) @@ -180,7 +207,9 @@ async function initializeMap(position: GeolocationPosition) { updateLocation(latitude, longitude, poiPlaceName.value) }) .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( async (position) => { - // Validate location first - const validationResult = validateLocation(position) + const sampledPosition = await getPositionForValidation(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) { - showMockWarning(validationResult) - emit('mockDetected', validationResult) + locationGranted.value = true + emit('locationStatus', true) } // Check for critical errors (invalid coordinates) that prevent showing location @@ -234,11 +266,11 @@ const requestLocationPermission = () => { return } - // Permission granted based on mock detection - locationGranted.value = !validationResult.isMockDetected - emit('locationStatus', !validationResult.isMockDetected) + // Permission granted for map preview state. + locationGranted.value = true + emit('locationStatus', true) - const { latitude, longitude } = position.coords + const { latitude, longitude } = sampledPosition.coords // console.log('Current position:', latitude, longitude) if (!latitude || !longitude) { @@ -248,7 +280,7 @@ const requestLocationPermission = () => { // Center map on user's location if map is initialized if (privacyStore.isAccepted) { - await initializeMap(position) + await initializeMap(sampledPosition) } }, (error) => { @@ -279,7 +311,7 @@ const requestLocationPermission = () => { break } }, - { enableHighAccuracy: true } + { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 } ) } @@ -291,7 +323,7 @@ defineExpose({ diff --git a/src/composables/useLocationValidation.ts b/src/composables/useLocationValidation.ts index 2522d07..689af41 100644 --- a/src/composables/useLocationValidation.ts +++ b/src/composables/useLocationValidation.ts @@ -24,6 +24,13 @@ export const VALIDATION_CONFIG = { POSITION_HISTORY_SIZE: 5, // number of positions to keep for pattern detection 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) + // 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 export function useLocationValidation() { @@ -41,6 +48,8 @@ export function useLocationValidation() { 'ตรวจพบการเคลื่อนที่ด้วยความเร็วผิดปกติ อาจเป็นการจำลองตำแหน่ง', SUSPICIOUS_ACCURACY: 'ตรวจพบค่าความแม่นยำที่ผิดปกติ อาจเป็นการจำลองตำแหน่ง', DUPLICATE_POSITION: 'ตรวจพบพิกัดตำแหน่งซ้ำกัน อาจเป็นการจำลองตำแหน่ง', + OUT_OF_SERVICE_AREA: + 'ตรวจพบตำแหน่งนอกพื้นที่ใช้งานของระบบ กรุณาปิดแอปจำลองตำแหน่งและลองใหม่', } const previousPositions = ref([]) @@ -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 const validateLocation = ( position: GeolocationPosition @@ -149,6 +168,12 @@ export function useLocationValidation() { 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 if (!validateTimestamp(timestamp)) { errors.push(errorMessages.STALE_TIMESTAMP) diff --git a/src/stores/mixin.ts b/src/stores/mixin.ts index 3ff5636..fffa00c 100644 --- a/src/stores/mixin.ts +++ b/src/stores/mixin.ts @@ -5,6 +5,56 @@ import { logout } from '@/plugins/auth' import { format, utcToZonedTime } from 'date-fns-tz' 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 + ) => { + 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) { if (srcDate == null) { return null @@ -133,53 +183,30 @@ export const useCounterMixin = defineStore('mixin', () => { } 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.data.status !== undefined) { if (e.response.data.status == 401) { //invalid_token - q.dialog({ - component: CustomComponent, - componentProps: { - title: `พบข้อผิดพลาด`, - message: `ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง`, - icon: 'warning', - color: 'red', - onlycancel: true, - }, - }).onCancel(async () => { - showLoader() - await logout() - setTimeout(() => { - hideLoader() - }, 1000) - }) + openSingleErrorDialog( + q, + 'ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง', + async () => { + showLoader() + await logout() + setTimeout(() => { + hideLoader() + }, 1000) + } + ) } else { const message = e.response.data.result ?? e.response.data.message - q.dialog({ - component: CustomComponent, - componentProps: { - title: `พบข้อผิดพลาด`, - message: `${message}`, - icon: 'warning', - color: 'red', - onlycancel: true, - }, - }) + openSingleErrorDialog(q, `${message}`) } } else { if (e.response.status == 401) { if (msg !== '') { - q.dialog({ - component: CustomComponent, - componentProps: { - title: `พบข้อผิดพลาด`, - message: msg, - icon: 'warning', - color: 'red', - onlycancel: true, - }, - }).onCancel(async () => { + openSingleErrorDialog(q, msg, async () => { showLoader() await logout() setTimeout(() => { @@ -188,70 +215,35 @@ export const useCounterMixin = defineStore('mixin', () => { }) } else { //invalid_token - q.dialog({ - component: CustomComponent, - componentProps: { - title: `พบข้อผิดพลาด`, - message: `ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง`, - icon: 'warning', - color: 'red', - onlycancel: true, - }, - }).onCancel(async () => { - showLoader() - await logout() - setTimeout(() => { - hideLoader() - }, 1000) - }) + openSingleErrorDialog( + q, + 'ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง', + async () => { + showLoader() + await logout() + setTimeout(() => { + hideLoader() + }, 1000) + } + ) } } else if (e.response.data.successful === false) { - q.dialog({ - component: CustomComponent, - componentProps: { - title: `พบข้อผิดพลาด`, - message: e.response.data.message, - icon: 'warning', - color: 'red', - onlycancel: true, - }, - }) + openSingleErrorDialog(q, e.response.data.message) } else { - q.dialog({ - component: CustomComponent, - componentProps: { - title: `พบข้อผิดพลาด`, - message: `ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์`, - icon: 'warning', - color: 'red', - onlycancel: true, - }, - }) + openSingleErrorDialog( + q, + 'ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์' + ) } } } else { if (msg !== '') { - return q.dialog({ - component: CustomComponent, - componentProps: { - title: `พบข้อผิดพลาด`, - message: msg, - icon: 'warning', - color: 'red', - onlycancel: true, - }, - }) + return openSingleErrorDialog(q, msg) } - q.dialog({ - component: CustomComponent, - componentProps: { - title: `พบข้อผิดพลาด`, - message: `ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์`, - icon: 'warning', - color: 'red', - onlycancel: true, - }, - }) + openSingleErrorDialog( + q, + 'ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์' + ) } } diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 379b970..43c14c0 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -8,6 +8,7 @@ import config from '@/app.config' import http from '@/plugins/http' import { useCounterMixin } from '@/stores/mixin' import { usePermissions } from '@/composables/usePermissions' +import { useLocationValidation } from '@/composables/useLocationValidation' import { usePrivacyStore } from '@/stores/privacy' import type { FormRef, OptionReason } from '@/interface/response/checkin' @@ -18,7 +19,10 @@ const mixin = useCounterMixin() const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin const $q = useQuasar() const { checkPrivacyAccepted } = usePermissions() +const { validateLocation, showMockWarning, resetValidation } = + useLocationValidation() const privacyStore = usePrivacyStore() +const MOCK_CHECK_DELAY_MS = 800 const modalTime = ref(false) // Dailog ลงเวลาเข้างานของคุณ const checkStatus = ref('') @@ -120,14 +124,110 @@ async function updateLocation( */ function onLocationStatus(status: boolean) { locationGranted.value = status + if (status) { + isMockLocationDetected.value = false + } } /** * รับค่า mock location detection จาก AscGISMap */ function onMockDetected(result: any) { - isMockLocationDetected.value = true - disabledBtn.value = true + isMockLocationDetected.value = !!result?.isMockDetected + disabledBtn.value = false +} + +function resetLocationForRetry() { + locationGranted.value = false + formLocation.lat = 0 + formLocation.lng = 0 + formLocation.POI = '' +} + +function getCurrentPositionAsync(options?: PositionOptions) { + return new Promise((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('') // พื้นที่ใกล้เคียง @@ -348,7 +448,7 @@ const objectRef: FormRef = { } /** function ตรวจสอบค่าว่างของ input*/ -function validateForm() { +async function validateForm() { const hasError = [] for (const key in objectRef) { if (Object.prototype.hasOwnProperty.call(objectRef, key)) { @@ -360,6 +460,11 @@ function validateForm() { } } if (hasError.every((result) => result === true)) { + const isLocationValid = await revalidateLocationBeforeSubmit() + if (!isLocationValid) { + return + } + if (statusCheckin.value == false) { getCheck() } else if (statusCheckin.value) { @@ -397,6 +502,12 @@ async function confirm() { mapRef.value?.requestLocationPermission() return } + + const isLocationValid = await revalidateLocationBeforeSubmit() + if (!isLocationValid) { + return + } + disabledBtn.value = true showLoader() const isLocation = workplace.value === 'in-place' //*true คือ ณ สถานที่ตั้ง, false คือ นอกสถานที่ตั้ง @@ -1016,7 +1127,13 @@ watch( push size="18px" :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" :loading="inQueue" /> @@ -1131,7 +1248,13 @@ watch( push size="18px" :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" :loading="inQueue" />