First dev version v.0.1

This commit is contained in:
2025-10-21 17:53:37 +01:00
parent 8f319f4039
commit a09d4818b5
26 changed files with 1680 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<?php
// includes/admin_actions.php
require_once __DIR__ . '/config.php';
session_start();
if (empty($_SESSION['is_admin'])) {
http_response_code(403);
exit('Access denied');
}
$action = $_GET['action'] ?? '';
switch ($action) {
case 'rebuild_gallery':
require_once __DIR__ . '/api.php';
echo "✅ Gallery cache rebuilt successfully.";
break;
case 'rebuild_exif':
require_once __DIR__ . '/exif_helper.php';
$dir = $CONFIG['gallery_dir'];
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
$count = 0;
foreach ($rii as $file) {
if ($file->isFile() && preg_match('/\.jpe?g$/i', $file->getFilename())) {
get_exif_cached($file->getPathname(), $CONFIG['exif_dir'], 0);
$count++;
}
}
echo "✅ Rebuilt EXIF cache for {$count} images.";
break;
case 'clean_thumbs':
$thumb_dir = $CONFIG['thumb_dir'];
$removed = 0;
foreach (glob($thumb_dir . '/*.jpg') as $t) {
unlink($t);
$removed++;
}
echo "🧹 Removed {$removed} thumbnails.";
break;
case 'restart_php':
shell_exec('pgrep php-fpm | xargs kill -USR2 2>/dev/null');
echo "🔄 PHP-FPM reload signal sent.";
break;
default:
echo "Unknown action.";
}

View File

@@ -0,0 +1,32 @@
<?php
// includes/admin_auth.php
session_start();
require_once __DIR__ . '/config.php';
header('Content-Type: application/json');
$action = $_REQUEST['action'] ?? '';
$password = $_POST['password'] ?? '';
switch ($action) {
case 'check':
echo json_encode(['logged_in' => !empty($_SESSION['is_admin'])]);
break;
case 'login':
if (password_verify($password, $CONFIG['upload_password'])) {
$_SESSION['is_admin'] = true;
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false]);
}
break;
case 'logout':
session_destroy();
echo json_encode(['success' => true]);
break;
default:
echo json_encode(['error' => 'Invalid action']);
}

84
src/includes/api.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
// includes/api.php gallery JSON API with EXIF summary + permanent cache
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/exif_helper.php';
require_once __DIR__ . '/thumbnail_helper.php';
header('Content-Type: application/json');
// Pagination setup
$page = max(1, intval($_GET['page'] ?? 1));
$per_page = $CONFIG['max_per_page'] ?? 100;
// Cache file setup
$cache_file = $CONFIG['cache_file'];
// Serve cached gallery JSON if fresh
if (file_exists($cache_file)) {
$data = json_decode(file_get_contents($cache_file), true);
} else {
$data = buildGalleryCache($CONFIG);
if (!is_dir(dirname($cache_file))) @mkdir(dirname($cache_file), 0775, true);
file_put_contents($cache_file, json_encode($data, JSON_PRETTY_PRINT));
}
// Paginate
$total = count($data['images']);
$total_blocks = ceil($total / $per_page);
$slice = array_slice($data['images'], ($page - 1) * $per_page, $per_page);
// Output JSON
echo json_encode([
'total_blocks' => $total_blocks,
'blocks_returned' => count($slice),
'blocks' => $slice
], JSON_PRETTY_PRINT);
// --- FUNCTIONS ---
function buildGalleryCache($CONFIG) {
$dir = $CONFIG['gallery_dir'];
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
$images = [];
foreach ($rii as $file) {
if ($file->isFile() && preg_match('/\.jpe?g$/i', $file->getFilename())) {
$path = $file->getPathname();
$hash = md5($path);
$rel = str_replace($CONFIG['base_dir'], '', $path);
$thumb = str_replace($CONFIG['base_dir'], '', $CONFIG['thumb_dir']) ."/" . $hash . '.' . strtolower(pathinfo($path, PATHINFO_EXTENSION));
// Get EXIF (cached full dump)
$info = [];
$exif = [];
$summary = [];
$info = get_exif_cached($path, $CONFIG['exif_dir'], $hash);
$exif_file = $info['file'];
$exif = $info['info'];
unset($info);
$summary = extractExifSummary($exif);
// Generate the thumbnails
if (!file_exists($thumb)) {
generate_thumbnail_imagick($path,$hash,$CONFIG['thumb_dir'],$CONFIG['thumb_max_size'],$CONFIG['thumb_quality']);
}
$images[] = [
'filename' => basename($path),
'publicPath' => $rel,
'thumbPublic' => $thumb,
'date' => date('Y-m-d', filemtime($path)),
'MD5' => $hash,
'exif_file' => $exif_file,
'exif' => $summary
];
}
}
return [
'generated' => date('c'),
'count' => count($images),
'images' => $images
];
}

39
src/includes/config.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
// includes/config.php
// Global configuration for the site. Edit values as needed.
$ROOT_DIR='/home/reclusejay/repos/camera-gallery/src';
$CONFIG = [
// === General Site Info ===
'site_name' => 'My Photo Gallery',
'nav_order' => ['home', 'gallery', 'upload'],
'nav_admin' => ['shell', 'gethash'],
'nav_hidden' => ['admin','infophp'],
'default_theme' => 'dark', // 'dark' or 'light'
'timezone' => 'UTC',
// === Paths ===
'base_dir' => $ROOT_DIR,
'gallery_dir' => $ROOT_DIR.'/img/sorted/jpg',
'thumb_dir' => $ROOT_DIR.'/cache/thumbs',
'cache_dir' => $ROOT_DIR.'/cache',
'log_dir' => $ROOT_DIR.'/logs',
'upload_dir' => $ROOT_DIR.'/img/uploads',
'index_file' => $ROOT_DIR.'/img/.index',
// === Gallery & Caching ===
'max_per_page' => 100,
'thumb_max_size' => 300,
'thumb_quality' => 80,
'cache_file' => $ROOT_DIR.'/cache/gallery.json',
'remove_nodes' => ['MAKERNOTE', 'INTEROP', 'THUMBNAIL'],
'remove_tags' => ['SectionsFound','UserComment','UserCommentEncoding','Thumbnail.FileType','Thumbnail.MimeType','ComponentsConfiguration','FileSource','SceneType'],
'exif_dir' => $ROOT_DIR.'/cache/exif',
// === Uploads ===
'upload_password' => '$2y$12$Fb7u3H.428PoPCy/pxhqpu.poqjmDbiyzJtRJs/CEcCEPPMOYBLCm', // bcrypt hash
'max_parallel_uploads' => 2, // max simultaneous uploads
];
// Apply timezone globally
date_default_timezone_set($CONFIG['timezone']);

View File

@@ -0,0 +1,123 @@
<?php
// includes/exif_helper.php
// Full EXIF extractor and permanent cache
require_once __DIR__ . '/config.php';
function get_exif_cached($path, $exif_dir, $hash) {
global $CONFIG;
if (!file_exists($path)) return [];
$json_path = rtrim($exif_dir, '/') . '/' . $hash . '.json';
// Return cached EXIF if it exists (and expiry = 0 means never refresh)
if (file_exists($json_path)) {
$exif = json_decode(file_get_contents($json_path), true);
if (is_array($exif)) return [ 'file' => $json_path, 'info' => $exif ];
}
// Extract fresh EXIF
$exif = @exif_read_data($path, null, true);
//clean exif data remove unwanted tags
foreach ($CONFIG['remove_nodes'] as $node) {
unset($exif[$node]);
}
if (!$exif || !is_array($exif)) return [];
// Clean up binary / overly large data
foreach ($exif as $section => &$entries) {
foreach ($entries as $key => $val) {
if (is_string($val) && (strlen($val) > 256 || !mb_check_encoding($val, 'UTF-8'))) { unset($entries[$key]); }
if (preg_match('/^UndefinedTag:0x[0-9A-Fa-f]+$/', $key)) { unset($entries[$key]); }
if (in_array($key, $CONFIG['remove_tags'])) { unset($entries[$key]); }
}
}
// Save the entire structure as JSON
if (!is_dir($exif_dir)) @mkdir($exif_dir, 0775, true);
file_put_contents($json_path, json_encode($exif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return [ 'file' => $json_path, 'info' => $exif ];
}
/*
function extractExifSummary($exif) {
if (!is_array($exif)) return [];
// Safely extract key EXIF tags if available
return array_filter([
'Camera' => $exif['IFD0']['MAKE'].'-'.$exif['IFD0']['Model'] ?? null,
'Lens' => $exif['EXIF']['LensModel'] ?? null,
'Aperture' => isset($exif['EXIF']['FNumber']) ? 'f/' . round(exifFractionToFloat($exif['EXIF']['FNumber']), 1) : null,
'Shutter' => $exif['EXIF']['ExposureTime'] ?? null,
'ISO' => $exif['EXIF']['ISOSpeedRatings'] ?? null,
'WhiteBalance' => ($exif['EXIF']['WhiteBalance'] ?? 0) ? 'Manual' : 'Auto',
'DateTaken' => $exif['EXIF']['DateTimeOriginal'] ?? null
]);
}*/
function extractExifSummary(array $exif): array {
// Exif mapped values
$programMap = [
0 => 'Not defined', 1 => 'Manual', 2 => 'Normal program',
3 => 'Aperture priority', 4 => 'Shutter priority', 5 => 'Creative program',
6 => 'Action program', 7 => 'Portrait', 8 => 'Landscape',
];
$colorSpaceMap = [
1 => 'sRGB', 65535 => 'AdobeRGB'
];
$meteringModeMap = [
0 => 'Unknown', 1 => 'Average', 2 => 'Center-weighted', 3 => 'Spot', 4 => 'Multi-spot',
5 => 'Pattern', 6 => 'Partial', 255 => 'Other'
];
$orientationMap = [
1 => 'Top-left', 2 => 'Top-right', 3 => 'Bottom-right', 4 => 'Bottom-left',
5 => 'Left-top', 6 => 'Right-top', 7 => 'Right-bottom', 8 => 'Left-bottom'
];
$sceneCaptureMap = [0 => 'Standard', 1 => 'Landscape', 2 => 'Portrait', 3 => 'Night scene'];
// Helper to convert EXIF fraction to float
$fractionToFloat = function($val) {
if (is_string($val) && strpos($val, '/') !== false) {
[$num, $den] = explode('/', $val);
return (float)$num / (float)$den;
}
return (float)$val;
};
// Helper to safely get nested array values
$get = function(array $arr, string ...$keys) {
foreach ($keys as $key) {
if (isset($arr[$key])) {
$arr = $arr[$key];
} else {
return null;
}
}
return $arr;
};
return array_filter([
'Camera' => $get($exif, 'IFD0','Make').'-'.$get($exif, 'IFD0', 'Model'),
'Lens' => $get($exif, 'EXIF', 'LensModel'),
'ExposureProgram' => $programMap[$get($exif, 'EXIF', 'ExposureProgram')] ?? 0,
'Aperture' => ($f = $get($exif, 'EXIF', 'FNumber')) ? 'f/' . round($fractionToFloat($f), 1) : null,
'Shutter' => $get($exif, 'EXIF', 'ExposureTime'),
'ISO' => $get($exif, 'EXIF', 'ISOSpeedRatings'),
'FocalLength' => ($fl = $get($exif, 'EXIF', 'FocalLength')) ? round($fractionToFloat($fl), 1) . 'mm' : null,
'ExposureBiasValue' => ($ev = $get($exif, 'EXIF', 'ExposureBiasValue')) ? round($fractionToFloat($ev), 1) . ' EV' : null,
'WhiteBalance' => ($wb = $get($exif, 'EXIF', 'WhiteBalance') ?? 0) ? 'Manual' : 'Auto',
'DateTaken' => $get($exif, 'EXIF', 'DateTimeOriginal'),
'Orientation' => $orientationMap[$get($exif, 'IFD0', 'Orientation') ?? 1],
'ImageWidth' => $get($exif, 'EXIF', 'PixelXDimension') ?? $get($exif, 'IFD0', 'ImageWidth'),
'ImageHeight' => $get($exif, 'EXIF', 'PixelYDimension') ?? $get($exif, 'IFD0', 'ImageLength'),
'ColorSpace' => $colorSpaceMap[$get($exif, 'EXIF', 'ColorSpace') ?? 65535],
'MeteringMode' => $meteringModeMap[$get($exif, 'EXIF', 'MeteringMode') ?? 0],
'SceneCaptureType' => $sceneCaptureMap[$get($exif, 'EXIF', 'SceneCaptureType') ?? 0],
'SubjectDistance' => $get($exif, 'EXIF', 'SubjectDistance'),
'Contrast' => $get($exif, 'EXIF', 'Contrast'),
'Saturation' => $get($exif, 'EXIF', 'Saturation'),
'Sharpness' => $get($exif, 'EXIF', 'Sharpness'),
]);
}

74
src/includes/stats.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
// includes/stats.php
header('Content-Type: application/json');
// --- CPU usage ---
$load = sys_getloadavg()[0];
$cpu_usage = round(($load / shell_exec('nproc')) * 100, 1);
// --- Memory usage ---
$meminfo = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES);
$mem = [];
foreach ($meminfo as $line) {
[$key, $val] = array_map('trim', explode(':', $line));
$mem[$key] = (int) filter_var($val, FILTER_SANITIZE_NUMBER_INT);
}
$ram_total = $mem['MemTotal'] ?? 0;
$ram_free = ($mem['MemAvailable'] ?? 0);
$ram_used = $ram_total - $ram_free;
$ram_usage = $ram_total ? round(($ram_used / $ram_total) * 100, 1) : 0;
$swap_total = $mem['SwapTotal'] ?? 0;
$swap_free = $mem['SwapFree'] ?? 0;
$swap_used = $swap_total - $swap_free;
$swap_usage = $swap_total ? round(($swap_used / $swap_total) * 100, 1) : 0;
// Optional: check ZRAM usage
$zram_used = 0;
$zram_total = 0;
if (is_dir('/sys/block')) {
foreach (glob('/sys/block/zram*/mm_stat') as $zram) {
$stats = explode(' ', file_get_contents($zram));
$zram_used += (int)$stats[1];
$zram_total += (int)$stats[2];
}
}
$zram_usage = $zram_total ? round(($zram_used / $zram_total) * 100, 1) : 0;
// --- Disk usage ---
$disk_total = disk_total_space('/');
$disk_free = disk_free_space('/');
$disk_used = $disk_total - $disk_free;
$disk_usage = round(($disk_used / $disk_total) * 100, 1);
$disk0_total = disk_total_space('/home');
$disk0_free = disk_free_space('/home');
$disk0_used = $disk0_total - $disk0_free;
$disk0_usage = round(($disk0_used / $disk0_total) * 100, 1);
// --- Gallery image count ---
// --- Image count from cache/gallery.json ---
$jsonFile = __DIR__ . '/../cache/gallery.json';
if (file_exists($jsonFile)) {
$gallery = json_decode(file_get_contents($jsonFile), true);
$count = $gallery['count'] ?? 0;
} else {
$count = 0;
}
// --- Response ---
echo json_encode([
'cpu' => $cpu_usage,
'ram' => $ram_usage,
'ram_total' => $ram_total * 1024, // convert KB → bytes
'ram_used' => $ram_used * 1024,
'zram' => $zram_usage,
'swap' => $swap_usage,
'disk' => $disk_usage,
'disk_total' => $disk_total,
'disk_used' => $disk_used,
'disk0' => $disk0_usage,
'disk0_total' => $disk0_total,
'disk0_used' => $disk0_used,
'images' => $count,
]);

View File

@@ -0,0 +1,90 @@
<?php
/** includes/thumbnail_helper.php
* Generate a thumbnail using Imagick
* @param string $src_path Path to the source image
* @param string $hash MD5 filename
* @param string $thumb_path Path to save the generated thumbnail
* @param int $max_size Max thumbnail size (pixels)
* @param int $quality Output image quality (1100)
* @return bool True on success, false on failure
*/
function generate_thumbnail_imagick($src_path, $hash, $thumb_path, $max_size, $quality) {
$ext = strtolower(pathinfo($src_path, PATHINFO_EXTENSION));
$img = new Imagick($src_path);
if (method_exists($img, 'autoOrientImage')) {
// Modern Imagick versions
$img->setImageOrientation(Imagick::ORIENTATION_UNDEFINED);
$img->autoOrientImage();
} else {
// Manual fallback for older Imagick builds
switch ($img->getImageOrientation()) {
case Imagick::ORIENTATION_TOPLEFT: // normal
break;
case Imagick::ORIENTATION_TOPRIGHT:
$img->flopImage(); // horizontal flip
break;
case Imagick::ORIENTATION_BOTTOMRIGHT:
$img->rotateImage(new ImagickPixel(), 180);
break;
case Imagick::ORIENTATION_BOTTOMLEFT:
$img->flopImage();
$img->rotateImage(new ImagickPixel(), 180);
break;
case Imagick::ORIENTATION_LEFTTOP:
$img->flopImage();
$img->rotateImage(new ImagickPixel(), 90);
break;
case Imagick::ORIENTATION_RIGHTTOP:
$img->rotateImage(new ImagickPixel(), 90);
break;
case Imagick::ORIENTATION_RIGHTBOTTOM:
$img->flopImage();
$img->rotateImage(new ImagickPixel(), 270);
break;
case Imagick::ORIENTATION_LEFTBOTTOM:
$img->rotateImage(new ImagickPixel(), 270);
break;
}
$img->setImageOrientation(Imagick::ORIENTATION_UNDEFINED);
}
// Get original dimensions
$width = $img->getImageWidth();
$height = $img->getImageHeight();
// Calculate proportional scaling based on longest side
if ($width > $height) {
$new_width = $max_size;
$new_height = (int) round($height * ($max_size / $width));
} else {
$new_height = $max_size;
$new_width = (int) round($width * ($max_size / $height));
}
$img->setImageCompressionQuality($quality);
$img->setBackgroundColor(new ImagickPixel('white'));
$img->thumbnailImage($new_width, $new_height, true);
$img->stripImage(); // remove metadata (saves space)
// Infer output format from extension
switch ($ext) {
case 'png':
$img->setImageFormat('png');
break;
case 'webp':
$img->setImageFormat('webp');
break;
default:
$img->setImageFormat('jpeg');
break;
}
$thumbnail=$thumb_path . '/' . $hash . '.' . $ext;
$img->writeImage($thumbnail);
$img->destroy();
return true;
}

View File

@@ -0,0 +1,58 @@
<?php
// includes/upload_handler.php backend for multi-file upload
require_once __DIR__ . '/config.php';
header('Content-Type: application/json');
$uploadDir = '/srv/www/uploads';
$indexFile = $CONFIG['index_file'];
$successCount = 0;
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
if (!isset($_FILES['files'])) {
echo json_encode(['success' => false, 'error' => 'No files uploaded.']);
exit;
}
foreach ($_FILES['files']['tmp_name'] as $i => $tmp) {
if (!is_uploaded_file($tmp)) continue;
$origName = $_FILES['files']['name'][$i];
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
if ($ext !== 'jpg' && $ext !== 'jpeg') continue;
// Extract EXIF date + shutter count if available
$exif = @exif_read_data($tmp, 'EXIF', true);
$date = $exif['EXIF']['DateTimeOriginal'] ?? date('Y:m:d H:i:s');
[$Y,$m,$d,$H,$M,$S] = sscanf($date, "%4d:%2d:%2d %2d:%2d:%2d");
$yearDir = sprintf("%s/%04d", $uploadDir, $Y);
$monthDir = sprintf("%s/%02d", $yearDir, $m);
$dayDir = sprintf("%s/%02d", $monthDir, $d);
if (!is_dir($dayDir)) mkdir($dayDir, 0755, true);
$time = sprintf("%02d%02d%02d", $H, $M, $S);
$shutter = '';
if (!empty($exif['EXIF']['ShutterCount'])) {
$shutter = 'sc' . $exif['EXIF']['ShutterCount'];
} elseif (!empty($exif['MakerNote']['ShutterCount'])) {
$shutter = 'sc' . $exif['MakerNote']['ShutterCount'];
} else {
$shutter = 'rn' . rand(100000, 999999);
}
// Read / increment index
$idx = 1;
if (file_exists($indexFile)) {
$idx = intval(trim(file_get_contents($indexFile))) + 1;
}
file_put_contents($indexFile, $idx);
$newName = sprintf("%s_%sidx%d.%s", $time, $shutter, $idx, $ext);
$dest = sprintf("%s/%s", $dayDir, $newName);
if (move_uploaded_file($tmp, $dest)) $successCount++;
}
echo json_encode(['success' => true, 'count' => $successCount]);