469 lines
22 KiB
PHP
469 lines
22 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']}");
|
|
}
|
|
}
|
|
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];
|
|
$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']);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$valid) {
|
|
header("Location: 404.php");
|
|
exit;
|
|
}
|
|
?>
|
|
<!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:#ffffff; }
|
|
.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%; transition:0.4s ease; }
|
|
#bgRect { fill: #ffffff; }
|
|
|
|
/* Restore the ring */
|
|
#ovalRing { stroke: #e2e8f0; stroke-width: 6; display: block; }
|
|
#ovalRingOut { stroke: none; display: block; }
|
|
|
|
/* Use pure neon colors for maximum screen brightness emission */
|
|
.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:#ffffff;
|
|
display:flex; flex-direction:column; align-items:center; justify-content:center; z-index:25; gap:20px;
|
|
}
|
|
.cam-loading .ldring {
|
|
width:64px; height:64px; border-radius:50%; border:4px solid #f1f5f9; border-top-color:var(--primary);
|
|
animation:spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
|
}
|
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
|
|
/* 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;
|
|
}
|
|
|
|
|
|
.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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="phone">
|
|
|
|
<img id="refImg" src="<?= $fotoRef ?>" style="display:none;">
|
|
|
|
<!-- PAGE: Camera -->
|
|
<div class="page" id="pgCamera">
|
|
|
|
<!-- 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="cam-loading" id="camLoading">
|
|
<div class="ldring"></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">10</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>
|
|
</div>
|
|
|
|
|
|
|
|
<?php if ($valid): ?>
|
|
<script src="js/face-api/face-api.min.js"></script>
|
|
|
|
<script>
|
|
|
|
(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 vid = document.getElementById('vid');
|
|
var ovCanvas = document.getElementById('ovCanvas');
|
|
var refImg = document.getElementById('refImg');
|
|
var oval = document.getElementById('oval');
|
|
|
|
var refDescriptor = null;
|
|
var stream = null;
|
|
var detectionLoopInterval = null;
|
|
|
|
var livenessScore = 0;
|
|
var livenessFrames = 0;
|
|
try {
|
|
await Promise.all([
|
|
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URLS),
|
|
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URLS),
|
|
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URLS)
|
|
]);
|
|
|
|
// Wait for image to load before processing
|
|
if (!refImg.complete || refImg.naturalWidth === 0) {
|
|
await new Promise((resolve, reject) => {
|
|
refImg.onload = resolve;
|
|
refImg.onerror = () => reject(new Error("Failed to load reference photo from database."));
|
|
if (refImg.complete && refImg.naturalWidth === 0) {
|
|
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;
|
|
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
throw new Error("Camera cannot be accessed. The browser may require HTTPS.");
|
|
}
|
|
// Force High Definition (HD) settings for camera
|
|
var videoConstraints = {
|
|
facingMode: 'user',
|
|
width: { ideal: 480 }
|
|
};
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
|
|
vid.srcObject = stream;
|
|
|
|
vid.onloadedmetadata = () => {
|
|
vid.play();
|
|
setTimeout(() => {
|
|
camLoading.style.display = 'none';
|
|
startMatching();
|
|
}, 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 runningCount = 0;
|
|
let isDetecting = false;
|
|
let faceState = 'idle'; // idle, scanning, showing_result
|
|
let scanStartTime = 0;
|
|
|
|
detectionLoopInterval = setInterval(async () => {
|
|
if (vid.paused || vid.ended || !vid.videoWidth || isDetecting) return;
|
|
if (faceState === 'showing_result') return;
|
|
|
|
isDetecting = true;
|
|
|
|
if (runningCount === 0) {
|
|
ovCanvas.width = vid.videoWidth;
|
|
ovCanvas.height = vid.videoHeight;
|
|
runningCount++;
|
|
}
|
|
|
|
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';
|
|
|
|
// Reset liveness when starting a new scan
|
|
livenessFrames = 0;
|
|
livenessScore = 0;
|
|
if (landmarks) prevLandmarks = landmarks.positions.map(p => ({x: p.x, y: p.y}));
|
|
} else if (faceState === 'scanning') {
|
|
// Liveness tracking ONLY active during scanning color phase
|
|
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;
|
|
// Reward micro-movements (natural live face)
|
|
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);
|
|
}
|
|
|
|
// Check results after 1.5 seconds of scanning
|
|
if (Date.now() - scanStartTime > 1500) {
|
|
faceState = 'showing_result';
|
|
|
|
// Distance check and Liveness check
|
|
if (distance < 0.58 && livenessScore >= 15) {
|
|
oval.className = 'face-oval success';
|
|
clearInterval(detectionLoopInterval);
|
|
stream.getTracks().forEach(t => t.stop());
|
|
|
|
setTimeout(() => {
|
|
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 = 10;
|
|
const cText = document.getElementById('countdownText');
|
|
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);
|
|
}, 1000);
|
|
} else {
|
|
oval.className = 'face-oval error';
|
|
setTimeout(() => {
|
|
faceState = 'idle';
|
|
oval.className = 'face-oval';
|
|
livenessFrames = 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);
|
|
}
|
|
|
|
})();
|
|
|
|
</script>
|
|
<?php endif; ?>
|
|
|
|
</body>
|
|
</html>
|
|
|
|
|