734 lines
35 KiB
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>
|
|
|
|
|