首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 綜合知識

jQuery Mobile + PHP 檔案上傳完整實作:前端 UI、後端處理、權限解決與安全強化

     編輯:PHP 綜合知識

專案架構

  • 前端:jQuery Mobile 1.4+ (RWD 行動優先框架)
  • 後端:PHP 7.4+ / 8.x
  • 核心機制:`$_FILES` 超全域變數 + `move_uploaded_file()`

前端頁面 (index.html)

<!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>

後端處理 (upload.php)

<?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、或檔案無法寫入

根本原因

  • 上傳目錄不存在
  • 目錄擁有者不是 Web 伺服器使用者 (www-data, apache, nginx)
  • 目錄權限不足 (需寫入權限)
  • SELinux/AppArmor 限制
  • PHP `open_basedir` 限制

Linux 環境修正步驟

# 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 環境 (CentOS/RHEL/Fedora)

# 檢查 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

共享主機/虛擬主機環境

  • 透過 FTP/檔案管理器建立 `uploads` 資料夾
  • 設定權限為 755 (目錄) / 644 (檔案)
  • 若仍失敗,聯繫主機商確認 `open_basedir`、`disable_functions` 限制

安全強化清單

層面措施實作位置
檔案類型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

Nginx 禁止上傳目錄執行腳本

# /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";
}

Apache .htaccess 保護

# 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 執行保護,即可構建生產級的檔案上傳功能。關鍵點:

  1. 前端:`
    ` + `FormData` + AJAX 進度條
  2. 後端:`finode` 驗證 + `move_uploaded_file` + 隨機檔名 + JSON 回應
  3. 權限:Web 使用者擁有目錄、755 目錄權限、SELinux context
  4. 安全:禁止上傳目錄執行腳本、MIME 白名單、大小限制、CSRF 保護
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved