ファイル配信システム詳細 - ha1t/php-gm-server GitHub Wiki

ファイル配信システム詳細

このページは、PHP GM Server のファイル配信システムの実装詳細を説明します。クライアントからのリクエストを受け取ってからファイルをレスポンスとして送信するまでの完全なフロー、MIME タイプの自動判定、ディレクトリ解決メカニズム、セキュリティ対策を網羅しています。GeminiServerコンポーネントStaticFileHandlerコンポーネントの詳細な動作仕様の補足として機能します。

ファイル配信フロー

ファイル配信システムは、GeminiServer のリクエストハンドラーから StaticFileHandler へのリクエスト処理、返されたファイル情報に基づく ResponseBuilder でのレスポンス構築という 2 段階で実現されます。

リクエスト受け取りからファイル配信までの流れ

以下の流れでファイルが配信されます:

sequenceDiagram
    participant Client as クライアント
    participant Server as GeminiServer
    participant Parser as RequestParser
    participant Handler as StaticFileHandler
    participant Builder as ResponseBuilder

    Client->>Server: TLS 接続確立
    Client->>Server: リクエスト送信<br/>(gemini://host/path\r\n)
    Server->>Server: バッファに蓄積
    Server->>Server: \r\n 検出
    Server->>Parser: parse(buffer)
    Parser-->>Server: {host, path}
    Server->>Handler: handle(path)
    Handler-->>Server: {mime, body} or null
    alt ファイル存在
        Server->>Builder: success(mime, body)
        Builder-->>Server: "20 mime\r\nBODY"
    else ファイル不在
        Server->>Builder: notFound()
        Builder-->>Server: "51 Not found\r\n"
    end
    Server-->>Client: Gemini レスポンス
    Server->>Server: 接続終了
Loading

Sources: php-gm-server/src/GeminiServer.php:44-82

StaticFileHandler の内部処理

StaticFileHandler.handle() メソッドは、以下の処理順序でファイルを解決します:

flowchart TD
    A["handle(path) 呼び出し"] --> B["ドキュメントルート + path<br/>を結合"]
    B --> C{"ディレクトリ?"}
    C -->|はい| D["末尾の / を削除<br/>+ /index.gmi 追加"]
    C -->|いいえ| E["ファイルパス確定"]
    D --> E
    E --> F["realpath() で正規化<br/>&シンボリックリンク解決"]
    F --> G{"realpath に成功?"}
    G -->|失敗| H["null を返却<br/>ファイル不在"]
    G -->|成功| I{"パストラバーサル<br/>チェック"}
    I -->|ドキュメント<br/>ルート外| H
    I -->|OK| J{"ファイル存在?"}
    J -->|いいえ| H
    J -->|はい| K["ファイル内容読み込み<br/>file_get_contents()"]
    K --> L{"読み込み成功?"}
    L -->|失敗| H
    L -->|成功| M["拡張子から<br/>MIME タイプ判定"]
    M --> N["MIME_MAP で検索"]
    N --> O{"マップに存在?"}
    O -->|はい| P["マッピング済み MIME"]
    O -->|いいえ| Q["application/octet-stream"]
    P --> R["{mime, body} を返却"]
    Q --> R
Loading

Sources: php-gm-server/src/StaticFileHandler.php:35-68

サポート MIME タイプ一覧

StaticFileHandler は拡張子とMIMEタイプのマッピングテーブル(MIME_MAP)を内部で保持します。以下の拡張子に対してはマッピングが定義されており、それ以外の拡張子ではデフォルトの application/octet-stream が使用されます。

拡張子 MIME タイプ 説明
.gmi, .gemini text/gemini Gemini テキスト形式
.txt text/plain プレーンテキスト
.html text/html HTML ドキュメント
.css text/css CSS スタイルシート
.js text/javascript JavaScript
.json application/json JSON データ
.png image/png PNG 画像
.jpg, .jpeg image/jpeg JPEG 画像
.gif image/gif GIF 画像
.svg image/svg+xml SVG 画像
.pdf application/pdf PDF ドキュメント

Sources: php-gm-server/src/StaticFileHandler.php:11-25

MIME タイプ判定の実装

$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
$mime = self::MIME_MAP[$ext] ?? 'application/octet-stream';

拡張子は小文字に正規化され、MIME_MAP で検索されます。Sources: php-gm-server/src/StaticFileHandler.php:61-62

ディレクトリアクセス時の index.gmi 自動解決

クライアントがディレクトリパスにアクセスした場合(例:/about//)、そのディレクトリ内に存在する index.gmi ファイルが自動的に配信されます。

動作メカニズム

StaticFileHandler.handle() は、渡されたパスがディレクトリかどうかを is_dir() で判定し、ディレクトリの場合は以下の処理を実施します:

if (is_dir($filePath)) {
    $filePath = rtrim($filePath, '/') . '/index.gmi';
}

処理順序:

  1. ドキュメントルートとリクエストパスを結合
  2. is_dir() でディレクトリ判定
  3. ディレクトリなら末尾の / を削除後に /index.gmi を追加
  4. 通常のファイル解決フローに進行

Sources: php-gm-server/src/StaticFileHandler.php:39-41

使用例

リクエストパス 配信ファイル MIME タイプ
/ DOCROOT/index.gmi text/gemini
/about/ DOCROOT/about/index.gmi text/gemini
/sub/ DOCROOT/sub/index.gmi text/gemini

index.gmi が存在しない場合、ファイル不在(ステータスコード 51)として処理されます。

Sources: php-gm-server/tests/StaticFileHandlerTest.php:48-61

デフォルト MIME タイプ

マッピングテーブルに含まれない拡張子のファイルに対しては、デフォルト MIME タイプ application/octet-stream が適用されます。これは、MIME タイプが不明なバイナリデータを示すタイプであり、クライアント側でのダウンロード処理を促す意図があります。

$mime = self::MIME_MAP[$ext] ?? 'application/octet-stream';

例えば、.exe.zip.bin など、MIME_MAP に未登録の拡張子を持つファイルはすべて application/octet-stream として配信されます。

Sources: php-gm-server/src/StaticFileHandler.php:62

ファイル不在時の処理

ファイル不在または読み込み失敗時は、StaticFileHandler.handle() は null を返却します。この null を受け取った GeminiServer は、ResponseBuilder.notFound() でステータスコード 51 のレスポンスを生成します。

ファイル不在と判定される条件

以下のいずれかの条件に該当した場合、ファイル不在として処理されます:

  1. realpath() 失敗:ファイルまたはディレクトリが存在しない場合
  2. パストラバーサル検出:解決後のパスがドキュメントルート外にある場合
  3. 非ファイルエスケープ:ディレクトリ、シンボリックリンク、その他の特殊ファイルの場合
  4. ファイル読み込み失敗:ファイルが存在しても読み込み権限がない、または読み込み中にエラーが発生した場合

パストラバーサル対策

StaticFileHandler は、realpath() で正規化したパスが、ドキュメントルート内に含まれているかを厳密にチェックします:

$realPath = realpath($filePath);
if ($realPath === false) {
    return null;
}

if ($realPath !== $this->docRoot && !str_starts_with($realPath, $this->docRoot . DIRECTORY_SEPARATOR)) {
    return null;
}

この実装により、/../../../etc/passwd のようなパストラバーサル攻撃は確実に防止されます。

Sources: php-gm-server/src/StaticFileHandler.php:43-50

レスポンス形式

ファイル不在時は、以下の形式でレスポンスが返却されます:

51 Not found

Sources: php-gm-server/src/ResponseBuilder.php:14-16

エラー時の接続処理

ファイル不在またはその他のエラーが発生した場合、GeminiServer は適切なステータスコードでレスポンスを送信した直後に、接続(ConnectionInterface)を終了します。

if ($result === null) {
    $conn->write(ResponseBuilder::notFound());
    $conn->end();
    return;
}

Sources: php-gm-server/src/GeminiServer.php:74-78

リクエストバッファのサイズ制限

Gemini プロトコルの仕様により、リクエスト行は最大 1024 バイトに制限されています。この制限を超えるリクエストは、ステータスコード 59(Bad Request)で拒否されます。

if (strlen($buffer) > 1024) {
    $conn->write(ResponseBuilder::badRequest('Request too long'));
    $conn->end();
}

Sources: php-gm-server/src/GeminiServer.php:55-58

実装例:デフォルトドキュメントルート

サーバー起動時に GEMINI_DOC_ROOT 環境変数が指定されない場合、デフォルトドキュメントルートは __DIR__ . '/../content' に設定されます。この content ディレクトリ内には、index.gmi などのコンテンツが配置されます。

$docRoot = getenv('GEMINI_DOC_ROOT') ?: __DIR__ . '/../content';
$fileHandler = new StaticFileHandler($docRoot);

Sources: php-gm-server/bin/server.php:13, 21

Related Pages

⚠️ **GitHub.com Fallback** ⚠️