TKK_E32230273/verifikasi_wajah.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>