掃描目錄圖片、按修改時間倒序、分頁顯示是相簿、圖庫常見需求。本文提供完整單檔案實作,含圖片過濾、分頁邏輯、前端顯示,並分析安全性與效能優化方向。
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>圖片相簿分頁顯示</title>
<style>
.gallery { max-width: 90%; margin: 20px auto; border: 1px solid #ccc; text-align: center; padding: 20px; }
.image-item { display: inline-block; margin: 10px; text-align: center; vertical-align: top; }
.image-item img { max-width: 200px; max-height: 200px; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.image-item .filename { margin-top: 5px; font-size: 0.9em; color: #666; word-break: break-all; }
.pagination { margin: 20px 0; }
.pagination a, .pagination span { display: inline-block; padding: 8px 12px; margin: 0 3px; border: 1px solid #ddd; text-decoration: none; color: #333; border-radius: 3px; }
.pagination a:hover { background: #007bff; color: white; border-color: #007bff; }
.pagination .current { background: #007bff; color: white; border-color: #007bff; }
.pagination .disabled { color: #999; border-color: #ddd; pointer-events: none; }
</style>
</head>
<body>
<div class="gallery">
<?php
// === 設定區 ===
$imagesPerPage = 10; // 每頁圖片數
$imageDir = 'upload'; // 圖片目錄 (相對路徑)
$allowedExt = ['gif', 'jpg', 'jpeg', 'png', 'webp', 'bmp']; // 允許副檔名
// === 取得頁碼 ===
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
// === 掃描圖片 ===
$images = [];
$handle = @opendir($imageDir);
if ($handle) {
while (($file = readdir($handle)) !== false) {
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (in_array($ext, $allowedExt, true) && is_file($imageDir . DIRECTORY_SEPARATOR . $file)) {
$fullPath = $imageDir . DIRECTORY_SEPARATOR . $file;
$images[] = [
'name' => $file,
'path' => $fullPath,
'mtime' => filemtime($fullPath),
'size' => filesize($fullPath),
'ext' => $ext
];
}
}
closedir($handle);
}
// === 排序:修改時間倒序 (最新在前) ===
usort($images, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
// === 分頁計算 ===
$totalImages = count($images);
$totalPages = max(1, (int)ceil($totalImages / $imagesPerPage));
$page = min($page, $totalPages); // 修正超出範圍頁碼
$offset = ($page - 1) * $imagesPerPage;
$pageImages = array_slice($images, $offset, $imagesPerPage);
// === 顯示圖片 ===
if (empty($pageImages)) {
echo '<p style="color: #999; padding: 40px;">該目錄無圖片或無讀取權限</p>';
} else {
foreach ($pageImages as $img) {
$safeName = htmlspecialchars($img['name']);
$safePath = htmlspecialchars($img['path']);
$mtime = date('Y-m-d H:i:s', $img['mtime']);
$size = formatBytes($img['size']);
echo "<div class='image-item'>";
echo "<img src='{$safePath}' alt='{$safeName}' loading='lazy'>";
echo "<div class='filename' title='{$mtime} | {$size}'>{$safeName}</div>";
echo "</div>";
}
}
// === 分頁導航 ===
if ($totalPages > 1) {
echo "<div class='pagination'>";
// 首頁
if ($page > 1) {
echo "<a href='?page=1'>首頁</a>";
echo "<a href='?page=" . ($page - 1) . "'>上一頁</a>";
} else {
echo "<span class='disabled'>首頁</span>";
echo "<span class='disabled'>上一頁</span>";
}
// 頁碼範圍 (顯示當前頁前後各 2 頁)
$start = max(1, $page - 2);
$end = min($totalPages, $page + 2);
if ($start > 1) echo "<span class='disabled'>...</span>";
for ($i = $start; $i <= $end; $i++) {
if ($i === $page) {
echo "<span class='current'>{$i}</span>";
} else {
echo "<a href='?page={$i}'>{$i}</a>";
}
}
if ($end < $totalPages) echo "<span class='disabled'>...</span>";
// 末頁
if ($page < $totalPages) {
echo "<a href='?page=" . ($page + 1) . "'>下一頁</a>";
echo "<a href='?page={$totalPages}'>末頁</a>";
} else {
echo "<span class='disabled'>下一頁</span>";
echo "<span class='disabled'>末頁</span>";
}
echo "</div>";
}
// 輔助函數:位元組格式化
function formatBytes($bytes, $precision = 2) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1024 ** $pow);
return round($bytes, $precision) . ' ' . $units[$pow];
}
?>
</div>
</body>
</html>
<?php
// ✅ 使用 pathinfo 取得副檔名 (避免多點檔名誤判)
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
// ✅ 嚴格白名單驗證
$allowedExt = ['gif', 'jpg', 'jpeg', 'png', 'webp', 'bmp'];
if (!in_array($ext, $allowedExt, true)) continue; // true = 嚴格比較
// ✅ 確認是檔案非目錄 (防止目錄遍歷)
if (!is_file($fullPath)) continue;
// ✅ 路徑安全:使用 DIRECTORY_SEPARATOR 而非硬編碼 /
$fullPath = $imageDir . DIRECTORY_SEPARATOR . $file;
?>
<?php
// 總頁數計算 (向上取整)
$totalPages = max(1, (int)ceil($totalImages / $imagesPerPage));
// 頁碼修正 (防止負數、超出範圍)
$page = max(1, min($page, $totalPages));
// 偏移量計算
$offset = ($page - 1) * $imagesPerPage;
// 陣列切片 (不修改原陣列)
$pageImages = array_slice($images, $offset, $imagesPerPage);
?>
<?php
$cacheFile = __DIR__ . '/.image_cache.php';
$cacheTime = 300; // 5 分鐘
if (file_exists($cacheFile) && time() - filemtime($cacheFile) < $cacheTime) {
$images = include $cacheFile;
} else {
// 掃描邏輯...
// 產生快取檔案
$cacheContent = '
<?php
function getThumbnail($imagePath, $maxWidth = 200, $maxHeight = 200, $quality = 80) {
$thumbDir = dirname($imagePath) . '/.thumbs';
if (!is_dir($thumbDir)) mkdir($thumbDir, 0755, true);
$thumbPath = $thumbDir . '/' . basename($imagePath);
// 若縮圖不存在或原圖較新,重新生成
if (!file_exists($thumbPath) || filemtime($thumbPath) < filemtime($imagePath)) {
$srcInfo = getimagesize($imagePath);
if (!$srcInfo) return $imagePath; // 非圖片
$srcW = $srcInfo[0];
$srcH = $srcInfo[1];
$ratio = min($maxWidth / $srcW, $maxHeight / $srcH);
$dstW = (int)($srcW * $ratio);
$dstH = (int)($srcH * $ratio);
$srcImg = match($srcInfo[2]) {
IMAGETYPE_JPEG => imagecreatefromjpeg($imagePath),
IMAGETYPE_PNG => imagecreatefrompng($imagePath),
IMAGETYPE_GIF => imagecreatefromgif($imagePath),
IMAGETYPE_WEBP => imagecreatefromwebp($imagePath),
default => null
};
if ($srcImg) {
$dstImg = imagecreatetruecolor($dstW, $dstH);
// 保持透明度 (PNG/GIF)
if ($srcInfo[2] === IMAGETYPE_PNG || $srcInfo[2] === IMAGETYPE_GIF) {
imagealphablending($dstImg, false);
imagesavealpha($dstImg, true);
$transparent = imagecolorallocatealpha($dstImg, 255, 255, 255, 127);
imagefilledrectangle($dstImg, 0, 0, $dstW, $dstH, $transparent);
}
imagecopyresampled($dstImg, $srcImg, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);
switch ($srcInfo[2]) {
case IMAGETYPE_JPEG: imagejpeg($dstImg, $thumbPath, $quality); break;
case IMAGETYPE_PNG: imagepng($dstImg, $thumbPath); break;
case IMAGETYPE_GIF: imagegif($dstImg, $thumbPath); break;
case IMAGETYPE_WEBP: imagewebp($dstImg, $thumbPath, $quality); break;
}
imagedestroy($srcImg);
imagedestroy($dstImg);
}
}
return file_exists($thumbPath) ? $thumbPath : $imagePath;
}
?>
<script>
// 前端 JS
let currentPage = 1;
let loading = false;
window.addEventListener('scroll', () => {
if (loading) return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
loadMore();
}
});
async function loadMore() {
loading = true;
currentPage++;
try {
const response = await fetch(`?page=${currentPage}&ajax=1`);
const html = await response.text();
if (html.trim()) {
document.querySelector('.gallery').insertAdjacentHTML('beforeend', html);
} else {
currentPage--; // 無更多資料
}
} catch (e) {
console.error(e);
currentPage--;
}
loading = false;
}
</script>
<?php
// 後端支援 AJAX
if (isset($_GET['ajax'])) {
// 僅輸出圖片項目 HTML,不含分頁導航
foreach ($pageImages as $img) { /* ... */ }
exit;
}
?>
| 項目 | 風險 | 對策 |
|---|---|---|
| 目錄遍歷 | 攻擊者輸入 `../../etc/passwd` | 固定目錄、不接受使用者輸入路徑、realpath 驗證 |
| 檔案型別偽裝 | 上傳 `shell.php.jpg` | 白名單副檔名、getimagesize 驗證、儲存時重命名 |
| 大量檔案效能 | 單目錄超過 1 萬檔案掃描緩慢 | 分級目錄 (年/月/日)、資料庫索引、快取 |
| 記憶體耗盡 | 大量圖片同時載入 | 分頁、懶載入 (loading="lazy")、縮圖 |
| XSS | 檔名含惡意腳本 | htmlspecialchars 輸出編碼、Content-Security-Policy |
此實作適合中小型相簿專案。大規模應用建議:資料庫儲存檔案中繼資料、物件儲存 (S3/OSS)、CDN 分發、非同步縮圖處理佇列。