<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>校園祭 - 圖片上傳</title>
<link rel="stylesheet" href="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css">
<script src="https://code.jquery.com/jquery-1.11.3.min.js"></script>
<script src="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
<style>
.upload-preview { max-width: 100%; height: auto; margin-top: 10px; display: none; }
.progress-bar { height: 20px; background: #e0e0e0; border-radius: 10px; overflow: hidden; display: none; }
.progress-fill { height: 100%; background: #4CAF50; width: 0%; transition: width 0.3s; }
.btn-upload:disabled { opacity: 0.6; }
</style>
</head>
<body>
<div data-role="page" id="upload-page">
<div data-role="header" data-position="fixed">
<h1>校園祭作品上傳</h1>
<a href="#home" class="ui-btn ui-icon-home ui-btn-icon-left">首頁</a>
</div>
<div role="main" class="ui-content">
<form id="upload-form" action="upload.php" method="post" enctype="multipart/form-data" data-ajax="false">
<div class="ui-field-contain">
<label for="file-input">選擇圖片 (JPG/PNG/GIF/WebP, ≤10MB)</label>
<input type="file" id="file-input" name="file" accept="image/*" capture="environment">
</div>
<div id="preview-container">
<img id="preview-img" class="upload-preview" alt="預覽">
</div>
<div class="progress-bar" id="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="ui-grid-a">
<div class="ui-block-a">
<button type="reset" class="ui-btn ui-btn-b">重選</button>
</div>
<div class="ui-block-b">
<button type="submit" class="ui-btn ui-btn-c btn-upload" id="btn-upload">上傳</button>
</div>
</div>
</form>
<div id="result-message" class="ui-body ui-body-a ui-corner-all" style="display:none; margin-top:15px;"></div>
</div>
<div data-role="footer" data-position="fixed">
<h4>創新實驗室 © 2024</h4>
</div>
</div>
<script>
$(document).on('pagecreate', '#upload-page', function() {
const $form = $('#upload-form');
const $input = $('#file-input');
const $preview = $('#preview-img');
const $progressBar = $('#progress-bar');
const $progressFill = $('#progress-fill');
const $btn = $('#btn-upload');
const $result = $('#result-message');
// 檔案選擇預覽
$input.on('change', function() {
const file = this.files[0];
if (!file) return;
// 客戶端驗證
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const maxSize = 10 * 1024 * 1024; // 10MB
if (!allowedTypes.includes(file.type)) {
showResult('不支援的檔案格式,請選擇 JPG/PNG/GIF/WebP', 'error');
$input.val('');
return;
}
if (file.size > maxSize) {
showResult('檔案過大,限制 10MB 以內', 'error');
$input.val('');
return;
}
// 預覽
const reader = new FileReader();
reader.onload = (e) => {
$preview.attr('src', e.target.result).show();
};
reader.readAsDataURL(file);
});
// 表單提交 (AJAX 上傳帶進度)
$form.on('submit', function(e) {
e.preventDefault();
if (!$input.val()) {
showResult('請先選擇檔案', 'error');
return;
}
$btn.prop('disabled', true).text('上傳中...');
$progressBar.show();
$progressFill.css('width', '0%');
$result.hide();
const formData = new FormData(this);
$.ajax({
url: $form.attr('action'),
type: 'POST',
data: formData,
processData: false,
contentType: false,
xhr: function() {
const xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
$progressFill.css('width', percent + '%');
}
}, false);
return xhr;
},
success: function(response) {
try {
const data = typeof response === 'string' ? JSON.parse(response) : response;
if (data.success) {
showResult('上傳成功!檔案:' + data.filename, 'success');
$input.val('');
$preview.hide();
} else {
showResult('上傳失敗:' + (data.error || '未知錯誤'), 'error');
}
} catch (ex) {
showResult('伺服器回應異常', 'error');
}
},
error: function(xhr) {
showResult('網路錯誤:' + xhr.statusText, 'error');
},
complete: function() {
$btn.prop('disabled', false).text('上傳');
$progressBar.hide();
}
});
});
function showResult(msg, type) {
$result.removeClass('ui-body-a ui-body-b ui-body-c ui-body-d ui-body-e')
.addClass(type === 'success' ? 'ui-body-b' : 'ui-body-e')
.text(msg)
.show();
}
});
</script>
</body>
</html>
<?php
/**
* 檔案上傳處理器
* 功能:驗證、安全檢查、重命名、儲存、JSON 回應
*/
header('Content-Type: application/json; charset=utf-8');
// 配置
$config = [
'upload_dir' => __DIR__ . '/uploads/',
'allowed_types' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
'max_size' => 10 * 1024 * 1024, // 10MB
'max_files' => 1,
'url_prefix' => '/uploads/',
];
// 確保上傳目錄存在且可寫
if (!is_dir($config['upload_dir'])) {
if (!mkdir($config['upload_dir'], 0755, true)) {
jsonResponse(false, '無法建立上傳目錄');
}
}
if (!is_writable($config['upload_dir'])) {
jsonResponse(false, '上傳目錄不可寫入,請檢查權限');
}
// 輔助函數
function jsonResponse(bool $success, string $message = '', array $data = []): never {
echo json_encode(['success' => $success, 'message' => $message] + $data, JSON_UNESCAPED_UNICODE);
exit;
}
// 檢查請求
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(false, '僅接受 POST 請求');
}
// 檢查上傳錯誤
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
$errorMessages = [
UPLOAD_ERR_INI_SIZE => '檔案超過 php.ini upload_max_filesize 限制',
UPLOAD_ERR_FORM_SIZE => '檔案超過表單 MAX_FILE_SIZE 限制',
UPLOAD_ERR_PARTIAL => '檔案僅部分上傳',
UPLOAD_ERR_NO_FILE => '未選擇檔案',
UPLOAD_ERR_NO_TMP_DIR => '缺少臨時目錄',
UPLOAD_ERR_CANT_WRITE => '寫入磁碟失敗',
UPLOAD_ERR_EXTENSION => 'PHP 擴展阻止上傳',
];
$errCode = $_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE;
jsonResponse(false, $errorMessages[$errCode] ?? '上傳錯誤代碼: ' . $errCode);
}
$file = $_FILES['file'];
// 檢查檔案大小
if ($file['size'] > $config['max_size']) {
jsonResponse(false, '檔案大小超過限制 (' . ($config['max_size'] / 1024 / 1024) . 'MB)');
}
// 檢查 MIME 類型 (使用 finfo 而非瀏覽器提供的 type)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, $config['allowed_types'])) {
jsonResponse(false, '不允許的檔案類型: ' . $mimeType);
}
// 額外驗證:圖片完整性
$imageInfo = @getimagesize($file['tmp_name']);
if (!$imageInfo) {
jsonResponse(false, '無效的圖片檔案或圖片損壞');
}
// 生成安全檔名
$extMap = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
$extension = $extMap[$mimeType] ?? 'bin';
$filename = sprintf(
'%s_%s.%s',
date('Ymd_His'),
bin2hex(random_bytes(8)), // 16 字元隨機
$extension
);
$destination = $config['upload_dir'] . $filename;
// 移動檔案
if (!move_uploaded_file($file['tmp_name'], $destination)) {
jsonResponse(false, '檔案移動失敗,請檢查目錄權限');
}
// 設定檔案權限 (僅擁有者可讀寫)
chmod($destination, 0644);
// 成功回應
jsonResponse(true, '上傳成功', [
'filename' => $filename,
'url' => $config['url_prefix'] . $filename,
'size' => $file['size'],
'mime' => $mimeType,
'width' => $imageInfo[0],
'height' => $imageInfo[1],
]);
上傳失敗:`move_uploaded_file()` 回傳 false、或檔案無法寫入
# 1. 確認 Web 伺服器使用者
ps aux | grep -E '(apache|nginx|php-fpm)' | head -5
# 輸出範例:www-data、apache、nginx
# 2. 建立目錄並設定擁有者 (假設使用者為 www-data)
sudo mkdir -p /var/www/html/uploads
sudo chown -R www-data:www-data /var/www/html/uploads
# 3. 設定目錄權限 (755: 目錄需執行權限才能進入)
sudo chmod 755 /var/www/html/uploads
# 4. 若使用 PHP-FPM,確認 pool 設定的使用者一致
# /etc/php/8.x/fpm/pool.d/www.conf
# user = www-data
# group = www-data
# 5. 重啟服務
sudo systemctl restart php8.x-fpm
sudo systemctl restart nginx # 或 apache2
# 檢查 SELinux 狀態
sestatus
# 為上傳目錄設定正確的 context
sudo semanage fcontext -a -t httpd_sys_rw_content_t '/var/www/html/uploads(/.*)?'
sudo restorecon -Rv /var/www/html/uploads
# 或暫時關閉 SELinux (不建議生產環境)
# sudo setenforce 0
| 層面 | 措施 | 實作位置 |
|---|---|---|
| 檔案類型 | finfo MIME 驗證 + getimagesize() 完整性檢查 | upload.php |
| 檔案大小 | 客戶端 JS + 伺服器端 PHP 雙重檢查 | index.html + upload.php |
| 檔名安全 | 時間戳 + 隨機字元,不使用用戶原始檔名 | upload.php |
| 目錄遍歷 | basename()、實際路徑比對、不允許 ../ | upload.php |
| 執行權限 | 上傳目錄禁止腳本執行 | Nginx/Apache 設定 |
| 病毒掃描 | 整合 ClamAV (clamdscan) 非同步掃描 | 隊列/排程 |
| CSRF 保護 | 表單隱藏 Token + 伺服器驗證 | index.html + upload.php |
| 速率限制 | IP 限制、用戶限制、Token 桶演算法 | 中介層/Redis |
# /etc/nginx/sites-available/your-site
location /uploads/ {
# 禁止執行 PHP、Python、Perl 等腳本
location ~ \.(php|php\d+|phtml|pl|py|jsp|asp|sh|cgi)$ {
deny all;
return 403;
}
# 禁止瀏覽目錄
autoindex off;
# 快取靜態資源
expires 30d;
add_header Cache-Control "public";
}
# uploads/.htaccess
# 禁止腳本執行
Require all denied
# 禁止目錄瀏覽
Options -Indexes
# 強制 MIME 類型 (防止誤判)
ForceType application/octet-stream
ForceType none
<?php
// upload_chunk.php - 接收分片
$chunkDir = __DIR__ . '/uploads/chunks/';
$fileId = $_POST['file_id'] ?? ''; // 前端生成的唯一 ID
$chunkIndex = (int)($_POST['chunk_index'] ?? 0);
$totalChunks = (int)($_POST['total_chunks'] ?? 1);
$fileName = $_POST['file_name'] ?? '';
$fileSize = (int)($_POST['file_size'] ?? 0);
if (!$fileId || !$fileName) {
jsonResponse(false, '缺少必要參數');
}
$chunkFile = $chunkDir . $fileId . '.part' . $chunkIndex;
move_uploaded_file($_FILES['chunk']['tmp_name'], $chunkFile);
// 最後一片:合併
if ($chunkIndex === $totalChunks - 1) {
$finalPath = __DIR__ . '/uploads/' . $fileName;
$fp = fopen($finalPath, 'wb');
for ($i = 0; $i < $totalChunks; $i++) {
$part = $chunkDir . $fileId . '.part' . $i;
fwrite($fp, file_get_contents($part));
unlink($part);
}
fclose($fp);
jsonResponse(true, '合併完成', ['url' => '/uploads/' . $fileName]);
} else {
jsonResponse(true, '分片接收成功');
}
jQuery Mobile 提供優雅的行動端上傳介面,配合 PHP 安全的後端處理、正確的目錄權限設定、Nginx/Apache 執行保護,即可構建生產級的檔案上傳功能。關鍵點: