← 返回文章列表
PHP 短網址服務支援國際化 URL 完整實作指南

PHP 短網址服務支援國際化 URL 完整實作指南

前言

在開發短網址服務時,我們發現了一個有趣的現象:某些包含中文的 URL 可以正常處理,但有些卻無法通過驗證。經過深入研究,我們發現這涉及到國際化域名 (IDN)、URL 編碼、以及 PHP 函數的特定行為。本文將完整記錄我們的問題發現、分析與解決過程。

問題發現

我們在測試時發現以下奇怪的現象:

  • https://stancode.tw/stancode_%E5%A4%A7%E5%AE%89%E5%BA%97/ - 已編碼的中文路徑可以處理
  • https://stancode.tw/stancode_大安店 - 未編碼的中文路徑無法處理
  • https://例子.台灣 - 中文域名完全無法處理

這個問題引發了我們對 URL 處理機制的深入研究。

根本原因分析

1. PHP filter_var() 的限制

我們的原始 URL 驗證代碼使用了 filter_var($string, FILTER_VALIDATE_URL),但這個函數對於包含非 ASCII 字元的 URL 會直接驗證失敗。

public static function is_https_url($string) {
    if (!filter_var($string, FILTER_VALIDATE_URL)) {
        return false; // 中文 URL 在這裡就被拒絕了
    }
    // ...
}

2. 國際化域名 (IDN) 的挑戰

中文域名如 例子.台灣 需要轉換為 Punycode 格式才能在網路上使用:

例子.台灣  →  xn--fsqu00a.xn--kpry57d
測試.中國  →  xn--g6w251d.xn--fiqz9s

這個轉換需要使用 PHP 的 intl 擴展中的 idn_to_ascii() 函數。

3. parse_url() 對中文域名的破壞性

更嚴重的是,PHP 的 parse_url() 函數在處理未編碼的中文域名時會產生亂碼:

$url = 'https://例子.台灣';
$parsed = parse_url($url);
print_r($parsed);

// 輸出:
// Array ( [scheme] => https [host] => �_�__.�_��_� )

原因: parse_url() 期望接收的是已經編碼過的 URL。

解決方案設計

整體架構

我們設計了一個三層驗證機制:

原始 URL
  ↓
1. encodeUrl() - 智能編碼處理
  ↓
2. filter_var() - PHP 標準驗證
  ↓
3. isValidHost() - 自定義域名驗證
  ↓
驗證通過 ✓

核心策略

  • 先編碼再驗證: 在驗證前先將 URL 標準化
  • 域名優先處理: 使用正則提取域名,避免 parse_url() 的問題
  • IDN 轉換: 將中文域名轉換為 Punycode
  • 路徑編碼: 對 URL 路徑中的非 ASCII 字元進行 URL 編碼
  • Punycode 支援: 更新正則表達式以支援 xn-- 開頭的域名

實作細節

1. 安裝 intl 擴展

Docker 環境 (Dockerfile):

FROM php:8.4-fpm

RUN apt-get update && apt-get install -y \
    libicu-dev \
 && docker-php-ext-install intl \
 && apt-get clean && rm -rf /var/lib/apt/lists/*

Ubuntu/Debian:

sudo apt install php8.4-intl
sudo systemctl restart php8.4-fpm

2. 實作智能 URL 編碼函數

/**
 * 對 URL 中的非 ASCII 字元進行編碼
 */
private static function encodeUrl($url) {
    // 使用正則提取 URL 各部分,避免 parse_url 對中文域名的誤判
    $pattern = '/^(https?):\/\/([^\/\?#:]+)(?::(\d+))?(\/[^\?#]*)?(\?[^#]*)?(#.*)?$/u';
    
    if (preg_match($pattern, $url, $matches)) {
        $scheme = $matches[1];
        $host = $matches[2];
        $port = $matches[3] ?? '';
        $path = $matches[4] ?? '';
        $query = $matches[5] ?? '';
        $fragment = $matches[6] ?? '';
        
        // 對 host 進行 IDN 編碼
        if (function_exists('idn_to_ascii')) {
            $ascii_host = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
            if ($ascii_host) {
                $host = $ascii_host;
            }
        }
        
        // 對 path 進行編碼
        if ($path) {
            $path_parts = explode('/', $path);
            $encoded_parts = array_map(function($part) {
                // 先解碼再編碼,避免重複編碼
                return rawurlencode(rawurldecode($part));
            }, $path_parts);
            $path = implode('/', $encoded_parts);
        }
        
        // 重組 URL
        $encoded = $scheme . '://' . $host;
        if ($port) {
            $encoded .= ':' . $port;
        }
        $encoded .= $path . $query . $fragment;
        
        return $encoded;
    }
    
    return $url;
}

關鍵設計決策:

  1. 使用正則而非 parse_url(): 避免中文域名被破壞
  2. 先解碼再編碼: rawurlencode(rawurldecode($part)) 避免重複編碼
  3. 條件式 IDN 轉換: 檢查 idn_to_ascii 函數是否存在
  4. 保留原始結構: 保持 query 和 fragment 不變

3. 更新 URL 驗證函數

public static function is_https_url($string) {
    // 1. 基本長度檢查 (防止 DoS)
    if (strlen($string) > 2048) {
        return false;
    }
    
    // 2. 對 URL 進行編碼處理
    $encoded_url = self::encodeUrl($string);
    
    if (!filter_var($encoded_url, FILTER_VALIDATE_URL)) {
        return false;
    }

    $parsed_url = parse_url($encoded_url);
    
    // 3. 檢查必要組件
    if (!$parsed_url || !isset($parsed_url['scheme']) || !isset($parsed_url['host'])) {
        return false;
    }
    
    // 4. 只允許 http/https 協議 (防止 JavaScript 注入)
    $scheme = strtolower($parsed_url['scheme']);
    if ($scheme !== 'https' && $scheme !== 'http') {
        return false;
    }
    
    // 5. 防止開放重定向攻擊
    if (strpos($parsed_url['host'], '@') !== false) {
        return false;
    }
    
    // 6. 驗證 host 是否為有效的域名或 IP
    if (!self::isValidHost($parsed_url['host'])) {
        return false;
    }
    
    return true;
}

4. 實作域名驗證函數

private static function isValidHost($host) {
    // 檢查是否為有效 IP (IPv4/IPv6)
    if (filter_var($host, FILTER_VALIDATE_IP)) {
        return true;
    }
    
    // 檢查標準 ASCII 域名 (包含 Punycode)
    if (preg_match('/^([a-z0-9]+([a-z0-9-]*[a-z0-9])?\.)*[a-z0-9]+([a-z0-9-]*[a-z0-9])?$/i', $host)) {
        return true;
    }
    
    // 支援包含非 ASCII 字元的域名
    if (preg_match('/^[^\s.]+(\.[^\s.]+)+$/u', $host) && 
        !preg_match('/^\.|\.$|', $host) &&
        strlen($host) <= 253) {
        
        if (function_exists('idn_to_ascii')) {
            $ascii_host = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
            if ($ascii_host && preg_match('/^([a-z0-9]+([a-z0-9-]*[a-z0-9])?\.)*[a-z0-9]+([a-z0-9-]*[a-z0-9])?$/i', $ascii_host)) {
                return true;
            }
            return false;
        }
        
        return true; // 沒有 intl 擴展時使用寬鬆驗證
    }
    
    return false;
}

常見陷阱與解決方法

陷阱 1: parse_url() 破壞中文域名

問題: 直接使用 parse_url('https://例子.台灣') 會導致域名變成亂碼。

解決方法: 使用正則表達式先提取域名,再進行 IDN 轉換。

陷阱 2: Punycode 域名無法通過正則驗證

問題: Punycode 的 xn--fsqu00a 格式包含連字符,可能被誤判為無效。

解決方法: 更新正則允許中間包含連字符: [a-z0-9]+([a-z0-9-]*[a-z0-9])?

陷阱 3: 重複編碼問題

問題: 已編碼的 URL 被再次編碼,導致 %20 變成 %2520

解決方法: 先解碼再編碼: rawurlencode(rawurldecode($part))

陷阱 4: Docker 環境缺少 intl 擴展

診斷方法: 創建測試頁面檢查 intl 擴展是否可用。

echo extension_loaded('intl') ? 'YES ✓' : 'NO ✗';

解決方法: 在 Dockerfile 中安裝 libicu-dev 和 intl 擴展。

陷阱 5: CLI 與 Web 環境不一致

問題: CLI 測試通過,但網頁請求失敗。

原因: CLI 和 PHP-FPM 使用不同的 php.ini 配置。

解決方法: 確保兩個環境都安裝了相同的擴展。

測試與驗證

單元測試

$test_urls = [
    'https://例子.台灣',
    'https://例子.台灣/測試路徑',
    'https://stancode.tw/stancode_大安店',
    'https://google.com',
    'https://xn--fsqu00a.xn--kpry57d',
];

foreach ($test_urls as $url) {
    $result = Helper::is_https_url($url);
    echo ($result ? '[✓] ' : '[✗] ') . $url . "\n";
}

預期輸出: 所有 URL 都應顯示 [✓]

IDN 轉換測試

php -r "echo idn_to_ascii('例子.台灣', IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);"
# 輸出: xn--fsqu00a.xn--kpry57d

總結

關鍵技術要點

  • 國際化域名 (IDN): 使用 idn_to_ascii() 轉換為 Punycode
  • URL 編碼: 使用 rawurlencode() 處理路徑中的非 ASCII 字元
  • 避免 parse_url() 陷阱: 使用正則表達式先提取域名
  • 防止重複編碼: 先 rawurldecode() 再 rawurlencode()
  • 環境一致性: 確保 CLI、Web、Docker 都安裝 intl 擴展

支援的 URL 格式

  • https://例子.台灣
  • https://例子.台灣/測試/路徑
  • https://example.com/中文路徑
  • https://example.com/path?query=中文參數
  • https://xn--fsqu00a.xn--kpry57d (Punycode)
  • https://192.168.1.1/path
  • https://[2001:db8::1]/path (IPv6)

安全性提升

除了支援中文 URL,我們的實作還加入了:

  • ✅ URL 長度限制 (防止 DoS)
  • ✅ 協議白名單 (防止 JavaScript 注入)
  • ✅ 開放重定向防護 (拒絕 @ 符號)
  • ✅ 嚴格的域名驗證 (防止無效域名)

參考資料

本文記錄了我們在實作短網址服務國際化 URL 支援時的完整過程,希望能幫助其他開發者避免相同的陷阱。