StaticFileHandlerコンポーネント - ha1t/php-gm-server GitHub Wiki
StaticFileHandler クラスは、Gemini プロトコルサーバーにおいて、ドキュメントルートからのファイル配信を担当するコンポーネントです。リクエストされたパスに対応するファイルの解決、ディレクトリハンドリング、MIME タイプの自動判定、およびパストラバーサル攻撃の防止機構を実装しています。アーキテクチャ設計で示される5つのコアコンポーネントの一つとして、GeminiServerコンポーネントからの要求に応じてファイル配信を処理します。
StaticFileHandler は GeminiServer\StaticFileHandler クラスとして実装され、次の責務を持ちます。
- ドキュメントルート(設定可能なディレクトリパス)の管理
- リクエストパスからの物理ファイル解決
- ディレクトリアクセス時の自動的な
index.gmiファイル返却 - ファイル拡張子に基づく自動 MIME タイプ判定
-
realpath()による正規化とパストラバーサル攻撃防止
StaticFileHandler は単一責任の原則に従い、ファイルシステムへのアクセスとファイル情報の取得に専念します。他のコンポーネント(RequestParserコンポーネントによるリクエスト解析、ResponseBuilderコンポーネントによるレスポンス構築)との統合は、GeminiServerコンポーネントで処理されます。
class StaticFileHandler
{
private string $docRoot;
private const MIME_MAP = [ ... ];
public function __construct(string $docRoot)
{
$this->docRoot = realpath($docRoot) ?: $docRoot;
}
public function handle(string $path): ?array
{
// ファイル解決ロジック
}
}コンストラクタで渡されたドキュメントルートパスは、realpath() 関数で正規化されます。このプロセスにより、シンボリックリンクが解決され、相対パスが絶対パスに変換されます。
public function __construct(string $docRoot)
{
$this->docRoot = realpath($docRoot) ?: $docRoot;
}realpath() が失敗した場合(ディレクトリが存在しない場合)は、渡されたパスがそのまま保持されます。これにより、初期化時の厳密なエラーハンドリングを避け、実際のファイルアクセス時にエラーが検出される仕様になっています。
出典: src/StaticFileHandler.php:27-30
StaticFileHandler の handle() メソッドは、与えられたパスに対応するファイルを解決します。
flowchart TD
A[リクエストパス受信<br/>例: /about.gmi] -->|docRoot結合| B[物理パス構築<br/>docRoot + path]
B -->|is_dir チェック| C{ディレクトリ<br/>か?}
C -->|Yes| D[index.gmi を追加<br/>path/index.gmi]
C -->|No| E[パス確定]
D --> E
E -->|realpath実行| F[正規化パス取得]
F -->|パス検証| G{パストラバーサル<br/>チェック}
G -->|不正| H[null 返却<br/>アクセス拒否]
G -->|正当| I{ファイル<br/>存在?}
I -->|No| H
I -->|Yes| J[ファイル読み込み]
J -->|MIME判定| K[拡張子からMIME取得]
K --> L[mime と body を返却<br/>配列形式]
$filePath = $this->docRoot . $path;ドキュメントルートとリクエストパスを連結して、物理的なファイルパスを構築します。
if (is_dir($filePath)) {
$filePath = rtrim($filePath, '/') . '/index.gmi';
}パスがディレクトリの場合、末尾のスラッシュを除去した後、/index.gmi を追加します。これにより、/sub/ へのアクセスは自動的に /sub/index.gmi に解決されます。
例:
- リクエスト:
/→{docRoot}/index.gmi - リクエスト:
/sub/→{docRoot}/sub/index.gmi - リクエスト:
/about.gmi→{docRoot}/about.gmi(変化なし)
$realPath = realpath($filePath);
if ($realPath === false) {
return null;
}
if ($realPath !== $this->docRoot && !str_starts_with($realPath, $this->docRoot . DIRECTORY_SEPARATOR)) {
return null;
}このステップはセキュリティ設計の中核です:
-
realpath() による正規化:
realpath()関数はシンボリックリンクを解決し、相対パス参照(..)を展開して、物理的な絶対パスを取得します。存在しないパスの場合はfalseを返します。 -
ドキュメントルート内チェック: 正規化されたパスが:
- ドキュメントルート自体と等しい、または
- ドキュメントルート直下のディレクトリ/ファイルであることを確認
パストラバーサル攻撃(例: /../../../etc/passwd)は、realpath() の段階で存在しないパスとして扱われ、またはルートチェックで拒否されます。
攻撃の例と防止:
リクエスト: /../../../etc/passwd
パス構築: {docRoot}/../../../etc/passwd
realpath結果: /etc/passwd
チェック: /etc/passwd が docRoot 下か? → 否 → null返却
if (!is_file($realPath)) {
return null;
}
$body = file_get_contents($realPath);
if ($body === false) {
return null;
}ファイルが実際に存在し、読み込み可能か確認します。ディレクトリやシンボリックリンク、アクセス権限がない場合は null が返却されます。
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
$mime = self::MIME_MAP[$ext] ?? 'application/octet-stream';
return [
'mime' => $mime,
'body' => $body,
];ファイル拡張子(小文字に正規化)から MIME_MAP を参照して MIME タイプを決定します。マッピングにない拡張子の場合は、デフォルト値 application/octet-stream を使用します。
出典: src/StaticFileHandler.php:35-68
StaticFileHandler は、ファイル拡張子から MIME タイプへのマッピングを定数 MIME_MAP として保持します。
private const MIME_MAP = [
'gmi' => 'text/gemini',
'gemini' => 'text/gemini',
'txt' => 'text/plain',
'html' => 'text/html',
'css' => 'text/css',
'js' => 'text/javascript',
'json' => 'application/json',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'pdf' => 'application/pdf',
];| 拡張子 | 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 ドキュメント |
| (その他) | application/octet-stream |
デフォルト(バイナリデータ) |
マッピングに存在しない拡張子のファイルは、デフォルト値 application/octet-stream で返却されます。これは、ファイルのバイナリデータとしてのダウンロードを意味します。
出典: src/StaticFileHandler.php:11-25
StaticFileHandler は GeminiServerコンポーネントから呼び出されます。統合フローは以下の通りです:
sequenceDiagram
participant Client
participant GeminiServer
participant RequestParser
participant StaticFileHandler
participant ResponseBuilder
Client->>GeminiServer: TLS接続、リクエスト送信
GeminiServer->>RequestParser: raw buffer を parse
RequestParser-->>GeminiServer: {host, path} 返却
GeminiServer->>StaticFileHandler: path を handle()
alt ファイル見つかる
StaticFileHandler-->>GeminiServer: {mime, body}
GeminiServer->>ResponseBuilder: success(mime, body)
ResponseBuilder-->>GeminiServer: "20 mime\r\nbody"
else ファイル見つからない
StaticFileHandler-->>GeminiServer: null
GeminiServer->>ResponseBuilder: notFound()
ResponseBuilder-->>GeminiServer: "51 Not found\r\n"
end
GeminiServer->>Client: レスポンス返却
初期化例 (bin/server.php):
$docRoot = getenv('GEMINI_DOC_ROOT') ?: __DIR__ . '/../content';
$fileHandler = new StaticFileHandler($docRoot);
$server = new GeminiServer($host, $port, $fileHandler, $certPath);コマンドラインから実行時に GEMINI_DOC_ROOT 環境変数でドキュメントルートを指定できます。詳細は環境変数設定リファレンスを参照してください。
出典: bin/server.php:13, 21
StaticFileHandler の核となるセキュリティ機構は、realpath() とドキュメントルート検証の組み合わせです:
$realPath = realpath($filePath);
if ($realPath === false) {
return null;
}
if ($realPath !== $this->docRoot && !str_starts_with($realPath, $this->docRoot . DIRECTORY_SEPARATOR)) {
return null;
}この設計により:
-
シンボリックリンク解決:
/content/linkが/etc/passwdへのシンボリックリンクでも、realpath()は/etc/passwdとして解決 -
相対パス展開:
/../../../etc/passwdは/etc/passwdに展開される - ドキュメントルート検証: 最終パスがドキュメントルート配下であることを確認
この多層防御により、サーバーメモリ上のドキュメントルート外へのアクセスが完全に防止されます。
詳細はセキュリティ設計を参照してください。
realpath() は存在しないパスに対して false を返します。これにより、ファイルアクセス前の段階で不正なパスが除外されます。
if (is_dir($filePath)) {
$filePath = rtrim($filePath, '/') . '/index.gmi';
}
$realPath = realpath($filePath); // ファイル存在チェック
if ($realPath === false) {
return null;
}例えば、/nonexistent.gmi をリクエストしても、realpath() は失敗し、null が返却されます。
出典: src/StaticFileHandler.php:43-46, tests/StaticFileHandlerTest.php:70-75
StaticFileHandler の動作は StaticFileHandlerTest で検証されます。主要なテストケースは以下の通りです:
public function testServeExistingFile(): void
{
$handler = new StaticFileHandler($this->docRoot);
$result = $handler->handle('/about.gmi');
$this->assertSame('text/gemini', $result['mime']);
$this->assertSame('# About', $result['body']);
}
public function testServeIndexForDirectory(): void
{
$handler = new StaticFileHandler($this->docRoot);
$result = $handler->handle('/');
$this->assertSame('text/gemini', $result['mime']);
$this->assertSame('# Welcome', $result['body']);
}
public function testPathTraversalBlocked(): void
{
$handler = new StaticFileHandler($this->docRoot);
$result = $handler->handle('/../../../etc/passwd');
$this->assertNull($result);
}| テスト名 | 検証内容 |
|---|---|
testServeExistingFile |
通常のファイル配信と MIME タイプ判定 |
testServeIndexForDirectory |
ルートディレクトリアクセス時の index.gmi 返却 |
testServeSubDirectoryIndex |
サブディレクトリへのアクセスと index.gmi 自動解決 |
testNonGmiMimeType |
.png など .gmi 以外のファイルの MIME タイプ判定 |
testFileNotFound |
存在しないファイルへのアクセス(null 返却) |
testPathTraversalBlocked |
パストラバーサル攻撃の防止 |
出典: tests/StaticFileHandlerTest.php:40-82
handle() メソッドは 2 種類の戻り値を返します:
array{
'mime' => string, // MIME タイプ
'body' => string // ファイル内容
}null失敗する条件:
- ファイルが存在しない
- ディレクトリ内に
index.gmiがない - パストラバーサル検証に失敗
- ファイル読み込みに失敗
- ファイルアクセス権限がない
出典: src/StaticFileHandler.php:33-34, 44-59
graph TD
A["リクエストパス<br/>例: /sub/doc.gmi"] -->|handle呼び出し| B["ドキュメントルート結合<br/>docRoot/sub/doc.gmi"]
B -->|ディレクトリ判定| C{ディレクトリ<br/>存在?}
C -->|Yes| D["index.gmi追加<br/>docRoot/sub/index.gmi"]
C -->|No| E["そのまま進行"]
D --> F["realpath正規化"]
E --> F
F -->|シンボリックリンク解決<br/>相対パス展開| G["正規化パス取得"]
G -->|docRoot外チェック| H{正当なパス<br/>か?}
H -->|No| I["null返却"]
H -->|Yes| J{ファイル<br/>存在?}
J -->|No| I
J -->|Yes| K["ファイル読み込み<br/>file_get_contents"]
K -->|成功| L["拡張子抽出"]
K -->|失敗| I
L -->|MIME_MAP参照| M["MIME タイプ決定"]
M --> N["配列構築<br/>mime + body"]
N -->|返却| O["GeminiServer へ"]
I -->|返却| O
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
$mime = self::MIME_MAP[$ext] ?? 'application/octet-stream';pathinfo() の第2引数 PATHINFO_EXTENSION はファイルの拡張子のみを返します。複数のドット(例: file.tar.gz)がある場合、最後のドットより後ろ部分(gz)が返されます。
拡張子は strtolower() で小文字に正規化されるため、FILE.GMI、File.Gmi、file.gmi はすべて同じ MIME タイプにマッピングされます。
StaticFileHandler は、エラー条件をすべて null で表現します:
if ($realPath === false) { return null; }
if (!is_file($realPath)) { return null; }
if ($body === false) { return null; }この設計により、呼び出し元(GeminiServer)は単純な if ($result === null) チェックで全てのエラー条件を統一的に処理できます。これは ファイル配信システム詳細での 51(Not Found)エラーレスポンスの返却につながります。
出典: src/StaticFileHandler.php:44-59, src/GeminiServer.php:72-78
StaticFileHandler はサーバー起動時に、環境変数 GEMINI_DOC_ROOT で指定されたディレクトリから初期化されます:
$docRoot = getenv('GEMINI_DOC_ROOT') ?: __DIR__ . '/../content';
$fileHandler = new StaticFileHandler($docRoot);デフォルトは {project_root}/content ディレクトリです。環境変数設定リファレンスで詳細が説明されています。
ディレクトリ構成についてはディレクトリ構成と規約を参照してください。
出典: bin/server.php:13
- アーキテクチャ設計 — システムコンポーネント構成と統合フロー
- GeminiServerコンポーネント — StaticFileHandler を利用するメインサーバー実装
- RequestParserコンポーネント — リクエスト解析処理
- ResponseBuilderコンポーネント — レスポンス構築処理
- ファイル配信システム詳細 — ファイル配信フロー全体とMIMEタイプリスト
- セキュリティ設計 — パストラバーサル防止とセキュリティ考慮事項
- 環境変数設定リファレンス — GEMINI_DOC_ROOT 他の環境変数
- ディレクトリ構成と規約 — プロジェクトディレクトリ構成
- テスト戦略と実装 — PHPUnit によるテスト実装