326 lines
12 KiB
PHP
326 lines
12 KiB
PHP
<?php
|
|
// ══════════════════════════════════════════════════════════════
|
|
// picture.php — Fingerprint Image Receiver & Gallery
|
|
// ══════════════════════════════════════════════════════════════
|
|
// Menerima raw image data dari ESP32, konversi ke PNG,
|
|
// dan menampilkan galeri untuk download.
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
$uploadDir = __DIR__ . '/fingerprints/';
|
|
if (!is_dir($uploadDir)) {
|
|
mkdir($uploadDir, 0777, true);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════
|
|
// HANDLE POST — Terima gambar dari ESP32
|
|
// ════════════════════════════════════════════════════════════
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
header('Content-Type: text/plain');
|
|
|
|
$type = $_GET['type'] ?? 'unknown';
|
|
$width = intval($_GET['w'] ?? 256);
|
|
$height = intval($_GET['h'] ?? 288);
|
|
|
|
$rawData = file_get_contents('php://input');
|
|
$rawLen = strlen($rawData);
|
|
|
|
if ($rawLen < 100) {
|
|
http_response_code(400);
|
|
echo "ERROR: Data terlalu kecil ($rawLen bytes)";
|
|
exit;
|
|
}
|
|
|
|
// Buat gambar grayscale dari raw 4-bit packed data
|
|
// Setiap byte = 2 pixel (high nibble = pixel 1, low nibble = pixel 2)
|
|
$img = imagecreatetruecolor($width, $height);
|
|
|
|
// Buat warna grayscale
|
|
$colors = [];
|
|
for ($i = 0; $i < 256; $i++) {
|
|
$colors[$i] = imagecolorallocate($img, $i, $i, $i);
|
|
}
|
|
|
|
$offset = 0;
|
|
for ($y = 0; $y < $height; $y++) {
|
|
for ($x = 0; $x < $width; $x += 2) {
|
|
if ($offset < $rawLen) {
|
|
$byte = ord($rawData[$offset++]);
|
|
$pixel1 = (($byte >> 4) & 0x0F) * 17; // 0-15 → 0-255
|
|
$pixel2 = ($byte & 0x0F) * 17;
|
|
|
|
imagesetpixel($img, $x, $y, $colors[$pixel1]);
|
|
if ($x + 1 < $width) {
|
|
imagesetpixel($img, $x + 1, $y, $colors[$pixel2]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Simpan sebagai PNG
|
|
$timestamp = date('Y-m-d_H-i-s');
|
|
$filename = "finger_{$type}_{$timestamp}.png";
|
|
$filepath = $uploadDir . $filename;
|
|
|
|
imagepng($img, $filepath);
|
|
imagedestroy($img);
|
|
|
|
// Juga simpan raw data untuk analisis
|
|
$rawFilename = "finger_{$type}_{$timestamp}.raw";
|
|
file_put_contents($uploadDir . $rawFilename, $rawData);
|
|
|
|
echo "OK: $filename ($rawLen bytes)";
|
|
error_log("[FINGERPRINT] Saved: $filename | Type: $type | Raw: $rawLen bytes | Size: {$width}x{$height}");
|
|
exit;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════
|
|
// HANDLE GET — Tampilkan galeri gambar
|
|
// ════════════════════════════════════════════════════════════
|
|
|
|
// Handle download
|
|
if (isset($_GET['download'])) {
|
|
$file = basename($_GET['download']); // Prevent path traversal
|
|
$path = $uploadDir . $file;
|
|
if (file_exists($path) && pathinfo($path, PATHINFO_EXTENSION) === 'png') {
|
|
header('Content-Type: image/png');
|
|
header('Content-Disposition: attachment; filename="' . $file . '"');
|
|
header('Content-Length: ' . filesize($path));
|
|
readfile($path);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// Handle delete
|
|
if (isset($_GET['delete'])) {
|
|
$file = basename($_GET['delete']);
|
|
$path = $uploadDir . $file;
|
|
$rawPath = str_replace('.png', '.raw', $path);
|
|
if (file_exists($path)) unlink($path);
|
|
if (file_exists($rawPath)) unlink($rawPath);
|
|
header('Location: picture.php');
|
|
exit;
|
|
}
|
|
|
|
// Handle delete all
|
|
if (isset($_GET['delete_all'])) {
|
|
$files = glob($uploadDir . 'finger_*');
|
|
foreach ($files as $f) {
|
|
unlink($f);
|
|
}
|
|
header('Location: picture.php');
|
|
exit;
|
|
}
|
|
|
|
// Scan directory for images
|
|
$images = glob($uploadDir . 'finger_*.png');
|
|
rsort($images); // Terbaru di atas
|
|
|
|
$totalImages = count($images);
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Fingerprint Image Gallery</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: 'Inter', sans-serif;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
min-height: 100vh;
|
|
}
|
|
.header {
|
|
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
|
border-bottom: 1px solid #1e293b;
|
|
padding: 20px 30px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.header h1 {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
color: #f8fafc;
|
|
}
|
|
.header h1 span { color: #38bdf8; }
|
|
.header .stats {
|
|
font-size: 13px;
|
|
color: #94a3b8;
|
|
}
|
|
.actions {
|
|
padding: 15px 30px;
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
border-bottom: 1px solid #1e293b;
|
|
}
|
|
.btn {
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
border: 1px solid #334155;
|
|
background: #1e293b;
|
|
color: #e2e8f0;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
transition: all 0.2s;
|
|
}
|
|
.btn:hover { background: #334155; }
|
|
.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
|
|
.btn-danger:hover { background: #7f1d1d; }
|
|
.filter-tag {
|
|
padding: 5px 12px;
|
|
border-radius: 20px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
border: 1px solid #334155;
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
transition: all 0.2s;
|
|
}
|
|
.filter-tag.active, .filter-tag:hover { background: #334155; color: #f8fafc; }
|
|
.filter-tag.bersih.active { background: #064e3b; color: #6ee7b7; border-color: #065f46; }
|
|
.filter-tag.basah.active { background: #0c4a6e; color: #7dd3fc; border-color: #075985; }
|
|
.filter-tag.kotor.active { background: #7c2d12; color: #fdba74; border-color: #9a3412; }
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 20px;
|
|
padding: 25px 30px;
|
|
}
|
|
.card {
|
|
background: #1e293b;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
border: 1px solid #334155;
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
.card:hover {
|
|
transform: translateY(-3px);
|
|
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
|
}
|
|
.card img {
|
|
width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
background: #000;
|
|
image-rendering: pixelated;
|
|
}
|
|
.card-info {
|
|
padding: 12px 15px;
|
|
}
|
|
.card-type {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.type-bersih { background: #064e3b; color: #6ee7b7; }
|
|
.type-basah { background: #0c4a6e; color: #7dd3fc; }
|
|
.type-kotor { background: #7c2d12; color: #fdba74; }
|
|
.type-unknown { background: #334155; color: #94a3b8; }
|
|
.card-date {
|
|
font-size: 12px;
|
|
color: #64748b;
|
|
margin-top: 6px;
|
|
}
|
|
.card-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
}
|
|
.card-actions a {
|
|
font-size: 12px;
|
|
padding: 4px 10px;
|
|
border-radius: 4px;
|
|
text-decoration: none;
|
|
border: 1px solid #334155;
|
|
color: #94a3b8;
|
|
transition: all 0.2s;
|
|
}
|
|
.card-actions a:hover { background: #334155; color: #f8fafc; }
|
|
.card-actions a.del:hover { background: #7f1d1d; color: #fca5a5; }
|
|
.empty {
|
|
text-align: center;
|
|
padding: 80px 30px;
|
|
color: #475569;
|
|
}
|
|
.empty h2 { font-size: 20px; margin-bottom: 8px; color: #64748b; }
|
|
.empty p { font-size: 14px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1><span>🔎</span> Fingerprint <span>Gallery</span></h1>
|
|
<div class="stats"><?= $totalImages ?> gambar</div>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<span style="font-size:13px; color:#64748b; margin-right:5px;">Filter:</span>
|
|
<button class="filter-tag active" onclick="filterImages('all')">Semua</button>
|
|
<button class="filter-tag bersih" onclick="filterImages('BERSIH')">Bersih</button>
|
|
<button class="filter-tag basah" onclick="filterImages('BASAH')">Basah</button>
|
|
<button class="filter-tag kotor" onclick="filterImages('KOTOR')">Kotor</button>
|
|
<div style="flex:1"></div>
|
|
<?php if ($totalImages > 0): ?>
|
|
<a href="?delete_all=1" class="btn btn-danger" onclick="return confirm('Hapus SEMUA gambar?')">Hapus Semua</a>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<?php if ($totalImages === 0): ?>
|
|
<div class="empty">
|
|
<h2>Belum ada gambar</h2>
|
|
<p>Upload gambar sidik jari dari ESP32 untuk melihatnya di sini.</p>
|
|
</div>
|
|
<?php else: ?>
|
|
<div class="grid">
|
|
<?php foreach ($images as $imgPath):
|
|
$fname = basename($imgPath);
|
|
// Parse filename: finger_TYPE_DATE.png
|
|
preg_match('/finger_(\w+)_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.png/', $fname, $m);
|
|
$type = $m[1] ?? 'unknown';
|
|
$dateStr = isset($m[2]) ? str_replace('_', ' ', str_replace('-', ':', substr($m[2], 11))) : '';
|
|
$dateStr = isset($m[2]) ? substr($m[2], 0, 10) . ' ' . str_replace('-', ':', substr($m[2], 11)) : $fname;
|
|
$typeClass = 'type-' . strtolower($type);
|
|
$filesize = filesize($imgPath);
|
|
?>
|
|
<div class="card" data-type="<?= strtoupper($type) ?>">
|
|
<img src="fingerprints/<?= $fname ?>" alt="<?= $fname ?>" loading="lazy">
|
|
<div class="card-info">
|
|
<span class="card-type <?= $typeClass ?>"><?= strtoupper($type) ?></span>
|
|
<div class="card-date"><?= $dateStr ?> · <?= number_format($filesize / 1024, 1) ?> KB</div>
|
|
<div class="card-actions">
|
|
<a href="?download=<?= urlencode($fname) ?>">⬇ Download</a>
|
|
<a href="fingerprints/<?= $fname ?>" target="_blank">🔍 Lihat</a>
|
|
<a href="?delete=<?= urlencode($fname) ?>" class="del" onclick="return confirm('Hapus?')">🗑 Hapus</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<script>
|
|
function filterImages(type) {
|
|
document.querySelectorAll('.filter-tag').forEach(t => t.classList.remove('active'));
|
|
event.target.classList.add('active');
|
|
document.querySelectorAll('.card').forEach(card => {
|
|
if (type === 'all' || card.dataset.type === type) {
|
|
card.style.display = '';
|
|
} else {
|
|
card.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|