472 lines
19 KiB
PHP
472 lines
19 KiB
PHP
<?php
|
|
// Validate token strictly before rendering anything.
|
|
$token = isset($_GET['token']) ? trim($_GET['token']) : '';
|
|
|
|
function renderError($message) {
|
|
header("Location: 404.php");
|
|
exit;
|
|
}
|
|
|
|
if (empty($token)) {
|
|
renderError("The link you are using missing an access token.");
|
|
}
|
|
|
|
$tokensFile = __DIR__ . '/door_tokens.json';
|
|
if (!file_exists($tokensFile)) {
|
|
renderError("The validation system is currently unavailable.");
|
|
}
|
|
$tokens = json_decode(file_get_contents($tokensFile), true) ?: [];
|
|
|
|
if (!isset($tokens[$token])) {
|
|
renderError("Access token not found or invalid.");
|
|
}
|
|
if ($tokens[$token]['used']) {
|
|
renderError("This link has already been used. Please request a new link from the Admin.");
|
|
}
|
|
if (time() - $tokens[$token]['created_at'] > 300) { // 5 minutes expiration
|
|
renderError("This access link has expired (more than 5 minutes).");
|
|
}
|
|
|
|
// Mark as used exclusively for this session
|
|
$tokens[$token]['used'] = true;
|
|
file_put_contents($tokensFile, json_encode($tokens, JSON_PRETTY_PRINT));
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no">
|
|
<title>Face Access — Identia</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="vendors/mdi/css/materialdesignicons.min.css">
|
|
<style>
|
|
:root {
|
|
--primary: #4747a1;
|
|
--primary-glow: rgba(71,71,161,0.3);
|
|
--success: #10b981;
|
|
--bg: #f8fafc;
|
|
--card: #ffffff;
|
|
--text: #0f172a;
|
|
--muted: #64748b;
|
|
--border: #e2e8f0;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body {
|
|
font-family: 'Inter', sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
padding: 20px 16px 40px;
|
|
}
|
|
|
|
.card {
|
|
background: var(--card);
|
|
border-radius: 20px;
|
|
width: 100%; max-width: 460px;
|
|
padding: 32px 28px 36px;
|
|
border: 1px solid var(--border);
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
|
position: relative;
|
|
}
|
|
|
|
/* ── HEADER ── */
|
|
.card-header {
|
|
display: flex; align-items: center; gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.header-icon {
|
|
width: 52px; height: 52px; border-radius: 16px;
|
|
background: #e6e6f2; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
}
|
|
.header-icon i { font-size: 26px; color: var(--primary); }
|
|
.header-text h1 { font-size: 20px; font-weight: 800; color: var(--text); letter-spacing: -0.3px; }
|
|
.header-text p { font-size: 12px; color: var(--muted); margin-top: 3px; font-weight: 500; }
|
|
|
|
/* ── TIMER STATUS ── */
|
|
.badge-timer {
|
|
position: absolute; top: 20px; right: 28px;
|
|
background: #fef2f2; border: 1px solid #fecaca;
|
|
color: #ef4444; font-size: 12px; font-weight: 700;
|
|
padding: 6px 12px; border-radius: 20px;
|
|
display: flex; align-items: center; gap: 6px;
|
|
}
|
|
|
|
/* ── CAMERA WRAPPER ── */
|
|
.cam-wrapper {
|
|
width: 100%; position: relative;
|
|
border-radius: 16px; overflow: hidden;
|
|
background: #1a1e20; aspect-ratio: 3/4; cursor: default;
|
|
border: 1px solid var(--border); transition: all 0.4s ease;
|
|
}
|
|
#cam-video { width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); display: block; }
|
|
|
|
#scan-line {
|
|
position: absolute; top: 0; left: 0;
|
|
width: 100%; height: 3px; background: #ffffff;
|
|
box-shadow: 0 0 10px #ffffff, 0 0 20px rgba(255,255,255,0.5);
|
|
z-index: 4; animation: scanMove 2.8s ease-in-out infinite;
|
|
}
|
|
@keyframes scanMove { 0% { top: 0%; } 50% { top: calc(100% - 3px); } 100% { top: 0%; } }
|
|
|
|
#cam-loading {
|
|
position: absolute; inset: 0; background: #ffffff;
|
|
display: flex; flex-direction: column; align-items: center;
|
|
justify-content: center; gap: 16px; z-index: 10;
|
|
}
|
|
.ld-ring {
|
|
width: 44px; height: 44px; border-radius: 50%;
|
|
border: 3px solid #e6e6f2; border-top-color: var(--primary);
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
#cam-loading p { font-size: 13px; color: var(--muted); font-weight: 600; text-align: center; padding: 0 20px;}
|
|
|
|
/* Live status pill */
|
|
#face-pill {
|
|
position: absolute; top: 16px; left: 50%; transform: translateX(-50%);
|
|
display: none; align-items: center; gap: 8px;
|
|
padding: 8px 18px; border-radius: 24px;
|
|
font-size: 12px; font-weight: 700; white-space: nowrap; z-index: 5;
|
|
background: rgba(255,255,255,0.9); color: var(--text); backdrop-filter: blur(8px);
|
|
}
|
|
|
|
/* ── SUCCESS VIEW (Replaces Camera) ── */
|
|
.success-view {
|
|
display: none; flex-direction: column; align-items: center;
|
|
justify-content: center; text-align: center; gap: 16px;
|
|
padding: 40px 20px; background: #f0fdf4; border-radius: 16px;
|
|
border: 1px solid #bbf7d0; margin-bottom: 20px; animation: popIn 0.5s ease;
|
|
}
|
|
@keyframes popIn { 0%{opacity:0; transform:scale(0.9);} 100%{opacity:1; transform:scale(1);} }
|
|
.success-icon {
|
|
width: 80px; height: 80px; border-radius: 50%; background: #dcfce7;
|
|
display: flex; align-items: center; justify-content: center; color: #16a34a; font-size: 40px;
|
|
}
|
|
.success-view h2 { font-size: 22px; font-weight: 800; color: #166534; margin:0; }
|
|
.success-view p { font-size: 14px; color: #15803d; margin:0; font-weight: 500;}
|
|
|
|
/* ── UNLOCK BUTTON ── */
|
|
.btn-unlock {
|
|
display: none; width: 100%; padding: 18px;
|
|
background: var(--primary); color: #fff;
|
|
border: none; border-radius: 16px; font-size: 16px; font-weight: 800;
|
|
cursor: pointer; display: none; align-items: center; justify-content: center; gap: 10px;
|
|
transition: all 0.3s;
|
|
box-shadow: 0 10px 20px rgba(71, 71, 161, 0.4);
|
|
margin-top: 10px; letter-spacing: 0.5px;
|
|
}
|
|
.btn-unlock:hover { transform: translateY(-3px); box-shadow: 0 15px 30px rgba(71, 71, 161, 0.5); }
|
|
.btn-unlock:active { transform: translateY(0); }
|
|
.btn-unlock.opened { background: var(--success); box-shadow: 0 10px 20px rgba(16, 185, 129, 0.4); pointer-events: none;}
|
|
|
|
.toast {
|
|
position: fixed; top: 20px; right: 20px; background: #fff; border-left: 4px solid var(--success);
|
|
padding: 14px 20px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
|
display: none; z-index: 10000; font-weight: 600; font-size: 14px;
|
|
}
|
|
|
|
.overlay-mask {
|
|
display: none; position: fixed; inset: 0; background: rgba(255,255,255,0.9);
|
|
z-index: 9999; flex-direction: column; align-items: center; justify-content: center; text-align: center;
|
|
}
|
|
.overlay-mask h1 { color: #0f172a; font-size: 24px; font-weight: 800; }
|
|
.overlay-mask p { color: #64748b; font-size: 16px; }
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="card">
|
|
<div class="badge-timer" id="timerBadge"><i class="mdi mdi-timer-sand"></i> <span id="timeTxt">05:00</span></div>
|
|
|
|
<div class="card-header">
|
|
<div class="header-icon"><i class="mdi mdi-face-recognition"></i></div>
|
|
<div class="header-text">
|
|
<h1>Face Verification</h1>
|
|
<p>Scan your face to unlock the door</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Camera View Focus -->
|
|
<div class="cam-wrapper" id="camWrapper">
|
|
<div id="cam-loading">
|
|
<div class="ld-ring"></div>
|
|
<p id="camLoadTxt">Connecting to API and loading AI face models...</p>
|
|
</div>
|
|
<div id="scan-line"></div>
|
|
<video id="cam-video" autoplay playsinline muted></video>
|
|
<div id="face-pill"><i class="mdi mdi-magnify" id="fpIcon"></i> <span id="fpTxt">Searching for Face...</span></div>
|
|
</div>
|
|
|
|
<!-- Success Output -->
|
|
<div class="success-view" id="successView">
|
|
<div class="success-icon"><i class="mdi mdi-check-decagram"></i></div>
|
|
<h2>Access Granted!</h2>
|
|
<p>Hello, <strong id="ocName">John Doe</strong>.</p>
|
|
</div>
|
|
|
|
<!-- The Door Mechanism -->
|
|
<button class="btn-unlock" id="btnUnlock">
|
|
<i class="mdi mdi-door-closed" id="doorIcon"></i> <span id="btnTxt">Open the door</span>
|
|
</button>
|
|
|
|
<button class="btn-unlock" id="btnLock" style="display:none; background:#ef4444; box-shadow:0 10px 20px rgba(239,68,68,0.3); margin-top:12px;">
|
|
<i class="mdi mdi-lock" id="lockIcon"></i> <span id="lockTxt">Lock the door</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toast" id="toastMsg">Status updated!</div>
|
|
|
|
<div class="overlay-mask" id="expireMask">
|
|
<i class="mdi mdi-timer-off-outline" style="font-size: 60px; color:#f59e0b; margin-bottom:10px;"></i>
|
|
<h1>Session Expired</h1>
|
|
<p>Your session has expired. This page will close automatically.</p>
|
|
</div>
|
|
|
|
<!-- Mengambil faceapi dari vendor as referenced from registrasi_foto.php -->
|
|
<script src="js/face-api/face-api.min.js"></script>
|
|
|
|
<script>
|
|
(function() {
|
|
// UI Elements
|
|
const camWrapper = document.getElementById('camWrapper');
|
|
const camLoadTxt = document.getElementById('camLoadTxt');
|
|
const camLoading = document.getElementById('cam-loading');
|
|
const video = document.getElementById('cam-video');
|
|
const facePill = document.getElementById('face-pill');
|
|
const fpIcon = document.getElementById('fpIcon');
|
|
const fpTxt = document.getElementById('fpTxt');
|
|
const btnUnlock = document.getElementById('btnUnlock');
|
|
const successView = document.getElementById('successView');
|
|
const ocName = document.getElementById('ocName');
|
|
|
|
let faceMatcher = null;
|
|
let recognitionLoop = null;
|
|
let stream = null;
|
|
|
|
// Page expires at max 5 mins tightly
|
|
let secondsRemaining = 300;
|
|
let doorTimer = null;
|
|
|
|
const TOKEN = new URLSearchParams(window.location.search).get('token');
|
|
|
|
// Display timer
|
|
const timeInterval = setInterval(() => {
|
|
secondsRemaining--;
|
|
if(secondsRemaining <= 0) {
|
|
expireSession();
|
|
return;
|
|
}
|
|
const m = Math.floor(secondsRemaining / 60);
|
|
const s = secondsRemaining % 60;
|
|
document.getElementById('timeTxt').textContent = `${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
|
|
}, 1000);
|
|
|
|
function expireSession() {
|
|
clearInterval(timeInterval);
|
|
clearTimeout(doorTimer);
|
|
document.getElementById('expireMask').style.display = 'flex';
|
|
// Cleanup tokens automatically handled by API generating, but we enforce it here locally.
|
|
setTimeout(() => { window.location.href = '192.168.4.1.php'; }, 4000);
|
|
}
|
|
|
|
async function init() {
|
|
try {
|
|
// Load neural nets. We fallback to CDN if faceRecognitionNet isn't local.
|
|
camLoadTxt.textContent = "Loading Face APIs...";
|
|
|
|
await faceapi.nets.ssdMobilenetv1.loadFromUri('https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model');
|
|
await faceapi.nets.faceLandmark68Net.loadFromUri('https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model');
|
|
await faceapi.nets.faceRecognitionNet.loadFromUri('https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model');
|
|
|
|
// Get reference faces
|
|
camLoadTxt.textContent = "Fetcfhing Access Data...";
|
|
const res = await fetch('api_get_reference_faces.php');
|
|
const data = await res.json();
|
|
|
|
if (!data.data || data.data.length === 0) {
|
|
camLoadTxt.textContent = "No registered users to match.";
|
|
return;
|
|
}
|
|
|
|
const labeledDescriptors = [];
|
|
let totalProcessed = 0;
|
|
|
|
for (const occ of data.data) {
|
|
camLoadTxt.textContent = `Processing reference... ${occ.name}`;
|
|
try {
|
|
const img = await faceapi.fetchImage(occ.foto + '?t=' + Date.now());
|
|
const detect = await faceapi.detectSingleFace(img).withFaceLandmarks().withFaceDescriptor();
|
|
if (detect) {
|
|
labeledDescriptors.push(new faceapi.LabeledFaceDescriptors(occ.name, [detect.descriptor]));
|
|
totalProcessed++;
|
|
}
|
|
} catch(e) { console.error("Could not process " + occ.name); }
|
|
}
|
|
|
|
if (totalProcessed === 0) {
|
|
camLoadTxt.textContent = "All photo references failed to load. Please call a technician.";
|
|
return;
|
|
}
|
|
|
|
// Create Matcher (Distance 0.55 makes sure it's accurate)
|
|
faceMatcher = new faceapi.FaceMatcher(labeledDescriptors, 0.55);
|
|
|
|
// Start Webcam
|
|
camLoadTxt.textContent = "Starting Webcam...";
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } });
|
|
video.srcObject = stream;
|
|
|
|
video.onloadedmetadata = () => {
|
|
camLoading.style.display = 'none';
|
|
facePill.style.display = 'flex';
|
|
startMatching();
|
|
};
|
|
|
|
} catch (err) {
|
|
camLoadTxt.textContent = "Error: " + err.message;
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
function startMatching() {
|
|
recognitionLoop = setInterval(async () => {
|
|
if (video.paused) return;
|
|
|
|
const detection = await faceapi.detectSingleFace(video).withFaceLandmarks().withFaceDescriptor();
|
|
|
|
if (detection) {
|
|
const bestMatch = faceMatcher.findBestMatch(detection.descriptor);
|
|
|
|
if (bestMatch.label !== 'unknown') {
|
|
// Berhasil Deteksi!
|
|
fpIcon.className = "mdi mdi-check-circle";
|
|
fpTxt.textContent = "Face Recognized!";
|
|
facePill.style.background = "#10b981";
|
|
facePill.style.color = "#fff";
|
|
|
|
clearInterval(recognitionLoop);
|
|
handleSuccess(bestMatch.label);
|
|
} else {
|
|
fpIcon.className = "mdi mdi-alert";
|
|
fpTxt.textContent = "Face Not Recognized";
|
|
facePill.style.background = "#ef4444";
|
|
facePill.style.color = "#fff";
|
|
}
|
|
} else {
|
|
fpIcon.className = "mdi mdi-magnify";
|
|
fpTxt.textContent = "Searching for Face...";
|
|
facePill.style.background = "rgba(255,255,255,0.9)";
|
|
facePill.style.color = "#0f172a";
|
|
}
|
|
|
|
}, 500);
|
|
}
|
|
|
|
function handleSuccess(name) {
|
|
// Stop Camera Streams
|
|
stream.getTracks().forEach(t => t.stop());
|
|
|
|
// Hide camera, show success logic
|
|
camWrapper.style.display = 'none';
|
|
ocName.textContent = name;
|
|
successView.style.display = 'flex';
|
|
|
|
// Show btn
|
|
btnUnlock.style.display = 'flex';
|
|
}
|
|
|
|
// Door Interactivity
|
|
btnUnlock.addEventListener('click', async () => {
|
|
// Play action unlock
|
|
btnUnlock.querySelector('#btnTxt').textContent = "Opening...";
|
|
btnUnlock.querySelector('#doorIcon').className = "mdi mdi-loading mdi-spin";
|
|
|
|
try {
|
|
// Post Unlock
|
|
let formData = new URLSearchParams();
|
|
formData.append("action", "unlock");
|
|
|
|
const res = await fetch("api_door_status.php", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: formData.toString()
|
|
});
|
|
const dat = await res.json();
|
|
|
|
if(dat.status === 'success') {
|
|
btnUnlock.classList.add('opened');
|
|
btnUnlock.querySelector('#doorIcon').className = "mdi mdi-door-open";
|
|
btnUnlock.querySelector('#btnTxt').textContent = "Door Unlocked!";
|
|
btnUnlock.style.pointerEvents = 'none';
|
|
|
|
// Show the Lock button
|
|
document.getElementById('btnLock').style.display = 'flex';
|
|
|
|
showToast("Door unlocked successfully! Access expires in 5 minutes.");
|
|
|
|
// Clear the earlier page countdown, force a new 5 minute door lock countdown
|
|
clearInterval(timeInterval);
|
|
document.getElementById('timerBadge').innerHTML = "<i class='mdi mdi-lock-clock'></i> Will automatically lock in 5 mins";
|
|
|
|
// Lock back after 5 minutes
|
|
doorTimer = setTimeout(() => {
|
|
lockTheDoor();
|
|
}, 300000); // 300 seconds
|
|
|
|
}
|
|
} catch(e) {
|
|
console.error("Failed handling door API", e);
|
|
}
|
|
});
|
|
|
|
// Lock The Door button
|
|
document.getElementById('btnLock').addEventListener('click', async () => {
|
|
clearTimeout(doorTimer); // Cancel auto-lock timer
|
|
await lockTheDoor();
|
|
});
|
|
|
|
async function lockTheDoor() {
|
|
const lockBtn = document.getElementById('btnLock');
|
|
lockBtn.querySelector('#lockTxt').textContent = 'Locking...';
|
|
lockBtn.querySelector('#lockIcon').className = 'mdi mdi-loading mdi-spin';
|
|
lockBtn.style.pointerEvents = 'none';
|
|
|
|
try {
|
|
// 1. Lock the door
|
|
let formLock = new URLSearchParams();
|
|
formLock.append("action", "lock");
|
|
await fetch("api_door_status.php", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: formLock.toString()
|
|
});
|
|
|
|
// 2. Turn off Bluetooth on ESP32
|
|
await fetch('api_wifi.php?action=trigger_bt_off');
|
|
|
|
showToast('Door locked and Bluetooth disabled.');
|
|
} catch(e) {
|
|
console.error('Lock error:', e);
|
|
}
|
|
|
|
// End session regardless
|
|
expireSession();
|
|
}
|
|
|
|
function showToast(msg) {
|
|
const t = document.getElementById('toastMsg');
|
|
t.textContent = msg;
|
|
t.style.display = 'block';
|
|
setTimeout(() => { t.style.display = 'none'; }, 5000);
|
|
}
|
|
|
|
init();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|