Files
LittleBeast/src/Std/Lib/Curl.php

693 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Std\Lib;
/**
* php_curlへの依存を排除するための独自のCURL実装
*/
class Curl {
// リクエスト関連のプロパティ
private string $url = '';
private string $method = 'GET';
private int $timeout = 30;
private array $headers = [];
private array $cookies = [];
private array $postFields = [];
private string $postRaw = '';
private string $userAgent = 'LittleBeast/1.0';
private bool $followRedirects = true;
private int $maxRedirects = 5;
private bool $verbose = false;
private $stderr = null;
private string $caInfoPath = '';
private bool $verifySSL = true;
private string $username = '';
private string $password = '';
private string $referer = '';
// レスポンス関連のプロパティ
private array $responseHeaders = [];
private string $responseBody = '';
private int $responseCode = 0;
private string $responseError = '';
private array $info = [];
/**
* コンストラクタ
*
* @param string|null $url リクエスト先のURL
*/
public function __construct(?string $url = null) {
if (!CURL_ENABLED) return;
if ($url !== null) {
$this->url = $url;
}
}
/**
* リクエスト先のURLを設定する
*
* @param string $url リクエスト先のURL
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setUrl(string $url): Curl {
$this->url = $url;
return $this;
}
/**
* リクエストメソッドを設定する
*
* @param string $method GE又はPOST等のHTTPメソッド
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setMethod(string $method): Curl {
$this->method = strtoupper($method);
return $this;
}
/**
* リクエストのタイムアウト秒数を設定する
*
* @param int $seconds タイムアウト秒数
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setTimeout(int $seconds): Curl {
$this->timeout = (int)$seconds;
return $this;
}
/**
* リクエストヘッダーを設定する
*
* @param array $headers リクエストヘッダーの配列
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setHeaders(array $headers): Curl {
$this->headers = $headers;
return $this;
}
/**
* 単一のヘッダーを追加する
*
* @param string $name ヘッダー名
* @param mixed $value ヘッダー値
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function addHeader(string $name, mixed $value): Curl {
$this->headers[$name] = $value;
return $this;
}
/**
* リクエストのクッキーを設定する
*
* @param array $cookies クッキーの配列
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setCookies(array $cookies): Curl {
$this->cookies = $cookies;
return $this;
}
/**
* 単一のクッキーを追加する
*
* @param string $name クッキー名
* @param mixed $value クッキー値
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function addCookie(string $name, mixed $value): Curl {
$this->cookies[$name] = $value;
return $this;
}
/**
* POSTフィールドを設定する
*
* @param array $fields POSTデータの配列
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setPostFields(array $fields): Curl {
$this->postFields = $fields;
return $this;
}
/**
* 生のPOSTデータを設定する
*
* @param string $data 生のPOSTデータ
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setPostRaw(string $data): Curl {
$this->postRaw = $data;
return $this;
}
/**
* ユーザーエージェントを設定する
*
* @param string $userAgent カスタムユーザーエージェント
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setUserAgent(string $userAgent): Curl {
$this->userAgent = $userAgent;
return $this;
}
/**
* リダイレクトを追跡するかどうかを設定する
*
* @param bool $follow 追跡するかどうかデフォルトはtrue
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setFollowRedirects(bool $follow): Curl {
$this->followRedirects = (bool)$follow;
return $this;
}
/**
* 追跡するリダイレクトの最大数を設定する
*
* @param int $max リダイレクトの最大数
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setMaxRedirects(int $max): Curl {
$this->maxRedirects = (int)$max;
return $this;
}
/**
* SSL証明書を検証するかどうかを設定する
*
* @param bool $verify SSL検証を行うかどうかデフォルトはtrue
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setVerifySSL(bool $verify): Curl {
$this->verifySSL = (bool)$verify;
return $this;
}
/**
* 基本認証の資格情報を設定する
*
* @param string $username ユーザー名
* @param string $password パスワード
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setBasicAuth(string $username, string $password): Curl {
$this->username = $username;
$this->password = $password;
return $this;
}
/**
* リファラーURLを設定する
*
* @param string $referer リファラーURL
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setReferer(string $referer): Curl {
$this->referer = $referer;
return $this;
}
/**
* 詳細ログを有効にする
*
* @param bool $verbose 詳細ログを有効にするかどうか
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setVerbose(bool $verbose): Curl {
$this->verbose = (bool)$verbose;
return $this;
}
/**
* エラー出力先を設定する
*
* @param resource $handle エラー出力先のファイルハンドル
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setStderr($handle): Curl {
$this->stderr = $handle;
return $this;
}
/**
* SSL証明書のファイルパスを設定する
*
* @param string $path 証明書ファイルのパス
* @return Curl このインスタンス(メソッドチェーン用)
*/
public function setCaInfo(string $path): Curl {
$this->caInfoPath = $path;
return $this;
}
/**
* リクエストを実行する
*
* @return Result 成功または失敗
*/
public function execute(): \Result {
if (!CURL_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
if (empty($this->url)) {
$this->responseError = 'URLがありません';
return \Result::Error($this->responseError);
}
// レスポンスデータのリセット
$this->responseHeaders = [];
$this->responseBody = '';
$this->responseCode = 0;
$this->responseError = '';
$this->info = [
'url' => $this->url,
'content_type' => '',
'http_code' => 0,
'header_size' => 0,
'request_size' => 0,
'total_time' => 0,
'redirect_count' => 0,
'redirect_url' => '',
];
$startTime = microtime(true);
// ソケットベースの実装を使用する
$redirectCount = 0;
$currentUrl = $this->url;
$originalMethod = $this->method;
do {
if ($this->verbose && $this->stderr) {
fwrite($this->stderr, "* 接続中: {$currentUrl}\n");
}
$parsed = parse_url($currentUrl);
if (!$parsed) {
$this->responseError = "無効なURL: {$currentUrl}";
return \Result::Error($this->responseError);
}
$scheme = isset($parsed['scheme']) ? strtolower($parsed['scheme']) : 'http';
$host = $parsed['host'];
$port = isset($parsed['port'])
? $parsed['port'] : ($scheme === 'https' ? 443 : 80);
$path = isset($parsed['path']) ? $parsed['path'] : '/';
if (isset($parsed['query'])) {
$path .= '?'.$parsed['query'];
}
// Basic認証
$authHeader = '';
if (!empty($this->username) && !empty($this->password)) {
$authHeader = "Authorization: Basic "
.base64_encode($this->username.':'.$this->password)."\r\n";
} elseif (isset($parsed['user']) && isset($parsed['pass'])) {
$authHeader = "Authorization: Basic "
.base64_encode($parsed['user'].':'.$parsed['pass'])."\r\n";
}
// 送信するHTTPリクエストを構築
$method = $this->method;
$httpData = '';
if ($method === 'POST' || $method === 'PUT') {
if (!empty($this->postRaw)) {
$httpData = $this->postRaw;
} elseif (!empty($this->postFields)) {
$httpData = http_build_query($this->postFields);
if (!isset($this->headers['Content-Type'])) {
$this->headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
}
$this->headers['Content-Length'] = strlen($httpData);
}
// HTTPリクエストを構築
$accept = 'Accept: */*';
foreach ($this->headers as $h) {
if (str_contains($h, 'Accept:')) $accept = $h;
}
$request = "{$method} {$path} HTTP/1.1\r\n";
$request .= "Host: {$host}\r\n";
$request .= "User-Agent: {$this->userAgent}\r\n";
$request .= "{$accept}\r\n";
$request .= "Connection: close\r\n";
if (!empty($authHeader)) {
$request .= $authHeader;
}
// ヘッダーを追加
foreach ($this->headers as $name => $value) {
$request .= "{$name}: {$value}\r\n";
}
// リファラーが設定されていれば追加
if (!empty($this->referer) && !isset($this->headers['Referer'])) {
$request .= "Referer: {$this->referer}\r\n";
}
// クッキーヘッダーを追加
if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
$cookieStrings = [];
foreach ($this->cookies as $name => $value) {
$cookieStrings[] = $name.'='.urlencode($value);
}
$request .= 'Cookie: '.implode('; ', $cookieStrings)."\r\n";
}
$request .= "\r\n";
// POSTデータを追加
if ($method === 'POST' || $method === 'PUT') {
$request .= $httpData;
}
if ($this->verbose && $this->stderr) {
fwrite($this->stderr, "* リクエストヘッダー:\n{$request}\n");
}
// ソケット接続を確立
$errno = 0;
$errstr = '';
if ($scheme === 'https') {
$sslOptions = [
'verify_peer' => $this->verifySSL,
'verify_peer_name' => $this->verifySSL
];
if (!empty($this->caInfoPath) && file_exists($this->caInfoPath)) {
$sslOptions['cafile'] = $this->caInfoPath;
}
$context = stream_context_create(['ssl' => $sslOptions]);
$socket = @stream_socket_client(
"tls://{$host}:{$port}",
$errno,
$errstr,
$this->timeout,
STREAM_CLIENT_CONNECT,
$context
);
} else {
$socket = @fsockopen($host, $port, $errno, $errstr, $this->timeout);
}
if (!$socket) {
$this->responseError = "接続出来ません: {$errstr} ({$errno})";
if ($this->verbose && $this->stderr) {
fwrite($this->stderr, "* エラー: {$this->responseError}\n");
}
return \Result::Error($this->responseError);
}
// タイムアウトを設定
stream_set_timeout($socket, $this->timeout);
// リクエストを送信
fwrite($socket, $request);
// レスポンスを読み込む
$rawResponse = '';
$headers = '';
$body = '';
$headersComplete = false;
// ヘッダーとボディを分けて読み込む
while (!feof($socket)) {
$line = fgets($socket);
if ($line === false) {
break;
}
$rawResponse .= $line;
if (!$headersComplete) {
if (trim($line) === '') {
$headersComplete = true;
} else {
$headers .= $line;
}
} else {
$body .= $line;
}
}
fclose($socket);
// レスポンスヘッダーを解析
$headerLines = explode("\r\n", $headers);
// ステータスコードを取得
$statusLine = isset($headerLines[0]) ? $headerLines[0] : '';
$statusParts = explode(' ', $statusLine, 3);
$this->responseCode = isset($statusParts[1]) ? (int)$statusParts[1] : 0;
$this->info['http_code'] = $this->responseCode;
// ヘッダーを解析
$this->responseHeaders = [];
$redirectUrl = '';
foreach ($headerLines as $index => $header) {
if ($index === 0) continue;
if (strpos($header, ':') !== false) {
list($name, $value) = explode(':', $header, 2);
$name = trim($name);
$value = trim($value);
$this->responseHeaders[$name] = $value;
if (strtolower($name) === 'content-type') {
$this->info['content_type'] = $value;
}
// リダイレクトをチェック
if ($this->followRedirects &&
strtolower($name) === 'location' &&
$this->responseCode >= 300 &&
$this->responseCode < 400) {
$redirectUrl = $value;
// 相対URLを絶対URLに変換
if (strpos($redirectUrl, 'http') !== 0) {
if ($redirectUrl[0] === '/') {
$redirectUrl = "{$scheme}://{$host}"
.($port != 80 && $port != 443 ? ":{$port}" : '').$redirectUrl;
} else {
$redirectUrl = "{$scheme}://{$host}"
.($port != 80 && $port != 443 ? ":{$port}" : '')
.dirname($path).'/'.$redirectUrl;
}
}
$this->info['redirect_url'] = $redirectUrl;
}
}
}
$this->info['header_size'] += strlen($headers);
$this->responseBody .= $body;
if ($this->verbose && $this->stderr) {
fwrite($this->stderr, "* レスポンスコード: {$this->responseCode}\n");
fwrite($this->stderr, "* レスポンスヘッダー:\n{$headers}\n");
if (!empty($redirectUrl)) {
fwrite($this->stderr, "* リダイレクト先: {$redirectUrl}\n");
}
}
// リダイレクトが必要な場合
if (!empty($redirectUrl) && $redirectCount < $this->maxRedirects) {
$currentUrl = $redirectUrl;
$redirectCount++;
$this->info['redirect_count'] = $redirectCount;
// 302や303リダイレクトはGETにメソッドを変更
if ($this->responseCode == 302 || $this->responseCode == 303) {
$this->method = 'GET';
$this->postRaw = '';
$this->postFields = [];
}
} else {
break;
}
} while (true);
// リクエスト完了後、元のメソッドに戻す
$this->method = $originalMethod;
$this->info['total_time'] = microtime(true) - $startTime;
return \Result::Success();
}
/**
* レスポンスボディを取得する
*
* @return string レスポンスボディ
*/
public function getResponseBody(): string {
return $this->responseBody;
}
/**
* レスポンスヘッダーを取得する
*
* @return array レスポンスヘッダーの配列
*/
public function getResponseHeaders(): array {
return $this->responseHeaders;
}
/**
* HTTPレスポンスコードを取得する
*
* @return int HTTPレスポンスコード
*/
public function getResponseCode(): int {
return $this->responseCode;
}
/**
* エラーメッセージがあれば取得する
*
* @return string エラーメッセージ
*/
public function getError(): string {
return $this->responseError;
}
/**
* リクエスト/レスポンス情報を取得する
*
* @return array 情報の配列
*/
public function getInfo(): array {
return $this->info;
}
// 機能性メソッド
/**
* リダイレクトURLを確認する
*
* @param string $name ヘッダー名
* @param string $value ヘッダー値
* @param string $currentUrl 現在のURL
* @return string リダイレクトURL、リダイレクトがない場合は空文字
*/
private function checkReds(string $name, string $value, string $currentUrl): string {
$redirectUrl = '';
if ($this->followRedirects && (strtolower($name) === 'location'
&& $this->responseCode >= 300 && $this->responseCode < 400)) {
$redirectUrl = $value;
if (strpos($redirectUrl, 'http') !== 0) {
if ($redirectUrl[0] === '/') {
$parsed = parse_url($currentUrl);
$redirectUrl = $parsed['scheme'].'://'.$parsed['host']
.(isset($parsed['port']) ? ':'.$parsed['port'] : '')
.$redirectUrl;
} else {
$redirectUrl = dirname($currentUrl).'/'.$redirectUrl;
}
}
}
return $redirectUrl;
}
/**
* ヘッダー文字列を構築する
*
* @return string 構築されたヘッダー文字列
*/
private function buildHeaderString(): string {
$headers = [];
// ユーザー指定のヘッダーを追加
foreach ($this->headers as $name => $value) {
$headers[] = "{$name}: {$value}";
}
// リファラーが設定されていれば追加
if (!empty($this->referer) && !isset($this->headers['Referer'])) {
$headers[] = "Referer: {$this->referer}";
}
// 必要に応じてクッキーヘッダーを追加
if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
$cookieStrings = [];
foreach ($this->cookies as $name => $value) {
$cookieStrings[] = $name.'='.urlencode($value);
}
$headers[] = 'Cookie: '.implode('; ', $cookieStrings);
}
return implode("\r\n", $headers)."\r\n";
}
/**
* レスポンスを解析してヘッダーとボディに分割する
*
* @param string $response 完全なHTTPレスポンス
* @return array [ヘッダー配列, ボディ文字列]
*/
private function parseResponse(string $response): array {
$parts = explode("\r\n\r\n", $response, 2);
if (count($parts) < 2) {
return [[], ''];
}
$headers = explode("\r\n", $parts[0]);
$body = $parts[1];
// チャンク転送エンコーディングを処理
if (isset($this->responseHeaders['Transfer-Encoding']) &&
strtolower($this->responseHeaders['Transfer-Encoding']) === 'chunked') {
$body = $this->decodeChunkedBody($body);
}
return [$headers, $body];
}
/**
* チャンク転送エンコーディングされたボディをデコードする
*
* @param string $body チャンクエンコードされたボディ
* @return string デコードされたボディ
*/
private function decodeChunkedBody(string $body): string {
$decodedBody = '';
$position = 0;
while ($position < strlen($body)) {
$lineEnd = strpos($body, "\r\n", $position);
if ($lineEnd === false) {
break;
}
$chunkSize = hexdec(substr($body, $position, $lineEnd - $position));
if ($chunkSize === 0) {
break;
}
$position = $lineEnd + 2;
$decodedBody .= substr($body, $position, $chunkSize);
$position += $chunkSize + 2; // チャンクサイズ + CRLF
}
return $decodedBody;
}
}