428 lines
19 KiB
PHP
428 lines
19 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, maximum-scale=1, user-scalable=0">
|
|
<title>Door Scanner - Identia</title>
|
|
<link rel="stylesheet" href="vendors/mdi/css/materialdesignicons.min.css">
|
|
<link rel="shortcut icon" href="images/myn.png" />
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
background: #0f0f1a; color: #fff;
|
|
min-height: 100vh; overflow: hidden; position: relative;
|
|
}
|
|
.camera-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; }
|
|
.camera-container video { width: 100%; height: 100%; object-fit: cover; }
|
|
.scan-overlay {
|
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
|
z-index: 10; pointer-events: none;
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
}
|
|
.top-bar {
|
|
position: fixed; top: 0; left: 0; right: 0; z-index: 20;
|
|
padding: 16px 20px; display: flex; align-items: center; justify-content: center;
|
|
background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%);
|
|
}
|
|
.top-bar-title {
|
|
font-size: 15px; font-weight: 700; color: #fff;
|
|
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.top-bar-title i { font-size: 20px; color: #a78bfa; }
|
|
.scan-frame { width: 280px; height: 180px; position: relative; border-radius: 20px; }
|
|
.scan-frame .corner { position: absolute; width: 36px; height: 36px; border: 4px solid #a78bfa; }
|
|
.scan-frame .corner.tl { top: 0; left: 0; border-right: none; border-bottom: none; border-radius: 12px 0 0 0; }
|
|
.scan-frame .corner.tr { top: 0; right: 0; border-left: none; border-bottom: none; border-radius: 0 12px 0 0; }
|
|
.scan-frame .corner.bl { bottom: 0; left: 0; border-right: none; border-top: none; border-radius: 0 0 0 12px; }
|
|
.scan-frame .corner.br { bottom: 0; right: 0; border-left: none; border-top: none; border-radius: 0 0 12px 0; }
|
|
.scan-line {
|
|
position: absolute; left: 8px; right: 8px; height: 3px;
|
|
background: linear-gradient(90deg, transparent, #a78bfa, #7c3aed, #a78bfa, transparent);
|
|
border-radius: 2px; animation: scanMove 2s ease-in-out infinite;
|
|
box-shadow: 0 0 15px rgba(167, 139, 250, 0.6);
|
|
}
|
|
@keyframes scanMove { 0%, 100% { top: 8px; } 50% { top: calc(100% - 11px); } }
|
|
.status-pill {
|
|
position: fixed; bottom: 100px; left: 50%; transform: translateX(-50%);
|
|
z-index: 20; display: flex; align-items: center; gap: 10px;
|
|
padding: 12px 24px; border-radius: 50px;
|
|
background: rgba(255,255,255,0.12); backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.15);
|
|
font-size: 13px; font-weight: 600; color: #fff;
|
|
transition: all 0.4s ease; white-space: nowrap;
|
|
}
|
|
.status-pill i { font-size: 18px; }
|
|
.status-pill.scanning i { color: #a78bfa; animation: pulse 1.5s infinite; }
|
|
.status-pill.success { background: rgba(16,185,129,0.3); border-color: rgba(16,185,129,0.4); }
|
|
.status-pill.success i { color: #34d399; animation: none; }
|
|
.status-pill.error { background: rgba(239,68,68,0.3); border-color: rgba(239,68,68,0.4); }
|
|
.status-pill.error i { color: #f87171; animation: none; }
|
|
.status-pill.warning { background: rgba(245,158,11,0.3); border-color: rgba(245,158,11,0.4); }
|
|
.status-pill.warning i { color: #fbbf24; animation: none; }
|
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
.bottom-hint {
|
|
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
|
|
z-index: 20; font-size: 12px; color: rgba(255,255,255,0.5); text-align: center;
|
|
}
|
|
.loading-overlay {
|
|
position: fixed; inset: 0; z-index: 50; background: #0f0f1a;
|
|
display: flex; flex-direction: column; align-items: center;
|
|
justify-content: center; gap: 20px; transition: opacity 0.5s ease;
|
|
}
|
|
.loading-overlay.hidden { opacity: 0; pointer-events: none; }
|
|
.loading-spinner {
|
|
width: 48px; height: 48px; border-radius: 50%;
|
|
border: 3px solid rgba(167,139,250,0.2); border-top-color: #a78bfa;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.loading-text { font-size: 14px; color: rgba(255,255,255,0.6); font-weight: 600; }
|
|
.error-overlay {
|
|
position: fixed; inset: 0; z-index: 50; background: #f8f9fa;
|
|
display: none; flex-direction: column; align-items: center;
|
|
justify-content: center; text-align: center; padding: 40px;
|
|
}
|
|
.error-overlay .err-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;
|
|
}
|
|
.error-overlay .err-icon i { font-size: 40px; color: #ef4444; }
|
|
.error-overlay h3 { font-size: 20px; font-weight: 800; color: #1e293b; margin-bottom: 8px; }
|
|
.error-overlay p { font-size: 14px; color: #64748b; line-height: 1.6; max-width: 300px; }
|
|
#scanCanvas { display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="loading-overlay" id="loadingOverlay">
|
|
<div class="loading-spinner"></div>
|
|
<div class="loading-text">Initializing camera...</div>
|
|
</div>
|
|
<div class="error-overlay" id="errorOverlay">
|
|
<div class="err-icon"><i class="mdi mdi-camera-off"></i></div>
|
|
<h3>Camera Access Failed</h3>
|
|
<p id="errorMsg">Unable to access the camera. Please allow camera permissions.</p>
|
|
</div>
|
|
<div class="top-bar">
|
|
<div class="top-bar-title">
|
|
<i class="mdi mdi-shield-lock-outline"></i>
|
|
Identia Door Scanner
|
|
</div>
|
|
</div>
|
|
<div class="camera-container">
|
|
<video id="cameraVideo" autoplay playsinline muted></video>
|
|
</div>
|
|
<div class="scan-overlay">
|
|
<div class="scan-frame">
|
|
<div class="corner tl"></div>
|
|
<div class="corner tr"></div>
|
|
<div class="corner bl"></div>
|
|
<div class="corner br"></div>
|
|
<div class="scan-line"></div>
|
|
</div>
|
|
</div>
|
|
<div class="status-pill scanning" id="statusPill">
|
|
<i class="mdi mdi-line-scan" id="statusIcon"></i>
|
|
<span id="statusText">Point camera at door code...</span>
|
|
</div>
|
|
<div class="bottom-hint">Only works with original printed Identia Door Code</div>
|
|
<canvas id="scanCanvas"></canvas>
|
|
|
|
<script>
|
|
(function() {
|
|
'use strict';
|
|
|
|
const video = document.getElementById('cameraVideo');
|
|
const canvas = document.getElementById('scanCanvas');
|
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
const loadingOverlay = document.getElementById('loadingOverlay');
|
|
const errorOverlay = document.getElementById('errorOverlay');
|
|
const errorMsg = document.getElementById('errorMsg');
|
|
const statusPill = document.getElementById('statusPill');
|
|
const statusIcon = document.getElementById('statusIcon');
|
|
const statusText = document.getElementById('statusText');
|
|
|
|
let scanning = true;
|
|
let detected = false;
|
|
let consecutiveDetections = 0;
|
|
const REQUIRED_DETECTIONS = 6;
|
|
let lastTokenTime = 0;
|
|
const RATE_LIMIT_MS = 10000; // 10 sec between scans
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// ULTRA-COMPLEX 61-ELEMENT BAR-WIDTH SIGNATURE
|
|
// 31 black bars + 30 white gaps
|
|
// Many very thin bars (1-unit) mixed with thick bars
|
|
// Photographing this code causes thin bars to blur/merge,
|
|
// breaking the precise ratio pattern
|
|
// ═══════════════════════════════════════════════════════════
|
|
const SIGNATURE = [
|
|
3,1,1,1,4,1,1,2,2,1,1,1,3,1,1,1,2,1,1,3,
|
|
5,1,1,1,1,2,3,1,1,1,2,1,1,1,4,1,1,2,1,1,
|
|
3,1,1,1,2,1,1,1,1,2,4,1,1,1,1,1,2,1,1,1,3
|
|
];
|
|
const SIG_LEN = SIGNATURE.length; // 61
|
|
const SIG_TOTAL = SIGNATURE.reduce((a, b) => a + b, 0); // 97
|
|
const SIG_RATIOS = SIGNATURE.map(v => v / SIG_TOTAL);
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// ANTI-PHOTO SECURITY
|
|
// 1. Precision matching: requires very accurate ratios
|
|
// (photo-of-photo degrades thin bars → ratios break)
|
|
// 2. Sharpness check: real printed code has sharp edges,
|
|
// photo-of-code has softer transitions
|
|
// 3. Rate limiting: max 1 scan per 10 seconds
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Scan one horizontal line, return array of run widths
|
|
*/
|
|
function scanLine(data, y, w) {
|
|
const runs = [];
|
|
let inBar = false;
|
|
let runStart = -1;
|
|
|
|
for (let x = 0; x < w; x++) {
|
|
const idx = (y * w + x) * 4;
|
|
const lum = data[idx] * 0.299 + data[idx+1] * 0.587 + data[idx+2] * 0.114;
|
|
const black = lum < 128;
|
|
|
|
if (!inBar && !black) continue; // skip leading white
|
|
if (!inBar && black) { inBar = true; runStart = x; runs.push(0); continue; }
|
|
|
|
if (runs.length === 0) continue;
|
|
|
|
const wasBlack = (runs.length % 2 === 1); // odd count = currently in bar
|
|
if (black === wasBlack) {
|
|
// same color, extend run
|
|
} else {
|
|
// transition
|
|
runs[runs.length - 1] = x - runStart;
|
|
runs.push(0);
|
|
runStart = x;
|
|
}
|
|
}
|
|
|
|
// close last run
|
|
if (runs.length > 0 && runStart >= 0) {
|
|
runs[runs.length - 1] = canvas.width - runStart;
|
|
}
|
|
|
|
// Trim trailing white gaps
|
|
while (runs.length > 0 && runs.length % 2 === 0) {
|
|
runs.pop();
|
|
}
|
|
|
|
return runs;
|
|
}
|
|
|
|
/**
|
|
* Match runs against signature with STRICT precision
|
|
* Returns score 0.0 - 1.0
|
|
*/
|
|
function matchSignature(runs) {
|
|
if (runs.length < SIG_LEN) return 0;
|
|
|
|
let bestScore = 0;
|
|
|
|
for (let off = 0; off <= runs.length - SIG_LEN; off++) {
|
|
const sub = runs.slice(off, off + SIG_LEN);
|
|
const total = sub.reduce((a, b) => a + b, 0);
|
|
if (total < 40) continue; // too small, noise
|
|
|
|
const ratios = sub.map(v => v / total);
|
|
let sumSqDiff = 0;
|
|
|
|
for (let i = 0; i < SIG_LEN; i++) {
|
|
const diff = ratios[i] - SIG_RATIOS[i];
|
|
sumSqDiff += diff * diff;
|
|
}
|
|
|
|
// STRICT threshold: multiply by 80 (stricter than before)
|
|
// Photo-of-photo typically scores < 0.5 due to blur
|
|
const score = Math.max(0, 1 - (sumSqDiff * 80));
|
|
bestScore = Math.max(bestScore, score);
|
|
}
|
|
|
|
return bestScore;
|
|
}
|
|
|
|
/**
|
|
* ANTI-PHOTO: Measure edge sharpness of black-to-white transitions.
|
|
* Real printed codes have sharp edges; photos-of-codes have gradual transitions.
|
|
* Returns sharpness score 0.0 - 1.0
|
|
*/
|
|
function measureSharpness(data, y, w) {
|
|
let sharpTransitions = 0;
|
|
let totalTransitions = 0;
|
|
|
|
for (let x = 1; x < w - 1; x++) {
|
|
const idx = (y * w + x) * 4;
|
|
const idxPrev = (y * w + (x - 1)) * 4;
|
|
const lumCur = data[idx] * 0.299 + data[idx+1] * 0.587 + data[idx+2] * 0.114;
|
|
const lumPrev = data[idxPrev] * 0.299 + data[idxPrev+1] * 0.587 + data[idxPrev+2] * 0.114;
|
|
const diff = Math.abs(lumCur - lumPrev);
|
|
|
|
if (diff > 30) { // significant change
|
|
totalTransitions++;
|
|
if (diff > 100) { // sharp change (>100 lum jump in 1 pixel)
|
|
sharpTransitions++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalTransitions === 0) return 0;
|
|
return sharpTransitions / totalTransitions;
|
|
}
|
|
|
|
/**
|
|
* Main frame analysis
|
|
*/
|
|
function analyzeFrame() {
|
|
if (!scanning || detected) return;
|
|
if (video.readyState < video.HAVE_ENOUGH_DATA) {
|
|
requestAnimationFrame(analyzeFrame);
|
|
return;
|
|
}
|
|
|
|
const vw = video.videoWidth, vh = video.videoHeight;
|
|
if (!vw || !vh) { requestAnimationFrame(analyzeFrame); return; }
|
|
|
|
const scale = 0.5;
|
|
canvas.width = Math.floor(vw * scale);
|
|
canvas.height = Math.floor(vh * scale);
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
|
|
const cw = canvas.width, ch = canvas.height;
|
|
const imgData = ctx.getImageData(0, 0, cw, ch).data;
|
|
|
|
let highestScore = 0;
|
|
let avgSharpness = 0;
|
|
let sharpCount = 0;
|
|
const scanCount = 20;
|
|
const centerY = Math.floor(ch / 2);
|
|
const range = Math.floor(ch * 0.25);
|
|
|
|
for (let i = 0; i < scanCount; i++) {
|
|
const y = centerY - range + Math.floor((2 * range * i) / (scanCount - 1));
|
|
if (y < 0 || y >= ch) continue;
|
|
|
|
const runs = scanLine(imgData, y, cw);
|
|
const score = matchSignature(runs);
|
|
highestScore = Math.max(highestScore, score);
|
|
|
|
// Also measure sharpness on lines with decent scores
|
|
if (score > 0.4) {
|
|
avgSharpness += measureSharpness(imgData, y, cw);
|
|
sharpCount++;
|
|
}
|
|
}
|
|
|
|
// Calculate average sharpness
|
|
if (sharpCount > 0) avgSharpness /= sharpCount;
|
|
|
|
// DETECTION LOGIC
|
|
if (highestScore > 0.70) {
|
|
// Check anti-photo: sharpness must be > 0.35
|
|
// Real printed code ≈ 0.5-0.9, photo-of-code ≈ 0.1-0.3
|
|
if (sharpCount > 0 && avgSharpness < 0.25) {
|
|
// Likely a photo of the code, not the real thing
|
|
setStatus('warning', 'mdi mdi-alert', 'Scan original printed code only');
|
|
consecutiveDetections = 0;
|
|
} else {
|
|
consecutiveDetections++;
|
|
if (consecutiveDetections >= REQUIRED_DETECTIONS) {
|
|
onCodeDetected();
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
consecutiveDetections = Math.max(0, consecutiveDetections - 1);
|
|
}
|
|
|
|
requestAnimationFrame(analyzeFrame);
|
|
}
|
|
|
|
function setStatus(type, icon, text) {
|
|
statusPill.className = 'status-pill ' + type;
|
|
statusIcon.className = icon;
|
|
statusText.textContent = text;
|
|
}
|
|
|
|
/**
|
|
* Code detected → generate token → redirect
|
|
*/
|
|
async function onCodeDetected() {
|
|
// Rate limiting
|
|
const now = Date.now();
|
|
if (now - lastTokenTime < RATE_LIMIT_MS) {
|
|
setStatus('warning', 'mdi mdi-clock-fast', 'Please wait before scanning again...');
|
|
resetScanner();
|
|
return;
|
|
}
|
|
lastTokenTime = now;
|
|
|
|
detected = true;
|
|
scanning = false;
|
|
setStatus('success', 'mdi mdi-check-circle', 'Code detected! Generating access...');
|
|
if (navigator.vibrate) navigator.vibrate(200);
|
|
|
|
try {
|
|
const resp = await fetch('generate_door_token.php', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: 'action=generate'
|
|
});
|
|
const data = await resp.json();
|
|
|
|
if (data.status === 'success' && data.token) {
|
|
setStatus('success', 'mdi mdi-check-circle', 'Redirecting...');
|
|
setTimeout(() => {
|
|
window.location.href = 'openning.php?token=' + encodeURIComponent(data.token);
|
|
}, 600);
|
|
} else {
|
|
setStatus('error', 'mdi mdi-alert-circle', data.message || 'Token generation failed');
|
|
resetScanner();
|
|
}
|
|
} catch (err) {
|
|
setStatus('error', 'mdi mdi-alert-circle', 'Network error');
|
|
resetScanner();
|
|
}
|
|
}
|
|
|
|
function resetScanner() {
|
|
setTimeout(() => {
|
|
detected = false;
|
|
scanning = true;
|
|
consecutiveDetections = 0;
|
|
setStatus('scanning', 'mdi mdi-line-scan', 'Point camera at door code...');
|
|
requestAnimationFrame(analyzeFrame);
|
|
}, 2500);
|
|
}
|
|
|
|
async function initCamera() {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
|
audio: false
|
|
});
|
|
video.srcObject = stream;
|
|
await new Promise(r => { video.onloadedmetadata = () => { video.play().then(r).catch(r); }; setTimeout(r, 5000); });
|
|
setTimeout(() => { loadingOverlay.classList.add('hidden'); requestAnimationFrame(analyzeFrame); }, 500);
|
|
} catch (err) {
|
|
loadingOverlay.style.display = 'none';
|
|
errorOverlay.style.display = 'flex';
|
|
errorMsg.textContent = err.name === 'NotAllowedError' ? 'Camera access denied.' :
|
|
err.name === 'NotFoundError' ? 'No camera found.' : 'Error: ' + err.message;
|
|
}
|
|
}
|
|
|
|
initCamera();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|