TKK_E32230273/verification.php

734 lines
35 KiB
PHP

<?php
include 'koneksi.php';
$tokenParam = isset($_GET['token']) ? trim($_GET['token']) : '';
$valid = false;
$fotoRef = '';
$occupantName = '';
$pin = '';
$idParam = '';
if ($tokenParam !== '') {
// Proses jika form disubmit via AJAX untuk aksi sukses
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$postToken = $_POST['token'] ?? '';
if ($action === 'success' && !empty($postToken)) {
$manifestPath = __DIR__ . '/manifest_pin_tokens.json';
if (file_exists($manifestPath)) {
$tokens = json_decode(file_get_contents($manifestPath), true) ?: [];
if (isset($tokens[$postToken])) {
$tokenData = $tokens[$postToken];
// Edit Message
if (isset($tokenData['msg_id'])) {
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
$id_occupant = $tokenData['id_occupant'];
$tstmt = $pdo->prepare("SELECT telegram_id FROM occupant WHERE id_occupant = ?");
$tstmt->execute([$id_occupant]);
$tdata = $tstmt->fetch(PDO::FETCH_ASSOC);
if ($tdata && $tdata['telegram_id']) {
$chatId = $tdata['telegram_id'];
@file_get_contents("https://api.telegram.org/bot$botToken/deleteMessage?chat_id=$chatId&message_id={$tokenData['msg_id']}");
if (isset($tokenData['user_msg_id'])) {
@file_get_contents("https://api.telegram.org/bot$botToken/deleteMessage?chat_id=$chatId&message_id={$tokenData['user_msg_id']}");
}
}
}
unset($tokens[$postToken]);
file_put_contents($manifestPath, json_encode($tokens, JSON_PRETTY_PRINT));
}
}
echo json_encode(['status'=>'ok']);
exit;
}
}
// Validasi token via local request atau include logic langsung
// Karena ini di file yang sama (local), kita bisa request HTTP ke server sendiri
// Tapi untuk mudahnya karena sudah ada db:
$manifestPath = __DIR__ . '/manifest_pin_tokens.json';
if (file_exists($manifestPath)) {
$json = file_get_contents($manifestPath);
$tokens = json_decode($json, true) ?: [];
if (isset($tokens[$tokenParam])) {
$tokenData = $tokens[$tokenParam];
if (time() <= $tokenData['expires_at']) {
$idParam = $tokenData['id_occupant'];
$stmt = $pdo->prepare("SELECT name, foto, pin FROM occupant WHERE id_occupant = ?");
$stmt->execute([$idParam]);
$row = $stmt->fetch();
if ($row && !empty($row['foto'])) {
$valid = true;
$fotoRef = htmlspecialchars($row['foto']);
$occupantName = htmlspecialchars($row['name']);
$pin = htmlspecialchars($row['pin']);
}
} else {
// Expired
unset($tokens[$tokenParam]);
file_put_contents($manifestPath, json_encode($tokens, JSON_PRETTY_PRINT));
}
}
}
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="theme-color" content="#ffffff">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>Face Verification — Identia</title>
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
:root {
--primary: #4747a1;
--primary-light: #e6e6f2;
--primary-glow: rgba(71, 71, 161, 0.4);
--success: #4747a1;
--success-glow: rgba(71, 71, 161, 0.4);
--danger: #ef4444;
--bg-main: #f8fafc;
--bg-glass: rgba(255, 255, 255, 0.85);
--text-main: #0f172a;
--text-muted: #64748b;
}
* { margin:0; padding:0; box-sizing:border-box; user-select:none; -webkit-user-select:none; }
html, body {
height:100%; width:100%; background:var(--bg-main);
font-family:'Inter',sans-serif; overflow:hidden; display:flex;
align-items:center; justify-content:center;
}
.phone { width:100%; height:100vh; background:#1e2223; display:flex; flex-direction:column; position:relative; overflow:hidden; }
.header {
display:flex; justify-content:space-between; align-items:center;
padding:20px 20px; z-index:30; position:absolute; top:0; left:0; right:0;
background: transparent;
}
.page { flex:1; display:flex; flex-direction:column; }
.page-hidden { display:none !important; }
.page-error { align-items:center; justify-content:center; padding:40px 30px; text-align:center; background: var(--bg-main); z-index: 50; position: absolute; inset: 0; }
.err-icon { width:80px; height:80px; border-radius:24px; display:flex; align-items:center; justify-content:center; margin-bottom:8px; background:#fef2f2; border:1px solid #fecaca; color:var(--danger); }
.err-icon i { font-size:40px; }
.page-error h2 { font-size:22px; font-weight:800; color:var(--text-main); }
.page-error p { font-size:14px; color:var(--text-muted); line-height:1.6; max-width:300px; margin-top: 8px; }
.cam-area { flex:1; position:relative; overflow:hidden; background:#1e2223; }
.cam-area video { width:100%; height:100%; object-fit:cover; transform:scaleX(-1); }
.cam-area canvas.overlay-cv { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; }
.face-oval { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index: 10; }
.face-oval svg { width:100%; height:100%; }
#bgRect { fill: #ffffff; }
#ovalRing { stroke: #e2e8f0; stroke-width: 6; display: block; }
#ovalRingOut { stroke: none; display: block; }
.face-oval.detecting #bgRect { animation: bankBgGlow 0.5s infinite; filter: brightness(1.2); }
.face-oval.success #bgRect { fill: #00FF00; animation: none; filter: brightness(1.2); }
.face-oval.error #bgRect { fill: #FF0000; animation: none; filter: brightness(1.2); }
@keyframes bankBgGlow {
0% { fill: #FF0000; }
25% { fill: #FFFF00; }
50% { fill: #00FF00; }
75% { fill: #00FFFF; }
100% { fill: #FF0000; }
}
.cam-loading {
position:absolute; inset:0; background:rgba(255, 255, 255, 0.95); backdrop-filter:blur(10px);
display:flex; flex-direction:column; align-items:center; justify-content:center; z-index:25; gap:20px;
}
.cam-loading .ldring {
width:48px; height:48px; border-radius:50%; border:3px solid var(--primary-light); border-top-color:var(--primary);
animation:spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes spin { to { transform:rotate(360deg); } }
.cam-loading p { font-size:14px; color:var(--text-muted); font-weight:600; text-align:center; }
.load-progress { width:200px; height:6px; background:rgba(99, 102, 241, 0.15); border-radius:4px; overflow:hidden; }
.load-progress-fill { height:100%; width:0%; background:var(--primary); transition:width 0.4s ease; border-radius:4px; }
/* Hide elements */
.hud-dashboard, .st-pill, .brightness-meter, .liveness-meter { display: none !important; }
/* HUD */
.hud-dashboard { position:absolute; bottom:20px; left:0; right:0; z-index:20; display:flex; flex-direction:column; align-items:center; pointer-events:none; }
.match-card {
background:rgba(255, 255, 255, 0.95); border:1px solid rgba(255,255,255,0.5);
padding:12px 16px; border-radius:24px;
width: 85%; max-width: 300px; position:relative; pointer-events:auto; text-align:center;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
display:flex; flex-direction:column; align-items:center; gap:6px;
}
.match-card img { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; border: 2px solid var(--primary-light); }
.match-card h4 { font-size: 13px; font-weight: 800; color:var(--text-main); margin-bottom: 0; }
.match-card p { font-size: 10px; color:var(--text-muted); font-weight: 500; margin-bottom: 0; }
.distance-bar { width:100%; height:4px; background:#e2e8f0; border-radius:4px; overflow:hidden; margin-top:5px; }
.distance-fill { height:100%; width:0%; background:var(--primary); transition:width 0.1s linear, background-color 0.3s; }
/* SUCCESS PIN REVEAL PAGE - ENHANCED */
.page-success { align-items:center; justify-content:center; padding:40px 30px; text-align:center; color:var(--text-main); background: var(--bg-main); z-index:90; position:absolute; inset:0; }
.suc-card {
background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.6); border-radius: 32px; padding: 40px 30px;
box-shadow: none; display: flex; flex-direction: column; align-items: center;
max-width: 90%; animation: slideUpFade 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes slideUpFade { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } }
.suc-ring-wrap {
width: 100px; height: 100px; border-radius: 50%; background: var(--primary-light);
display: flex; align-items: center; justify-content: center; margin-bottom: 24px;
position: relative;
}
.suc-ring-wrap::before {
content: ''; position: absolute; inset: -15px; border-radius: 50%;
border: 2px solid var(--primary-glow); animation: pulseSurround 2s infinite;
}
.suc-ring {
width: 70px; height: 70px; border-radius: 50%; background: var(--success);
display:flex; align-items:center; justify-content:center;
z-index: 2;
}
.suc-ring i { font-size: 38px; color: #fff; }
@keyframes pulseSurround { 0% { transform: scale(0.8); opacity: 1; } 100% { transform: scale(1.3); opacity: 0; } }
.page-success h2 { font-size: 26px; font-weight: 800; color: var(--success); letter-spacing: -0.5px; margin-bottom: 12px; }
.page-success p { font-size: 15px; color: var(--text-muted); line-height: 1.6; max-width: 280px; margin-bottom: 30px; }
.pin-box-wrapper { position: relative; display: inline-block; }
.pin-box {
background: #fff; border: 2px dashed rgba(99, 102, 241, 0.4); padding: 20px 40px; border-radius: 20px;
font-size: 42px; font-weight: 900; letter-spacing: 16px; color: var(--text-main); font-family: 'Courier New', monospace;
text-align: center; position: relative; z-index: 2; display: flex; align-items: center; justify-content: center;
}
.pin-box-wrapper::after {
content:''; position: absolute; bottom: -8px; left: 10px; right: 10px; height: 20px;
background: rgba(99, 102, 241, 0.15); border-radius: 50%; z-index: 1;
}
/* ── APP-LIKE METERS ── */
.brightness-meter, .liveness-meter {
position:absolute; top:50%; transform:translateY(-50%);
display:flex; flex-direction:column; align-items:center; gap:12px; z-index:12;
padding: 14px 10px; background: none; border-radius: 0; backdrop-filter: none;
}
.brightness-meter { right:6px; }
.liveness-meter { left:6px; }
.bm-icon, .lm-icon { font-size:16px; color:#fff; }
.bm-bar, .lm-bar {
width:6px; height:100px; background:rgba(255,255,255,0.2); position:relative; overflow:hidden;
border-radius:6px;
}
.bm-fill, .lm-fill {
position:absolute; bottom:0; left:0; width:100%; border-radius:6px;
background: #fff; transition:height 0.4s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.4s ease;
}
.bm-label, .lm-label { display:none; }
.success-footer { margin-top: 30px; font-size: 13px; color: var(--text-muted); font-weight: 500; display: flex; align-items: center; gap: 6px; }
.success-footer i { color: var(--success); font-size: 16px; }
/* Session Timer Pill (dark theme for camera UI) */
.session-timer-pill {
position: fixed; top: 12px; right: 12px; z-index: 10000;
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 30px;
background: rgba(0,0,0,0.6); border: 1px solid rgba(255,255,255,0.2);
font-size: 12px; font-weight: 700; color: rgba(255,255,255,0.9);
backdrop-filter: blur(8px); box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transition: all 0.3s ease;
}
.session-timer-pill i { font-size: 16px; color: #f59e0b; }
.session-timer-pill.urgent { background: rgba(220,38,38,0.8); border-color: rgba(239,68,68,0.5); color: #fff; }
.session-timer-pill.urgent i { color: #fff; }
/* Session Expired Overlay */
.session-expired-overlay {
display: none; position: fixed; inset: 0; z-index: 99999;
background: var(--bg-main);
flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 40px 30px;
animation: fadeInOverlay 0.4s ease;
}
@keyframes fadeInOverlay { from { opacity: 0; } to { opacity: 1; } }
.expired-icon {
width: 80px; height: 80px; border-radius: 50%;
background: #fef2f2; border: 1px solid #fecaca;
display: flex; align-items: center; justify-content: center;
margin-bottom: 20px;
}
.expired-icon i { font-size: 40px; color: #ef4444; }
.session-expired-overlay h3 { font-size: 22px; font-weight: 800; color: var(--text-main); margin-bottom: 8px; }
.session-expired-overlay p { font-size: 14px; color: var(--text-muted); line-height: 1.6; max-width: 300px; }
</style>
</head>
<body>
<div class="phone">
<?php if (!$valid): ?>
<?php
header("Location: 404.php");
exit;
?>
<?php else: ?>
<img id="refImg" src="<?= $fotoRef ?>" style="display:none;">
<!-- PAGE: Camera -->
<div class="page" id="pgCamera">
<div class="header">
<a href="new_occupant.php" style="color: white; text-decoration: none;">
<i class="mdi mdi-chevron-left" style="font-size: 28px; text-shadow: 0 1px 3px rgba(0,0,0,0.5);"></i>
</a>
<div style="color: white; font-weight:700; font-size: 14px; text-shadow: 0 1px 3px rgba(0,0,0,0.5);">Auto Verification</div>
<div style="width:28px;"></div>
</div>
<!-- Text Looking removed to avoid clutter, stPill will handle it -->
<!-- Camera -->
<div class="cam-area" id="camArea">
<video id="vid" autoplay playsinline muted></video>
<canvas class="overlay-cv" id="ovCanvas"></canvas>
<div class="face-oval" id="oval">
<svg viewBox="0 0 400 800" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">
<defs>
<mask id="silhouetteMask">
<rect width="100%" height="100%" fill="white"/>
<path d="M 200 200 C 110 200 70 270 70 360 C 40 350 30 460 70 450 C 90 550 140 580 200 580 C 260 580 310 550 330 450 C 370 460 360 350 330 360 C 330 270 290 200 200 200 Z" fill="black" />
</mask>
</defs>
<rect id="bgRect" width="100%" height="100%" mask="url(#silhouetteMask)"/>
<path d="M 200 200 C 110 200 70 270 70 360 C 40 350 30 460 70 450 C 90 550 140 580 200 580 C 260 580 310 550 330 450 C 370 460 360 350 330 360 C 330 270 290 200 200 200 Z" fill="none" class="ringOut" id="ovalRingOut"/>
<path d="M 200 200 C 110 200 70 270 70 360 C 40 350 30 460 70 450 C 90 550 140 580 200 580 C 260 580 310 550 330 450 C 370 460 360 350 330 360 C 330 270 290 200 200 200 Z" fill="none" class="ringIn" id="ovalRing"/>
</svg>
</div>
<div class="corners" id="corners" style="display:none;"><div class="c"></div></div>
<div class="scanbar" id="scanbar" style="display:none;"></div>
<!-- Brightness Meter -->
<div class="brightness-meter" id="brightMeter" style="display:none;">
<i class="mdi mdi-white-balance-sunny bm-icon" id="bmIcon"></i>
<div class="bm-bar"><div class="bm-fill" id="bmFill" style="height:50%;background:#f59e0b;"></div></div>
<span class="bm-label">Light</span>
</div>
<!-- Liveness Meter -->
<div class="liveness-meter" id="liveMeter" style="display:none;">
<i class="mdi mdi-shield-check lm-icon" id="lmIcon"></i>
<div class="lm-bar"><div class="lm-fill" id="lmFill" style="height:0%;background:#f59e0b;"></div></div>
<span class="lm-label">Live</span>
</div>
<div class="cam-loading" id="camLoading">
<div class="ldring"></div>
<p id="loadText" style="text-shadow: none;">Loading AI Model & Camera...</p>
<div class="load-progress"><div class="load-progress-fill" id="loadProgress"></div></div>
</div>
<!-- Status Pill -->
<div class="st-pill searching" id="stPill" style="display:none;">
<i class="mdi mdi-face-recognition" id="stIcon"></i>
<span id="stText">Searching for face...</span>
</div>
</div>
<div class="hud-dashboard">
<div class="match-card">
<img src="<?= $fotoRef ?>" alt="Reference">
<div>
<h4><?= $occupantName ?></h4>
<p>Matching face identity...</p>
</div>
<!-- Mini distance bar -->
<div class="distance-bar">
<div class="distance-fill" id="distFill" style="background:#f59e0b;"></div>
</div>
</div>
</div>
</div>
<!-- PAGE: Success -->
<div class="page page-success page-hidden" id="pgSuccess">
<div class="suc-card">
<div class="suc-ring-wrap">
<div class="suc-ring"><i class="mdi mdi-shield-check"></i></div>
</div>
<h2>Access Granted</h2>
<p>Your face matches the occupant data for <strong><?= $occupantName ?></strong>. Here is your secret PIN:</p>
<div class="pin-box-wrapper">
<div class="pin-box"><?= $pin ?></div>
</div>
<div class="success-footer">
<i class="mdi mdi-check-circle"></i> Session verified
</div>
<p class="text-muted mt-4 mb-4" style="font-size: 13px;">This page will automatically close in <span id="countdownText" class="font-weight-bold text-primary">5</span> seconds to keep your session secure.</p>
<button type="button" onclick="if(window.Telegram && window.Telegram.WebApp) window.Telegram.WebApp.close(); window.close()" class="btn btn-outline-primary fw-bold" style="border-radius: 8px; width: 100%; border: 1px solid var(--primary); color: var(--primary); padding: 10px; background: transparent;">Close Now</button>
</div>
</div>
<?php endif; ?>
</div>
<!-- Session Timer Pill -->
<div class="session-timer-pill" id="sessionTimerPill" <?php if (!$valid) echo 'style="display:none;"'; ?>>
<i class="mdi mdi-timer-outline"></i>
<span id="sessionTimerText">5:00</span>
</div>
<!-- Session Expired Overlay -->
<div class="session-expired-overlay" id="sessionExpiredOverlay">
<div class="expired-icon"><i class="mdi mdi-timer-off-outline"></i></div>
<h3>Session Expired</h3>
<p>Your 5-minute access window has ended. Please use the <b>/forgot_pin</b> command in Telegram to request a new session.</p>
</div>
<?php if ($valid): ?>
<script src="js/face-api/face-api.min.js"></script>
<script>
// Session expiry timestamp from server
var SESSION_EXPIRES_AT = <?= (int)$tokenData['expires_at'] ?>;
(async function() {
var MODEL_URLS = 'js/face-api/models';
var pgCamera = document.getElementById('pgCamera');
var pgSuccess = document.getElementById('pgSuccess');
var camLoading = document.getElementById('camLoading');
var loadProgress = document.getElementById('loadProgress');
var loadText = document.getElementById('loadText');
var stPill = document.getElementById('stPill');
var stIcon = document.getElementById('stIcon');
var stText = document.getElementById('stText');
var vid = document.getElementById('vid');
var ovCanvas = document.getElementById('ovCanvas');
var refImg = document.getElementById('refImg');
var oval = document.getElementById('oval');
var distFill = document.getElementById('distFill');
var brightMeter = document.getElementById('brightMeter');
var bmFill = document.getElementById('bmFill');
var bmIcon = document.getElementById('bmIcon');
var liveMeter = document.getElementById('liveMeter');
var lmFill = document.getElementById('lmFill');
var lmIcon = document.getElementById('lmIcon');
var refDescriptor = null;
var stream = null;
var detectionLoopInterval = null;
var brightInterval = null;
var livenessScore = 0;
var livenessFrames = 0;
var prevLandmarks = null;
function setProgress(pct) { loadProgress.style.width = pct + '%'; }
function setStatus(type, txt) {
stPill.className = 'st-pill';
if (type === 'search') { stPill.classList.add('searching'); stIcon.className = 'mdi mdi-face-recognition'; }
if (type === 'ok') { stPill.classList.add('found'); stIcon.className = 'mdi mdi-check-circle'; }
if (type === 'err') { stPill.classList.add('warn'); stIcon.className = 'mdi mdi-alert-circle'; }
stText.textContent = txt;
}
try {
setProgress(20);
loadText.textContent = 'Loading AI models...';
// Add timeout to prevent infinite hang if models fail to load
const loadModelsPromise = Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URLS),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URLS),
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URLS)
]);
await Promise.race([
loadModelsPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error("Model loading timed out after 15 seconds.")), 15000))
]);
setProgress(50);
loadText.textContent = 'Computing reference face features...';
// Wait for image to load with timeout
if (!refImg.complete || refImg.naturalWidth === 0) {
await new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error("Reference photo loading timed out.")), 10000);
refImg.onload = () => { clearTimeout(timeoutId); resolve(); };
refImg.onerror = () => { clearTimeout(timeoutId); reject(new Error("Failed to load reference photo from database.")); };
if (refImg.complete && refImg.naturalWidth === 0) {
clearTimeout(timeoutId);
reject(new Error("Reference photo is corrupted or in an invalid format."));
}
});
}
// We relax the score threshold for the reference image in case of lighting/angle issues
const refDetection = await faceapi.detectSingleFace(refImg, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.1 })).withFaceLandmarks().withFaceDescriptor();
if (!refDetection) {
alert('Unable to detect a face in the reference photo. Please update the occupant photo.');
window.location.href = 'new_occupant.php';
return;
}
refDescriptor = refDetection.descriptor;
setProgress(70);
loadText.textContent = 'Starting camera...';
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Camera cannot be accessed. The browser may require HTTPS.");
}
// Force Standard Definition (SD) settings for camera to improve performance
var videoConstraints = {
facingMode: 'user',
width: { ideal: 480 }
};
stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
vid.srcObject = stream;
vid.onloadedmetadata = () => {
vid.play();
setProgress(100);
setTimeout(() => {
camLoading.style.display = 'none';
stPill.style.display = 'flex';
brightMeter.style.display = 'flex';
liveMeter.style.display = 'flex';
setStatus('search', 'Position your face...');
startMatching();
startBrightnessCheck();
}, 500);
};
} catch (e) {
let msg = e.message;
if (e.name === 'NotAllowedError' || e.message.includes('Permission denied')) {
msg = "Camera access denied by your browser.\n\nPlease allow camera access (click the lock/camera icon to the left of the URL/address bar, select 'Allow' for Camera), then reload this page.";
} else if (e.name === 'NotFoundError' || e.message.includes('Requested device not found')) {
msg = "Camera not found. Make sure your device has a properly connected camera.";
} else if (e.name === 'NotReadableError' || e.message.includes('device in use')) {
msg = "Camera is being used by another application. Close that application and try again.";
}
alert("Failed to load system: \n\n" + msg);
}
function startMatching() {
let isDetecting = false;
let faceState = 'idle';
let scanStartTime = 0;
detectionLoopInterval = setInterval(async () => {
if (vid.paused || vid.ended || !vid.videoWidth || isDetecting) return;
isDetecting = true;
ovCanvas.width = vid.videoWidth;
ovCanvas.height = vid.videoHeight;
try {
// Lower threshold to detect faces even if slightly cropped/close up
const results = await faceapi.detectAllFaces(vid, new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.15 })).withFaceLandmarks().withFaceDescriptors();
if (results.length === 1) {
const res = results[0];
const landmarks = res.landmarks;
const distance = faceapi.euclideanDistance(refDescriptor, res.descriptor);
if (faceState === 'idle') {
faceState = 'scanning';
scanStartTime = Date.now();
oval.className = 'face-oval detecting';
livenessFrames = 0; livenessScore = 0;
if (landmarks) prevLandmarks = landmarks.positions.map(p => ({x: p.x, y: p.y}));
} else if (faceState === 'scanning') {
if (landmarks) {
const pts = landmarks.positions;
if (prevLandmarks && pts.length === prevLandmarks.length) {
let totalDiff = 0;
for (let i = 0; i < pts.length; i++) {
totalDiff += Math.abs(pts[i].x - prevLandmarks[i].x) + Math.abs(pts[i].y - prevLandmarks[i].y);
}
const avgDiff = totalDiff / pts.length;
if (avgDiff > 0.05 && avgDiff < 15) {
livenessFrames = Math.min(livenessFrames + 1, 20);
} else if (avgDiff >= 15) {
livenessFrames = Math.max(0, livenessFrames - 2);
} else {
livenessFrames = Math.min(livenessFrames + 0.5, 20);
}
}
prevLandmarks = pts.map(p => ({x: p.x, y: p.y}));
livenessScore = Math.min(100, (livenessFrames / 12) * 100);
}
if (Date.now() - scanStartTime > 1500) {
faceState = 'showing_result';
if (distance < 0.58 && livenessScore >= 15) {
oval.className = 'face-oval success';
clearInterval(detectionLoopInterval);
clearInterval(brightInterval);
setTimeout(() => {
if (stream) stream.getTracks().forEach(t => t.stop());
pgCamera.classList.add('page-hidden');
pgSuccess.classList.remove('page-hidden');
let fd = new FormData();
fd.append('action', 'success');
fd.append('token', '<?= htmlspecialchars($tokenParam) ?>');
fetch('', { method: 'POST', body: fd });
let count = 5;
const cText = document.getElementById('countdownText');
if(cText) cText.innerText = count;
const cInterval = setInterval(() => {
count--;
if(cText) cText.innerText = count;
if(count <= 0) {
clearInterval(cInterval);
if(window.Telegram && window.Telegram.WebApp) window.Telegram.WebApp.close();
window.close();
}
}, 1000);
}, 1500); // Wait 1.5s to show the green ring
} else {
oval.className = 'face-oval error';
setTimeout(() => {
if (faceState === 'showing_result') {
faceState = 'idle';
oval.className = 'face-oval';
livenessFrames = 0;
livenessScore = 0;
}
}, 1500);
}
}
}
} else {
if (faceState !== 'showing_result') {
faceState = 'idle';
oval.className = 'face-oval';
livenessFrames = 0; livenessScore = 0;
}
}
} catch (e) {
console.error("Detection error:", e);
} finally {
isDetecting = false;
}
}, 400);
}
function startBrightnessCheck() {
const tmpCanvas = document.createElement('canvas');
const tmpCtx = tmpCanvas.getContext('2d');
brightInterval = setInterval(() => {
if (vid.paused || vid.ended || !vid.videoWidth) return;
tmpCanvas.width = 80;
tmpCanvas.height = 60;
tmpCtx.drawImage(vid, 0, 0, 80, 60);
const data = tmpCtx.getImageData(0, 0, 80, 60).data;
let sum = 0;
const count = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i+1];
const b = data[i+2];
sum += (r * 0.299 + g * 0.587 + b * 0.114);
}
const pct = Math.min(100, Math.max(5, (sum / count / 255) * 100));
bmFill.style.height = pct + '%';
if (pct < 30) {
bmFill.style.background = '#ef4444'; // Red
bmIcon.style.color = '#ef4444';
} else if (pct < 55) {
bmFill.style.background = '#f59e0b'; // Warning
bmIcon.style.color = '#f59e0b';
} else {
bmFill.style.background = '#22c55e'; // Green
bmIcon.style.color = '#22c55e';
}
}, 800);
}
})();
// ─── SESSION EXPIRY TIMER ───
(function() {
var timerPill = document.getElementById('sessionTimerPill');
var timerText = document.getElementById('sessionTimerText');
var expiredOverlay = document.getElementById('sessionExpiredOverlay');
var sessionCompleted = false;
function updateSessionTimer() {
if (sessionCompleted) return;
var now = Math.floor(Date.now() / 1000);
var remaining = SESSION_EXPIRES_AT - now;
if (remaining <= 0) {
// Session expired - close directly
if (window.Telegram && window.Telegram.WebApp) {
window.Telegram.WebApp.close();
}
window.close();
return;
}
var mins = Math.floor(remaining / 60);
var secs = remaining % 60;
timerText.textContent = mins + ':' + (secs < 10 ? '0' : '') + secs;
if (remaining <= 60) {
timerPill.classList.add('urgent');
} else {
timerPill.classList.remove('urgent');
}
setTimeout(updateSessionTimer, 1000);
}
// Stop timer if user completes verification successfully
var pgSuccess = document.getElementById('pgSuccess');
if (pgSuccess) {
var observer = new MutationObserver(function() {
if (!pgSuccess.classList.contains('page-hidden')) {
sessionCompleted = true;
timerPill.style.display = 'none';
}
});
observer.observe(pgSuccess, { attributes: true, attributeFilter: ['class'] });
}
updateSessionTimer();
})();
</script>
<?php endif; ?>
</body>
</html>