fix fack location complated
All checks were successful
Build & Deploy on Dev / build (push) Successful in 2m22s

This commit is contained in:
Warunee Tamkoo 2026-03-11 17:44:39 +07:00
parent 859d74056a
commit 3ae6e6eeac
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 $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) {
// event parent component props
@ -24,6 +49,7 @@ function updateLocation(latitude: number, longitude: number, namePOI: string) {
}
const poiPlaceName = ref<string>('') //
const isMapReady = ref<boolean>(false)
// Replace ArcGIS api key
const apiKey = ref<string>(
@ -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({
<template>
<!-- Loading skeleton -->
<div v-if="!poiPlaceName" class="col-12">
<div v-if="!isMapReady" class="col-12">
<q-skeleton
:height="$q.screen.gt.xs ? '35vh' : '45px'"
width="100%"
@ -301,7 +333,7 @@ defineExpose({
</div>
<q-card
v-show="poiPlaceName"
v-show="isMapReady"
bordered
flat
class="col-12 bg-grey-2 shadow-0"
@ -323,7 +355,7 @@ defineExpose({
>
นทใกลเคยง
<span class="q-px-sm">:</span>
{{ poiPlaceName }}
{{ poiPlaceName || 'ไม่พบข้อมูล' }}
</div>
</div>
@ -340,7 +372,7 @@ defineExpose({
</q-item-section>
<q-item-section>
{{ poiPlaceName }}
{{ poiPlaceName || 'ไม่พบข้อมูล' }}
</q-item-section>
</template>

View file

@ -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<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
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)

View file

@ -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<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) {
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,
'ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์'
)
}
}

View file

@ -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<boolean>(false) // Dailog
const checkStatus = ref<string>('')
@ -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<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>('') //
@ -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"
/>