| 方案 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|
| ZipArchive (PHP 擴展) | 純 PHP、跨平台、物件導向、支援加密 | 需安裝擴展、大檔案記憶體壓力 | 現代專案首選、共享主機支援度高 |
| 系統指令 (unzip/7z) | 效能極佳、支援所有格式、管道串流 | 需 shell 執行權限、命令注入風險、跨平台差異 | 大檔案、高併發、自建伺服器 |
| PEAR Archive_Zip | 純 PHP、無擴展需求 | 已棄用、效能差、功能有限 | 極舊環境無法安裝擴展 |
<?php
function extractZip(string $zipPath, string $destDir, array $options = []): array {
$defaults = [
'overwrite' => false,
'maxFiles' => 1000,
'maxTotalSize' => 100 * 1024 * 1024, // 100MB
'allowedExtensions' => null, // null = 全部允許
'stripPaths' => false, // true = 解壓時忽略內部目錄結構
'password' => null,
];
$opts = array_merge($defaults, $options);
if (!class_exists('ZipArchive')) {
throw new RuntimeException('ZipArchive 擴展未安裝');
}
if (!is_file($zipPath) || !is_readable($zipPath)) {
throw new InvalidArgumentException('ZIP 檔案不存在或不可讀');
}
if (!is_dir($destDir)) {
if (!mkdir($destDir, 0755, true)) {
throw new RuntimeException("無法建立目標目錄:$destDir");
}
}
$zip = new ZipArchive();
$flags = $opts['password'] ? ZipArchive::OVERWRITE : 0; // 密碼需開啟檔案才能設定
$res = $zip->open($zipPath, $flags);
if ($res !== true) {
throw new RuntimeException("無法開啟 ZIP:錯誤代碼 $res");
}
if ($opts['password']) {
if (!$zip->setPassword($opts['password'])) {
$zip->close();
throw new RuntimeException('ZIP 密碼錯誤');
}
}
$count = $zip->count();
if ($count > $opts['maxFiles']) {
$zip->close();
throw new RuntimeException("檔案數量超過限制:$count > {$opts['maxFiles']}");
}
$totalSize = 0;
$extracted = [];
$errors = [];
for ($i = 0; $i < $count; $i++) {
$stat = $zip->statIndex($i);
if (!$stat) continue;
$name = $stat['name'];
$size = $stat['size'];
// 安全:路徑遍歷檢查
$destPath = $opts['stripPaths']
? $destDir . '/' . basename($name)
: $destDir . '/' . $name;
$destPath = realpath($destDir) . '/' . ltrim(str_replace(realpath($destDir), '', $destPath), '/');
// 確保在目標目錄內
if (strpos($destPath, realpath($destDir)) !== 0) {
$errors[] = "跳過路徑遍歷嘗試:$name";
continue;
}
// 副檔名白名單
if ($opts['allowedExtensions']) {
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext, array_map('strtolower', $opts['allowedExtensions']))) {
$errors[] = "跳過不允許副檔名:$name";
continue;
}
}
// 總大小限制
$totalSize += $size;
if ($totalSize > $opts['maxTotalSize']) {
$errors[] = "超過總大小限制,停止解壓";
break;
}
// 覆蓋檢查
if (!$opts['overwrite'] && file_exists($destPath)) {
$errors[] = "檔案已存在:$name";
continue;
}
// 建立目錄
$dir = dirname($destPath);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
$errors[] = "無法建立目錄:$dir";
continue;
}
// 解壓
if ($zip->extractTo($destDir, [$name]) === false) {
$errors[] = "解壓失敗:$name";
continue;
}
// 如果 stripPaths,需移動檔案
if ($opts['stripPaths'] && $name !== basename($name)) {
$originalPath = $destDir . '/' . $name;
if (file_exists($originalPath)) {
rename($originalPath, $destPath);
}
}
$extracted[] = [
'name' => $name,
'path' => $destPath,
'size' => $size,
];
}
$zip->close();
return ['extracted' => $extracted, 'errors' => $errors];
}
// 使用範例
try {
$result = extractZip(
$_FILES['zipfile']['tmp_name'],
__DIR__ . '/uploads/extracted/' . bin2hex(random_bytes(8)),
[
'allowedExtensions' => ['php', 'html', 'css', 'js', 'json', 'txt', 'md', 'png', 'jpg', 'jpeg', 'gif', 'svg'],
'maxFiles' => 500,
'maxTotalSize' => 50 * 1024 * 1024,
]
);
echo "成功解壓 " . count($result['extracted']) . " 個檔案\n";
if ($result['errors']) {
echo "警告:" . implode("\n", $result['errors']) . "\n";
}
} catch (Throwable $e) {
echo "錯誤:" . $e->getMessage() . "\n";
}
?>
<?php
function extractZipCli(string $zipPath, string $destDir, array $options = []): array {
// 安全:嚴格驗證路徑,禁止任何用戶輸入進入命令
$zipPath = realpath($zipPath);
$destDir = realpath($destDir);
if (!$zipPath || !$destDir) throw new InvalidArgumentException('路徑無效');
// 白名單允許的解壓工具
$tools = ['unzip', '7z', '7za', '7zr'];
$tool = null;
foreach ($tools as $t) {
if (@is_executable("/usr/bin/$t") || @is_executable("/usr/local/bin/$t") || @is_executable("/bin/$t")) {
$tool = $t;
break;
}
}
if (!$tool) throw new RuntimeException('系統未安裝 unzip 或 7z');
// 建構命令 (僅使用固定參數,零用戶輸入)
$cmd = [];
switch ($tool) {
case 'unzip':
$cmd = ["$tool", "-q", "-o", $zipPath, "-d", $destDir];
break;
case '7z': case '7za': case '7zr':
$cmd = ["$tool", "x", $zipPath, "-o$destDir", "-y"];
break;
}
// 執行
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($proc)) throw new RuntimeException('無法啟動解壓程序');
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
foreach ($pipes as $p) fclose($p);
$returnCode = proc_close($proc);
if ($returnCode !== 0) {
throw new RuntimeException("解壓失敗 (code $returnCode):$stderr");
}
// 掃描結果
$extracted = [];
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($destDir, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile()) {
$extracted[] = [
'path' => $file->getPathname(),
'size' => $file->getSize(),
];
}
}
return ['extracted' => $extracted, 'raw_output' => $stdout];
}
?>
<?php
// upload_and_extract.php
$uploadDir = __DIR__ . '/uploads/zips';
$extractBase = __DIR__ . '/uploads/extracted';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['zipfile'])) {
$file = $_FILES['zipfile'];
// 基礎驗證
if ($file['error'] !== UPLOAD_ERR_OK) die('上傳錯誤');
if ($file['size'] > 200 * 1024 * 1024) die('檔案過大 (限制 200MB)');
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'])) {
die('非 ZIP 檔案');
}
// 安全儲存
$zipName = bin2hex(random_bytes(16)) . '.zip';
$zipPath = $uploadDir . '/' . $zipName;
if (!move_uploaded_file($file['tmp_name'], $zipPath)) die('儲存失敗');
// 建立獨立解壓目錄
$extractDir = $extractBase . '/' . bin2hex(random_bytes(8));
mkdir($extractDir, 0755, true);
try {
// 根據檔案大小選擇方案
if ($file['size'] > 50 * 1024 * 1024 && function_exists('proc_open')) {
$result = extractZipCli($zipPath, $extractDir);
} else {
$result = extractZip($zipPath, $extractDir, [
'allowedExtensions' => ['php', 'html', 'css', 'js', 'json', 'txt', 'md', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'pdf'],
]);
}
echo json_encode(['success' => true, 'count' => count($result['extracted']), 'dir' => $extractDir]);
} catch (Throwable $e) {
// 失敗時清理
@unlink($zipPath);
// 遞迴刪除解壓目錄
function rrmdir($d) { foreach (scandir($d) as $f) { if ($f!='.'&&$f!='..') { $p="$d/$f"; is_dir($p)?rrmdir($p):unlink($p); } } rmdir($d); }
@rrmdir($extractDir);
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
}
?>
PHP 解壓 ZIP 首選 ZipArchive,安全可控、跨平台、功能完整。大檔案 (>50MB) 或高併發場景可評估 系統指令 (7z/unzip),但必須實施嚴格的命令注入防護與資源限制。核心安全原則:**白名單驗證、路徑遍歷檢查、資源配額限制、最小權限執行、完整清理機制**。