SVNからのミラー

This commit is contained in:
2025-11-07 22:48:07 +09:00
commit 438c7d8aef
68 changed files with 7619 additions and 0 deletions

View File

@@ -0,0 +1,558 @@
<?php
namespace Site\Lib;
use Site\Lib\Curl;
/**
* ActivityPubプロトコルの実装クラス
*
* このクラスはActivityPubプロトコルを利用して分散型
* ソーシャルネットワーキングを実装します。
*/
class ActivityPub {
private string $domain;
private string $actor;
private string $actorNick;
private string $desc;
private string $icon;
private array $posts = [];
/**
* コンストラクタ
*
* @param array $posts 投稿データの配列
*/
public function __construct(array $posts = []) {
$this->domain = $_SERVER['SERVER_NAME'];
$this->actor = FEDIINFO['actor'];
$this->actorNick = FEDIINFO['actorNick'];
$this->desc = FEDIINFO['desc'];
$this->icon = "https://{$this->domain}".FEDIINFO['icon'];
$this->posts = $posts;
}
/**
* ActivityPubアクタープロフィールを受け取る
*
* @return string アクターオブジェクト
* @throws \Exception 公開鍵の読み込みに失敗した場合
*/
public function getActor(): string {
$pubkey = file_get_contents(FEDIINFO['pubkey']);
if ($pubkey === false) {
throw new \Exception('公開鍵の受取に失敗。パス:'.FEDIINFO['pubkey']);
}
$actor = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/actor",
'name' => $this->actorNick,
'summary' => $this->desc,
'manuallyApprovesFollowers' => false,
'icon' => [
'type' => 'Image',
'mediaType' => 'image/png',
'url' => $this->icon,
],
'image' => [
'type' => 'Image',
'url' =>
"https://{$this->domain}/static/article/o_53803618dc1691.28179609.jpg",
'mediaType' => 'image/jpeg',
],
'type' => 'Person',
'url' => "https://{$this->domain}",
'preferredUsername' => $this->actor,
'inbox' => "https://{$this->domain}/ap/inbox",
'outbox' => "https://{$this->domain}/ap/outbox",
'followers' => "https://{$this->domain}/ap/followers",
'following' => "https://{$this->domain}/ap/following",
'published' => '2025-03-28T18:00:00Z',
'updated' => gmdate('c'),
'publicKey' => [
'id' => "https://{$this->domain}/ap/actor#main-key",
'owner' => "https://{$this->domain}/ap/actor",
'publicKeyPem' => $pubkey,
],
];
return json_encode($actor, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* 特定のUUIDに対応するActivityを取得する
*
* @param string $uuid 取得するアクティビティのUUID
* @return string JSONエンコードされたアクティビティデータ
*/
public function getActivity(string $uuid): string {
$items = [];
foreach ($this->posts as $post) {
if ($post['uuid'] != $uuid) continue;
$items = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'Emoji' => 'toot:Emoji',
'EmojiReact' => 'litepub:EmojiReact',
'Hashtag' => 'as:Hashtag',
'litepub' => 'http://litepub.social/ns#',
'sensitive' => 'as:sensitive',
'toot' => 'http://joinmastodon.org/ns#',
],
],
'id' => "https://{$this->domain}/ap/activities/create/{$post['uuid']}",
'type' => 'Create',
'actor' => "https://{$this->domain}/ap/actor",
'cc' => [
"https://{$this->domain}/ap/followers",
],
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'object' => [
'id' => "https://{$this->domain}/ap/objects/{$post['uuid']}",
'type' => 'Note',
'name' => $post['title'],
'attributedTo' => "https://{$this->domain}/ap/actor",
'cc' => [
"https://{$this->domain}/ap/followers",
],
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'content' =>
$post['preview']."<br /><br /><a href=\"https://{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
'url' => "https://{$this->domain}/blog/{$post['slug']}",
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
'replies' => "https://{$this->domain}/ap/objects/{$uuid}/replies",
'sensitive' => false,
],
];
if (isset($post['category']) && !empty($post['category'])) {
$item['tag'] = [];
foreach ($post['category'] as $cat) {
$items['tag'][] = $cat;
}
}
if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
$imgurl = "https://technicalsuwako.moe/static/article/{$post['thumbnail']}";
$imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
$imgraw = file_get_contents($imgpath);
$items['attachment'] = [
[
'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
'mediaType' => mime_content_type($imgpath),
'type' => "Image",
'url' => $imgurl,
],
];
}
break;
}
return json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* アウトボックスデータを取得する
*
* @return string JSONエンコードされたアウトボックスデータ
*/
public function getOutbox(): string {
$items = [];
$counter = 0;
foreach ($this->posts as $post) {
$uid = $post['uuid'];
$items[] = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'Emoji' => 'toot:Emoji',
'EmojiReact' => 'litepub:EmojiReact',
'Hashtag' => 'as:Hashtag',
'litepub' => 'http://litepub.social/ns#',
'sensitive' => 'as:sensitive',
'toot' => 'http://joinmastodon.org/ns#',
],
],
'id' => "https://{$this->domain}/ap/activities/create/{$uid}",
'type' => 'Create',
'actor' => "https://{$this->domain}/ap/actor",
'cc' => [
"https://{$this->domain}/ap/followers",
],
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'object' => [
'id' => "https://{$this->domain}/ap/objects/{$uid}",
'type' => 'Note',
'name' => $post['title'],
'attributedTo' => "https://{$this->domain}/ap/actor",
'cc' => [
"https://{$this->domain}/ap/followers",
],
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'content' =>
$post['preview']."<br /><br /><a href=\"https://{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
'url' => "https://{$this->domain}/blog/{$post['slug']}",
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
'replies' => "https://{$this->domain}/ap/objects/{$uid}/replies",
'sensitive' => false,
],
];
if (isset($post['category']) && !empty($post['category'])) {
$items[$counter]['tag'] = [];
foreach ($post['category'] as $cat) {
$items[$counter]['tag'][] = $cat;
}
}
if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
$imgurl = "https://technicalsuwako.moe/static/article/{$post['thumbnail']}";
$imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
$imgraw = file_get_contents($imgpath);
$items[$counter]['attachment'] = [
[
'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
'mediaType' => mime_content_type($imgpath),
'type' => "Image",
'url' => $imgurl,
],
];
}
$counter++;
}
$outbox = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/outbox",
'type' => 'OrderedCollection',
'totalItems' => count($items),
'orderedItems' => $items,
];
return json_encode($outbox, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* WebFingerデータを取得する
*
* @return string JSONエンコードされたWebFingerデータ
*/
public function getWebfinger(): string {
$webfinger = [
'subject' => "acct:{$this->actor}@{$this->domain}",
'links' => [
[
'rel' => 'self',
'type' => 'application/activity+json',
'href' => "https://{$this->domain}/ap/actor",
],
],
];
return json_encode($webfinger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* フォロワーのリストを取得する
*
* @return string JSONエンコードされたフォロワーのリスト
*/
public function getFollowers(): string {
$f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
$followers = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/followers",
'type' => 'OrderredCollection',
'totalItems' => count($f),
'orderedItems' => $f,
];
return json_encode($followers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* フォローしているアカウントのリストを取得する
*
* @return string JSONエンコードされたフォローリスト
*/
public function getFollowing(): string {
$f = array_filter(explode("\n", file_get_contents(ROOT.'/data/following.txt')));
$following = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/following",
'type' => 'OrderredCollection',
'totalItems' => count($f),
'orderedItems' => $f,
];
return json_encode($following, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* インボックスにアクティビティを投稿する
*
* @param array $activity 処理するアクティビティデータ
* @return void
*/
public function postInbox(array $activity): void {
switch ($activity['type']) {
case 'Follow':
$this->acceptFollower($activity);
break;
default:
header('HTTP/1.1 501 Not Implemented');
header('Content-Type: application/activity+json');
echo json_encode(['error' =>
'未対応なアクティビティタイプ: '.$activity['type']]);
exit;
}
header('HTTP/1.1 200 OK');
header('Content-Type: application/activity+json');
echo json_encode(['status' => 'OK'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* アクタープロフィールの更新アクティビティを作成する
*
* @return string JSONエンコードされた更新アクティビティ
*/
public function update(): string {
$update = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/activities/update/".uuid(),
'type' => 'Update',
'actor' => "https://{$this->domain}/ap/actor",
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'object' => json_decode($this->getActor(), true),
];
return json_encode($update, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* アクター更新をフォロワーに送信する
*
* @param array $params パラメータ配列
* @return void
*/
public function sendActorUpdate(array $params): void {
$f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
$ap = new Activitypub();
$inboxes = implode("\n", $f);
$update = json_decode($ap->update(), true);
foreach ($f as $inbox) {
$this->sendActivity($inbox, $update);
}
}
// 機能性メソッド
/**
* 指定されたインボックスURLにアクティビティを送信する
*
* @param string $inboxUrl 送信先のインボックスURL
* @param array $activity 送信するアクティビティデータ
* @return void
*/
private function sendActivity(string $inboxUrl, array $activity): void {
$privFile = FEDIINFO['privkey'];
$priv = file_get_contents($privFile);
if ($priv === false) {
logger(\LogType::ActivityPub, "エラー:秘密鍵「{$privFile}」の読込に失敗");
header('HTTP/1.1 500 Internal Server Error');
header('Content-Type: application/activity+json');
echo json_encode(['error' => '秘密鍵の読込に失敗']);
exit;
}
$body = json_encode($activity, JSON_UNESCAPED_SLASHES);
$digest = base64_encode(hash('sha256', $body, true));
$date = gmdate('D, d M Y H:i:s \G\M\T');
$host = parse_url($inboxUrl, PHP_URL_HOST);
$headers = [
'Host' => $host,
'Date' => $date,
'Content-Type' => 'application/activity+json',
'Digest' => "SHA-256=$digest",
];
$stringToSign = "host: {$headers['Host']}\n"
."date: {$headers['Date']}\n"
."digest: {$headers['Digest']}";
logger(\LogType::ActivityPub, "署名対象: {$stringToSign}");
if (!openssl_sign($stringToSign, $signature, $priv, OPENSSL_ALGO_SHA256)) {
$error = openssl_error_string();
logger(\LogType::ActivityPub, "エラー:署名に失敗: {$error}");
header('HTTP/1.1 500 Internal Server Error');
header('Content-Type: application/activity+json');
echo json_encode(['error' => '署名に失敗']);
exit;
}
$sigValue = base64_encode($signature);
$headers['Signature'] = "keyId=\"https://{$this->domain}/ap/actor#main-key\",";
$headers['Signature'] .= 'algorithm="rsa-sha256",';
$headers['Signature'] .= 'headers="host date digest",';
$headers['Signature'] .= 'signature="'.$sigValue.'"';
logger(\LogType::ActivityPub,
"署名: {$headers['Signature']}\n送信データ: {$body}");
$curl = new Curl($inboxUrl);
$curl->setMethod('POST')
->setPostRaw($body)
->setHeaders(array_map(fn($k, $v) => "$k: $v",
array_keys($headers), $headers))
->setCaInfo('/etc/ssl/cert.pem')
->setVerbose(true)
->setStderr(fopen(ROOT.'/log/ap_log.txt', 'a'));
$success = $curl->execute();
$res = $curl->getResponseBody();
$code = $curl->getResponseCode();
$err = $curl->getError();
var_dump(print_r($res));
logger(\LogType::ActivityPub,
"アクティビティは「{$inboxUrl}」に送信しました: HTTP {$code}");
logger(\LogType::ActivityPub, "エラー: {$err}");
logger(\LogType::ActivityPub, "レスポンス: {$res}");
}
/**
* フォロワーを受け入れる
*
* @param array $activity フォローアクティビティデータ
* @return void
*/
private function acceptFollower(array $activity): void {
$followerActor = $activity['actor'] ?? null;
if (!$followerActor) {
header('HTTP/1.1 400 Bad Request');
header('Content-Type: application/activity+json');
echo json_encode(['error' => 'アクターがない']);
exit;
}
$this->storeFollower($followerActor);
$inbox = $this->getInboxFromActor($followerActor);
if (!$inbox) {
header('HTTP/1.1 500 Internal Server Error');
header('Content-Type: application/activity+json');
echo json_encode(['error' => 'フォロワーの受付ボックスの受取に失敗']);
exit;
}
$accept = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/activities/".uniqid(),
'type' => 'Accept',
'actor' => "https://{$this->domain}/ap/actor",
'object' => $activity,
];
$this->sendActivity($inbox, $accept);
}
/**
* フォロワーをストレージに保存する
*
* @param string $followerActor フォロワーのアクターURL
* @return void
*/
private function storeFollower(string $followerActor): void {
$file = ROOT.'/data/followers.txt';
if (!file_exists($file)) {
touch($file);
chmod($file, 0644);
}
$followers = $this->getFollowersList();
if (!in_array($followerActor, $followers)) {
file_put_contents($file, "$followerActor\n", FILE_APPEND);
}
}
/**
* フォロワーのリストを配列として取得する
*
* @return array フォロワーのURLの配列
*/
private function getFollowersList(): array {
$file = ROOT.'/data/followers.txt';
$f = array_filter(explode("\n", file_get_contents($file)));
return file_exists($file)
? array_filter(explode("\n", file_get_contents($file))) : [];
}
/**
* アクターのインボックスURLを取得する
*
* @param string $actor アクターのURL
* @return string|null インボックスURL、取得に失敗した場合はnull
*/
private function getInboxFromActor(string $actor): ?string {
$curl = new Curl($actor);
$curl->setHeaders(['Accept: application/activity+json'])
->setFollowRedirects(true)
->setMaxRedirects(5)
->setCaInfo('/etc/ssl/cert.pem');
logger(\LogType::ActivityPub, "アクターURLにリクエスト: {$actor}");
$success = $curl->execute();
if (!$success) {
logger(\LogType::ActivityPub, "アクターリクエストに失敗: ".$curl->getError());
return null;
}
$res = $curl->getResponseBody();
$code = $curl->getResponseCode();
$err = $curl->getError();
if ($code !== 200) {
logger(\LogType::ActivityPub, "アクター取得に失敗: HTTP {$code}, エラー: {$err}");
return null;
}
$data = json_decode($res, true);
return $data['inbox'] ?? null;
}
}

85
src/Site/Lib/Csv.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
namespace Site\Lib;
enum Delimiter: int {
case COMMA = 1;
case SEMICOLON = 2;
case TAB = 3;
case PIPE = 4;
public static function default(): self {
return self::COMMA;
}
public function getChar(): string {
return match ($this) {
self::COMMA => ",",
self::SEMICOLON => ";",
self::TAB => "\t",
self::PIPE => "|",
};
}
}
/**
* CSVパーシングクラス
*/
class Csv {
// リクエスト関連のプロパティ
private bool $isHeader = false;
private int $length = 8192;
private Delimiter $delimiter;
private string $filename;
private $fp;
public function __construct(string $filename, int $length = 8192) {
$this->length = $length;
$this->filename = $filename;
$this->delimiter = Delimiter::default();
$this->fp = fopen($this->filename, 'r');
if ($this->fp === false) {
$msg = "ファイルを開けられません。";
logger(\LogType::Csv, $msg);
throw new \Exception($msg);
}
}
public function __destruct() {
if ($this->fp !== false) {
fclose($this->fp);
}
}
public function parse(?Delimiter $delimiter = null, bool $isHeader = false): array {
$res = [];
$this->isHeader = $isHeader;
$this->delimiter = $delimiter ?? $this->delimiter;
$delimChar = $this->delimiter->getChar();
rewind($this->fp);
if ($this->isHeader) {
$res = ['header' => [], 'body' => []];
$head = fgets($this->fp, $this->length);
if ($head !== false) {
$res['header'] = str_getcsv($head, $delimChar);
}
}
while (($buffer = fgets($this->fp, $this->length)) !== false) {
$row = str_getcsv($buffer, $delimChar);
if ($this->isHeader) $res['body'][] = $row;
else $res[] = $row;
}
if (!feof($this->fp)) {
$msg = "エラーfgets()の失敗";
logger(\LogType::Csv, $msg);
throw new \Exception($msg);
}
return $res;
}
}

691
src/Site/Lib/Curl.php Normal file
View File

@@ -0,0 +1,691 @@
<?php
namespace Site\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 ($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 bool 成功または失敗
*/
public function execute(): bool {
if (empty($this->url)) {
$this->responseError = 'URLがありません';
return false;
}
// レスポンスデータのリセット
$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 false;
}
$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 false;
}
// タイムアウトを設定
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 true;
}
/**
* レスポンスボディを取得する
*
* @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;
}
}

130
src/Site/Lib/DiffViewer.php Normal file
View File

@@ -0,0 +1,130 @@
<?php
namespace Site\Lib;
class DiffViewer {
private $diffContent;
public function __construct(string $filePath) {
if (!file_exists($filePath)) {
throw new \Exception("Diff file not found: $filePath");
}
$this->diffContent = file_get_contents($filePath);
}
public function displaySideBySide(): string {
$lines = explode("\n", $this->diffContent);
$fileDiffs = [];
$currentFile = null;
$hunk = [];
$lineNumbers = ['left' => 0, 'right' => 0];
$currentLeftLines = [];
$currentRightLines = [];
foreach ($lines as $line) {
// ファイルヘッダーの処理
if (preg_match('/^---\s+(.+)/', $line, $matches)) {
// ファイルを処理する場合、データを保存する
if ($currentFile !== null) {
$this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
$fileDiffs[$currentFile] = [
'leftLines' => $currentLeftLines,
'rightLines' => $currentRightLines
];
$hunk = [];
$currentLeftLines = [];
$currentRightLines = [];
$lineNumbers = ['left' => 0, 'right' => 0];
}
$currentFile = $matches[1];
continue;
}
if (preg_match('/^\+\+\+\s+(.+)/', $line)) {
continue;
}
// ハンクヘッダーの処理 (例:@@ -10,6 +10,7 @@)
if (preg_match('/^@@\s+-(\d+),\d+\s+\+(\d+),\d+\s+@@/', $line, $matches)) {
$this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
$hunk = [];
$lineNumbers['left'] = (int)$matches[1];
$lineNumbers['right'] = (int)$matches[2];
continue;
}
// ハンクでの行列の集まり
if (substr($line, 0, 1) === '-' || substr($line, 0, 1) === '+' || substr($line, 0, 1) === ' ') {
$hunk[] = $line;
}
}
// 最後のハンク・ファイルの処理
if ($currentFile !== null) {
$this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
$fileDiffs[$currentFile] = [
'leftLines' => $currentLeftLines,
'rightLines' => $currentRightLines
];
}
// 各ファイルにHTMLの出力の作成
$html = '';
foreach ($fileDiffs as $fileName => $diff) {
$html .= "<h2>ファイル: ".htmlspecialchars($fileName)."</h2>\n";
$html .= $this->generateHtml($diff['leftLines'], $diff['rightLines']);
}
return $html;
}
private function processHunk(array $hunk, array &$leftLines, array &$rightLines, array &$lineNumbers): void {
foreach ($hunk as $line) {
$prefix = substr($line, 0, 1);
$content = substr($line, 1);
if ($prefix === '-') {
$leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'removed', 'line' => $lineNumbers['left']];
$lineNumbers['left']++;
} elseif ($prefix === '+') {
$rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'added', 'line' => $lineNumbers['right']];
$lineNumbers['right']++;
} elseif ($prefix === ' ') {
// 両側のコンテキストは同じ行列があるかの確認
while ($lineNumbers['left'] < $lineNumbers['right']) {
$leftLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['left']];
$lineNumbers['left']++;
}
while ($lineNumbers['right'] < $lineNumbers['left']) {
$rightLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['right']];
$lineNumbers['right']++;
}
$leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['left']];
$rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['right']];
$lineNumbers['left']++;
$lineNumbers['right']++;
}
}
}
private function generateHtml(array $leftLines, array $rightLines): string {
$html = '<table class="diff-table">';
$html .= '<tr class="diff-header"><th colspan="2">前</th><th colspan="2">新</th></tr>';
$maxLines = max(count($leftLines), count($rightLines));
for ($i = 0; $i < $maxLines; $i++) {
$left = isset($leftLines[$i]) ? $leftLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
$right = isset($rightLines[$i]) ? $rightLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
$html .= '<tr>';
// 左(変更前)
$html .= '<td class="line-number">' . ($left['line'] ?: '&nbsp;') . '</td>';
$html .= '<td class="' . $left['type'] . '">' . ($left['content'] ?: '&nbsp;') . '</td>';
// 右(変更後)
$html .= '<td class="line-number">' . ($right['line'] ?: '&nbsp;') . '</td>';
$html .= '<td class="' . $right['type'] . '">' . ($right['content'] ?: '&nbsp;') . '</td>';
$html .= '</tr>';
}
$html .= '</table>';
return $html;
}
}

238
src/Site/Lib/Mailer.php Normal file
View File

@@ -0,0 +1,238 @@
<?php
namespace Site\Lib;
class Mailer {
private $socket;
private string $host;
private int $port;
private ?string $user;
private ?string $pass;
private ?string $pgpKey;
private ?string $pgpPass;
/**
* コンストラクタ
*/
public function __construct() {
if (!MAILER_ENABLED) {
throw new \Exception("メーラーは無効です。");
}
$this->host = MAILINFO['host'];
$this->port = MAILINFO['port'];
$this->user = MAILINFO['user'];
$this->pass = MAILINFO['pass'];
}
/**
* ソケット接続を開く。
*
* @return void
*/
public function connect(): void {
$this->socket = fsockopen($this->host, $this->port, $errno, $err, 30);
if (!$this->socket) {
$msg = "接続に失敗: {$err} ({$errno})";
logger(\LogType::Mailer, $msg);
throw new \Exception($msg);
}
$this->readResponse();
}
/**
* ソケット接続を切断する。
*
* @return void
*/
public function disconnect(): void {
$this->sendCommand('QUIT', 221);
fclose($this->socket);
}
/**
* サーバーで認証する。
* ユーザー名とパスワードが指定されている場合はAUTH LOGINを使用する。
*
* @return void
*/
public function authenticate(): void {
$ehloRes = $this->sendCommand('EHLO '.$this->host, 250);
// STARTTLSは対応するかどうか確認
if (strpos($ehloRes, 'STARTTLS') !== false) {
$this->sendCommand('STARTTLS', 220);
// TLS暗号化
if (!stream_socket_enable_crypto(
$this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
$msg = "TLSハンドシェイクに失敗";
logger(\LogType::Mailer, $msg);
throw new \Exception($msg);
}
$this->sendCommand('EHLO '.gethostname(), 250);
}
if ($this->user && $this->pass) {
$this->sendCommand('AUTH LOGIN', 334);
$this->sendCommand(base64_encode($this->user), 334);
$this->sendCommand(base64_encode($this->pass), 235);
}
}
/**
* TLSハンドシェイクに失敗する。
*
* @param string $to 受信者のメールアドレス。
* @param string $subject メールの件名。
* @param string $body メールの本文。
* @param string|null $toName 受信者の名前。
* @param string|null $replyTo 返信先メールアドレス。
* @param string|null $replyToName 返信先の名前。
* @param bool $pgpSign PGPで署名するかどうか現在は正常に動作していません
*
* @return void
*/
public function send(
string $to,
string $subject,
string $body,
?string $toName = null,
?string $replyTo = null,
?string $replyToName = null,
bool $pgpSign = false
): void {
$from = MAILINFO['from'];
$this->sendCommand("MAIL FROM: <{$from}>", 250);
$this->sendCommand("RCPT TO: <{$to}>", 250);
$this->sendCommand('DATA', 354);
$headers = "Date: ".date('r')."\r\n"; // RFC 2822
$encSubject = mb_encode_mimeheader($subject, 'UTF-8', 'Q');
$headers .= "Subject: {$encSubject}\r\n";
$fromHeader = mb_encode_mimeheader(SITEINFO['title'], 'UTF-8', 'Q')." <{$from}>";
$headers .= "From: {$fromHeader}\r\n";
$toHeader = $toName
? mb_encode_mimeheader($toName, 'UTF-8', 'Q')." <{$to}>" : $to;
$headers .= "To: {$toHeader}\r\n";
if ($replyTo) {
$replyToHeader = $replyToName
? mb_encode_mimeheader($replyToName, 'UTF-8', 'Q')." <{$replyTo}>" : $replyTo;
$headers .= "Reply-To: {$replyToHeader}\r\n";
}
$headers .= "MIME-Version: 1.0\r\n";
if ($pgpSign) {
$boundary = uniqid('BOUNDARY_');
$headers .= "Content-Type: multipart/signed;\r\n";
$headers .= " protocol=\"application/pgp-signature\";\r\n";
$headers .= " micalg=php-sha512;\r\n";
$headers .= " boundary=\"{$boundary}\"\r\n";
} else {
$headers .= "Content-Type: text/plain; charset=utf-8\r\n";
$headers .= "Content-Transfer-Encoding: quoted-printable\r\n";
}
$headers .= "X-Mailer: 076 Little Beast\r\n";
$encBody = quoted_printable_encode($body);
$data = $headers."\r\n".$encBody;
if ($pgpSign) {
$signature = $this->signMessage($data);
$data = "--{$boundary}\r\n"
.$data."\r\n"
."Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n"
."Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n"
.$signature."\r\n"
."--{$boundary}--\r\n";
}
$data .= "\r\n.\r\n";
fwrite($this->socket, $data);
$response = $this->readResponse();
if (substr($response, 0, 3) != '250') {
$msg = "メール送信に失敗: {$response}";
logger(\LogType::Mailer, $msg);
throw new \Exception($msg);
}
}
/**
* お好みのタイムゾーンを設定する。
* 設定しない場合、php.iniで設定されたタイムゾーンがデフォルトになる。
* それも設定されていない場合、GMTタイムゾーンがデフォルトになる。
*
* @param string $zone IANAタイムゾーンデータベース標準のタイムゾーンAsia/Tokyo
* @return void
*/
public function setTimezone(string $zone): void {
date_default_timezone_set($zone);
}
/**
* PGPキーとオプションでパスフレーズを設定する。
*
* @param string $keypath PGP署名に使用する秘密鍵へのパス。
* @param string|null $passphrase 設定されている場合、署名用のパスフレーズ。
*
* @return void
*/
public function enablePGP(string $keypath, ?string $passphrase = null): void {
$this->pgpKey = file_get_contents($keypath);
$this->pgpPass = $passphrase;
}
// 機能性メソッド
private function sendCommand(string $command, int $retcode): string {
fwrite($this->socket, $command."\r\n");
$res = $this->readResponse();
if (substr($res, 0, 3) != $retcode) {
$msg = "{$command}」に対する予期しないレスポンス: {$res}";
logger(\LogType::Mailer, $msg);
throw new \Exception($msg);
}
return $res;
}
private function readResponse(): string {
$res = '';
while ($line = fgets($this->socket, 515)) {
$res .= $line;
if (substr($line, 3, 1) == ' ') break; // レスポンスの終了だ
}
return $res;
}
private function signMessage(string $message): string {
if (extension_loaded('gnupg')) {// gnupg延長は有効の場合
$gpg = new \gnupg();
$gpg->addsignkey($this->pgpKey, $this->pgpPass);
$gpg->setsignmode(\gnupg::SIG_MODE_DETACH);
return $gpg->sign($message);
} else { // なければ、CLIツールを使うgnupgをインストールは必須
$tmp = tempnam(sys_get_temp_dir(), 'pgpmsg');
file_put_contents($tmp, $message);
$sig = shell_exec(
"gpg --batch "
.($this->pgpPass ? "--passphrase {$this->pgpPass} " : '')
."--detach-sign --armor {$tmp} 2>&1"
);
unlink($tmp);
return $sig;
}
}
}

646
src/Site/Lib/Markdown.php Normal file
View File

@@ -0,0 +1,646 @@
<?php
namespace Site\Lib;
class Markdown {
private string $content;
private array $html;
private string $path;
private bool $inCodeBlock = false;
private string $codeBlockLanguage = '';
private array $codeBlockContent = [];
private const METADATA_LINE = "----";
private array $algebraicPlaceholder = [];
public function __construct(string $path, string $section, ?string $lang = null) {
$this->html = [];
if ($lang) $this->path = ROOT.$section.$lang.'/'.$path.'.md';
else $this->path = ROOT.$section.$path.'.md';
if (!file_exists($this->path)) {
header('Location: /404');
exit();
}
}
/**
* メタデータを取得する
*
* @return \stdClass メタデータオブジェクト
*/
public function getMetadata(): \stdClass {
$content = file_get_contents($this->path);
$metadata = new \stdClass();
$parts = explode(self::METADATA_LINE, $content, 2);
if (count($parts) < 2) return $metadata;
$lines = explode("\n", trim($parts[0]));
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
$colonPos = strpos($line, ':');
if ($colonPos === false) continue;
$key = trim(substr($line, 0, $colonPos));
$value = trim(substr($line, $colonPos + 1));
$value = trim($value, '"\'');
if ($key == 'category' || $key == 'css') {
$cat = explode(',', $value);
$value = $cat;
}
$metadata->$key = $value;
}
return $metadata;
}
/**
* Markdownをパースする
*
* @return string HTMLとしてレンダリングされたコンテンツ
*/
public function parse(): string {
$content = file_get_contents($this->path);
$parts = explode(self::METADATA_LINE, $content, 2);
$this->content = count($parts) > 1 ? trim($parts[1]): trim($content);
$this->html = [];
$lines = explode("\n", $this->content);
$currentParagraph = [];
$inList = false;
$listItems = [];
$listLevel = 0;
$inBlockquote = false;
$blockquoteContent = [];
$tableHeaders = [];
$tableRows = [];
$inTable = false;
$i = 0;
foreach ($lines as $line) {
$i++;
$hasBR = substr($line, -1) === '\\';
$line = rtrim($line, " \t\r\n\\");
// コメント
// if (str_starts_with($line, '//')) {
// if ($hasBR && !empty($currentParagraph)) {
// $currentParagraph[] = '';
// }
// continue;
// }
// コードブロック
if (preg_match('/^```(\w*)$/', $line, $matches)) {
if (!$this->inCodeBlock) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
$this->inCodeBlock = true;
$this->codeBlockLanguage = $matches[1];
continue;
} else {
$this->html[] = $this->createCodeBlock();
$this->inCodeBlock = false;
$this->codeBlockLanguage = '';
$this->codeBlockContent = [];
continue;
}
}
if ($this->inCodeBlock) {
$this->codeBlockContent[] = $line;
continue;
}
// テーブルの処理
if (preg_match('/^\|(.+)\|$/', $line)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
$cells = array_map('trim', explode('|', trim($line, '|')));
if (!$inTable) {
$tableHeaders = $cells;
$inTable = true;
} elseif (preg_match('/^\|(\s*:?-+:?\s*\|)+$/', $line)) {
continue;
} else {
$tableRows[] = $cells;
}
continue;
} elseif ($inTable) {
$this->html[] = $this->createTable($tableHeaders, $tableRows);
$tableHeaders = [];
$tableRows = [];
$inTable = false;
}
// 水平線の処理
if (preg_match('/^([\-\*\_])\1{2,}$/', $line)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
$this->html[] = "<hr>";
continue;
}
// 引用ブロックの処理
if (preg_match('/^>\s(.+)/', $line, $matches)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
$inBlockquote = true;
$blockquoteContent[] = $this->parseInline($matches[1]);
continue;
} elseif ($inBlockquote && empty($line)) {
$this->html[] = $this->createBlockquote($blockquoteContent);
$blockquoteContent = [];
$inBlockquote = false;
continue;
}
// 空行をスキップ
if (empty($line)) {
if ($inList) {
$this->html[] = $this->createList($listItems);
$listItems = [];
$inList = false;
}
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
continue;
}
// ヘッダー
if (preg_match('/^(#{1,6})\s(.+)/', $line, $m)) {
if ($inList) {
$this->html[] = $this->createList($listItems);
$listItems = [];
$inList = false;
}
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
$level = strlen($m[1]);
$this->html[] = "<h{$level}>".$this->parseInline($m[2])."</h{$level}>";
continue;
}
// 箇条書きリスト
if (preg_match('/^(\s*)([\*\-])\s(.+)/', $line, $m)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
$inList = true;
$currentLevel = strlen($m[1]) / 2;
$listLevel = max($listLevel, $currentLevel);
$listItems[] = [
'content' => $this->parseInline($m[3]),
'level' => $currentLevel,
'type' => 'ul',
];
continue;
}
// 番号付きリスト
if (preg_match('/^(\s*)\d+\.\s(.+)/', $line, $m)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
$inList = true;
$currentLevel = strlen($m[1]) / 2;
$listLevel = max($listLevel, $currentLevel);
$listItems[] = [
'content' => $this->parseInline($m[2]),
'level' => $currentLevel,
'type' => 'ol',
];
continue;
}
if ($inList) {
$this->html[] = $this->createList($listItems);
$listItems = [];
$inList = false;
$listLevel = 0;
}
$parsedLine = $this->parseInline($line);
$currentParagraph[] = $parsedLine;
if ($hasBR) {
$currentParagraph[] = "<br />";
}
}
if ($inList) $this->html[] = $this->createList($listItems);
if ($inBlockquote) $this->html[] = $this->createBlockquote($blockquoteContent);
if ($inTable) $this->html[] = $this->createTable($tableHeaders, $tableRows);
if (!empty($currentParagraph))
$this->html[] = " <p>".implode("", $currentParagraph)."</p>";
return implode("\n", $this->html);
}
// 機能性メソッド
/**
* インラインのMarkdown記法をパースする
*
* @param string $text パースするテキスト
* @return string HTMLとしてレンダリングされたテキスト
*/
private function parseInline(string $text): string {
$this->algebraicPlaceholder = [];
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
$patterns = [
// 数式
'/\$\$([^$]+)\$\$/u' => function($matches): string {
$placeholder = "{{ALG".count($this->algebraicPlaceholder).'ALG}}';
$this->algebraicPlaceholder[$placeholder] = $matches[1];
return $placeholder;
},
// 太字
'/\*\*(.+?)\*\*/u' => '<strong>$1</strong>',
// 斜体
'/\*(.+?)\*/u' => '<em>$1</em>',
// 下線
'/\_(.+?)\_/u' => '<u>$1</u>',
// 取り消し線
'/\~(.+?)\~/u' => '<s>$1</s>',
// Blink (with speed)
'/!:\((.+?)\)(.+?):!/u' => '<span class="blink" style="animation-duration: $1s;">$2</span>',
// Blink
'/!:(.+?):!/u' => '<span class="blink">$1</span>',
// フォントの大きさ
'/\^\((.+?)\)(.+?)\^/u' => '<span style="font-size: $1px;">$2</span>',
// フォントカラー
'/\%\((.+?)\)(.+?)\%/u' => '<span style="color: #$1;">$2</span>',
// 画像
'/\!\[(.*?)(?:#([^\]]*))?\]\((.+?)\)/u' => '<img style="width: 100%; $2" src="$3" alt="$1" />',
// 音楽
'/\$\[([^\]]+)\]\(([^\)]+)\)/u' => '<audio controls><source src="$2" type="$1" /></audio>',
// 動画
'/\#\[([^\]]+)\]\(([^\)]+)\)/u' => '<video style="max-width: 100%;" controls><source src="$2" type="$1" /></video>',
// リンク
'/\[(.+?)\]\((.+?)\)/u' => '<a href="$2">$1</a>',
// 振り仮名
'/\&lt;(.+?)\&gt;\((.+?)\)/u' => '<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>',
// インラインコード
'/`(.+?)`/u' => '<code>$1</code>',
];
foreach ($patterns as $pattern => $replacement) {
if (is_callable($replacement)) {
$text = preg_replace_callback($pattern, $replacement, $text);
} else {
$result = preg_replace($pattern, $replacement, $text);
if ($result === null) continue;
$text = $result;
}
}
// プレースホルダーの数式に交換
foreach ($this->algebraicPlaceholder as $placeholder => $expr) {
$text = str_replace($placeholder, $this->parseAlgebraic($expr), $text);
}
return $text;
}
/**
* 数式記法をパースする
*
* @param string $expression パースする数式
* @return string MathJax用にフォーマットされた数式
*/
private function parseAlgebraic(string $expression): string {
// HTMLエスケープ
$expression = htmlspecialchars_decode($expression, ENT_QUOTES);
// ふぃりしゃもじのマッピング
$greekLetters = [
'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ', 'delta' => 'δ',
'epsilon' => 'ε', 'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ',
'iota' => 'ι', 'kappa' => 'κ', 'lambda' => 'λ', 'mu' => 'μ',
'nu' => 'ν', 'xi' => 'ξ', 'omicron' => 'ο', 'pi' => 'π',
'rho' => 'ρ', 'sigma' => 'σ', 'tau' => 'τ', 'upsilon' => 'υ',
'phi' => 'φ', 'chi' => 'χ', 'psi' => 'ψ', 'omega' => 'ω',
'Alpha' => 'Α', 'Beta' => 'Β', 'Gamma' => 'Γ', 'Delta' => 'Δ',
'Epsilon' => 'Ε', 'Zeta' => 'Ζ', 'Eta' => 'Η', 'Theta' => 'Θ',
'Iota' => 'Ι', 'Kappa' => 'Κ', 'Lambda' => 'Λ', 'Mu' => 'Μ',
'Nu' => 'Ν', 'Xi' => 'Ξ', 'Omicron' => 'Ο', 'Pi' => 'Π',
'Rho' => 'Ρ', 'Sigma' => 'Σ', 'Tau' => 'Τ', 'Upsilon' => 'Υ',
'Phi' => 'Φ', 'Chi' => 'Χ', 'Psi' => 'Ψ', 'Omega' => 'Ω',
];
// ギリシャ文字を置換
foreach ($greekLetters as $text => $symbol) {
$expression = preg_replace("/\b$text\b/", $symbol, $expression);
}
// 上付き文字(例: x^2
$expression = preg_replace_callback('/(\w+)\^(\{?(\d+|\w+)\}?)/', function($matches) {
$base = $matches[1];
$exponent = $matches[2];
if ($exponent[0] === '{') $exponent = substr($exponent, 1, -1);
$superscripts = str_split($exponent);
$supMap = [
'0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴',
'5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹',
'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ',
'i' => 'ⁱ', 'n' => 'ⁿ',
];
$converted = '';
foreach ($superscripts as $char) $converted .= $supMap[$char] ?? $char;
return $base.'<sup>'.$converted.'</sup>';
}, $expression);
// 下付き文字(例: x_1
$expression = preg_replace_callback('/(\w+)_((\d+|\w+)\}?)/', function($matches) {
$base = $matches[1];
$subscript = $matches[2];
if ($subscript[0] === '{') $subscript = substr($subscript, 1, -1);
$subscripts = str_split($subscript);
$subMap = [
'0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄',
'5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉',
];
$converted = '';
foreach ($subscripts as $char) {
$converted .= $subMap[$char] ?? $char;
}
return $base.'<sub>'.$converted.'</sub>';
}, $expression);
// 分数(例: 4/5x, a/b
$expression = preg_replace('/(\d+|\w+)\/(\d*\w+)/',
'<span class="fraction"><span class="numerator">$1</span><span class="denominator">$2</span></span>',
$expression);
// 演算子
$operators = [
'\*' => '・',
'!=' => '≠',
'>=' => '≥',
'<=' => '≤',
'sqrt' => '√',
];
foreach ($operators as $op => $symbol) $expression = preg_replace("/\b$op\b/", $symbol, $expression);
// 数式全体をspanで囲む
return '<span class="algebraic">'.$expression.'</span>';
}
/**
* リストを作成する
*
* @param array $items リストアイテムの配列
* @param int $maxLevel 最大ネストレベル
* @return string HTMLのリスト
*/
private function createList(array $items, int $maxLevel = 1): string {
if ($items === []) return '';
$html = '';
$currentLevel = 0;
$listStack = [];
$currentType = '';
foreach ($items as $item) {
$level = isset($item['level']) ? $item['level'] : $currentLevel;
while ($currentLevel > $level)
$html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
while ($currentLevel < $level) {
$currentLevel++;
$listStack[] = $item['type'];
$html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
}
if ($currentType != $item['type'] && $currentLevel == $item['level']) {
if (!empty($listStack)) {
$html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
$listStack[] = $item['type'];
$html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
}
}
$currentType = $item['type'];
}
$html .= "<{$currentType}>\n";
foreach ($items as $item) {
$html .= str_repeat(' ', $currentLevel)." <li>".$item['content']."</li>\n";
}
$html .= "</{$currentType}>";
while (!empty($listStack)) {
$html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
$currentLevel--;
}
return rtrim($html);
}
/**
* コードブロックを作成する
*
* @return string HTMLのコードブロック
*/
private function createCodeBlock(): string {
$code = htmlspecialchars(implode("\n", $this->codeBlockContent));
$class = $this->codeBlockLanguage ? " class=\"language-{$this->codeBlockLanguage}\"" : '';
return "<pre><code{$class}>{$code}</code></pre>";
// $raw = implode("\n", $this->codeBlockContent);
// $lang = $this->codeBlockLanguage ?: 'txt';
// $class = $lang === 'txt' ? '' : " class=\"language-{$lang}\"";
// $highlighted = $this->highlightCode($raw, $lang);
// return "<pre><code{$class}>{$highlighted}</code></pre>";
}
private function createBlockquote(array $content): string {
return "<blockquote>\n <p>".implode("</p>\n <p>", $content)."</p>\n</blockquote>";
}
/**
* テーブルを作成する
*
* @param array $headers ヘッダー配列
* @param array $rows 行データの配列
* @return string HTMLのテーブル
*/
private function createTable(array $headers, array $rows): string {
$html = "<table>\n";
// ヘッダーを追加
if (!empty($headers)) {
$html .= " <thead>\n <tr>\n";
foreach ($headers as $header) {
$html .= " <th>".$this->parseInline($header)."</th>\n";
}
$html .= " </tr>\n </thead>\n";
}
// 行を追加
if (!empty($rows)) {
$html .= " <tbody>\n";
foreach ($rows as $row) {
$html .= " <tr>\n";
foreach ($row as $cell) {
$html .= " <td>".$this->parseInline($cell)."</td>\n";
}
$html .= " </tr>\n";
}
$html .= " </tbody>\n";
}
$html .= "</table>";
return $html;
}
/**
* Pure-PHP syntax highlighter
*
* @param string $code Raw source code
* @param string $lang Language identifier (html, css, js, php, …)
* @return string HTML with <span class="hl-…"> tokens
*/
private function highlightCode(string $code, string $lang): string
{
$lang = strtolower(trim($lang));
$rules = [];
// ---- COMMON -------------------------------------------------------
$rules['comment'] = [
// /* … */ (multi-line)
['/\/\*(?:.|\n)*?\*\//', 'hl-comment'],
// // …\n
['/\/\/.*(?=\n|$)/', 'hl-comment'],
];
$rules['string'] = [
// "…" and '…' (allow escaped quotes)
['/"(?:\\.|[^"\\\n])*"/', 'hl-string'],
["/'(?:\\.|[^'\\\n])*'/", 'hl-string'],
];
$rules['number'] = [
['/\b(?:0x[0-9a-fA-F]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/', 'hl-number'],
];
// ---- HTML ---------------------------------------------------------
if ($lang === 'html') {
$rules['tag'] = [['/&lt;\/?[a-zA-Z0-9\-]+/', 'hl-tag']];
$rules['attr'] = [['/\s[a-zA-Z0-9\-]+(?==)/', 'hl-attr']];
$rules['value'] = [['/(?<==)"(?:\\.|[^"\\\n])*"(?=[>\s])/', 'hl-value']];
}
// ---- CSS ----------------------------------------------------------
if ($lang === 'css') {
$rules['selector'] = [['/^[\t ]*[a-zA-Z0-9#\.\-\:$$ $$\=\*\>\+\~\^]+(?=\s*\{)/m', 'hl-selector']];
$rules['property'] = [['/\b[a-z\-]+(?=\s*:)/', 'hl-property']];
$rules['value'] = [['/(?<=\:)\s*[^;]+(?=;)/', 'hl-value']];
$rules['unit'] = [['/\b\d+(px|em|rem|%|vh|vw|pt|pc|cm|mm|in)\b/i', 'hl-unit']];
}
// ---- PHP ----------------------------------------------------------
if ($lang === 'php') {
$keywords = 'abstract|and|array|as|break|callable|case|catch|class|clone|'
. 'const|continue|declare|default|die|do|echo|else|elseif|empty|'
. 'enddeclare|endfor|endforeach|endif|endswitch|endwhile|eval|exit|'
. 'extends|final|finally|for|foreach|function|global|goto|if|'
. 'implements|include|include_once|instanceof|insteadof|interface|'
. 'isset|list|namespace|new|or|print|private|protected|public|'
. 'require|require_once|return|static|switch|throw|trait|try|unset|'
. 'use|var|while|xor|yield';
$rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
$rules['function']= [['/\b([a-zA-Z_\x7f-\xff][\w\x7f-\xff]*)(?=\s*\()/', 'hl-function']];
$rules['variable']= [['/\$[a-zA-Z_\x7f-\xff][\w\x7f-\xff]*/', 'hl-variable']];
}
// ---- Shell --------------------------------------------------------
if ($lang === 'sh') {
$keywords = 'if|then|else|elif|fi|case|esac|for|select|while|until|do|done|in';
$rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
$rules['variable']= [['/\$\{?[a-zA-Z_][\w]*\}?/', 'hl-variable']];
}
// ---- JSON ---------------------------------------------------------
if ($lang === 'json') {
$rules['property'] = [['/"([^"]+)":/', '<span class="hl-property">$1</span>:']];
}
// ---- C / C++ -----------------------------------------------
if (in_array($lang, ['c', 'cpp', 'cs'])) {
$keywords = 'abstract|assert|boolean|break|byte|case|catch|char|class|const|'
. 'continue|default|do|double|else|enum|extends|false|final|finally|'
. 'float|for|goto|if|implements|import|instanceof|int|interface|long|'
. 'native|new|null|package|private|protected|public|return|short|static|'
. 'strictfp|super|switch|synchronized|this|throw|throws|transient|true|'
. 'try|void|volatile|while';
$rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
$rules['type'] = [['/\b(?:int|float|double|char|bool|void|string)\b/', 'hl-type']];
$rules['define'] = [['/(#include)|(#define)/', 'hl-define']];
}
if ($lang === 'markdown') {
$rules['header'] = [['/^(#{1,6})\s+(.+)$/', '<span class="hl-header">$1 $2</span>']];
$rules['link'] = [['/\[([^\]]+)\]\(([^)]+) $$/', '<span class="hl-link">[$1]($2)</span>']];
}
$html = htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8');
$order = ['comment', 'string', 'number'];
foreach (['tag','attr','value','selector','property','unit','keyword','function','variable','type','header','link'] as $k) {
if (isset($rules[$k])) $order[] = $k;
}
foreach ($order as $type) {
if (!isset($rules[$type])) continue;
foreach ($rules[$type] as $rule) {
[$pattern, $class] = $rule;
$html = preg_replace_callback(
$pattern,
function ($m) use ($class) {
// For JSON property we already built the <span>
if (strpos($m[0], '<span') === 0) return $m[0];
$inner = $m[0];
// Preserve already escaped entities inside strings/comments
return '<span class="'.$class.'">'.$inner.'</span>';
},
$html
);
}
}
return $html;
}
}

1559
src/Site/Lib/Mysql.php Normal file

File diff suppressed because it is too large Load Diff

163
src/Site/Lib/Route.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
namespace Site\Lib;
class Route {
protected static array $routes = [];
protected static array $fallback = [];
/**
* ルート設定を言語固有のハンドラで初期化する
*
* @param array $routes ルート設定の配列
* @return void
*/
public static function init(array $routes): void {
self::$routes = $routes;
}
/**
* ルートを追加する
*
* @param string $method HTTPメソッド
* @param string $path URLパス
* @param string|callable $class ハンドラクラスとメソッド、またはコールバック
* @param array $params オプションのパラメータ
* @return array ルート設定
*/
public static function add(string $method, string $path, string|callable $class,
array $params = []): array {
$route = [
'method' => $method,
'path' => $path,
'class' => $class,
'params' => $params,
];
self::$routes[] = $route;
return $route;
}
/**
* 404処理用のフォールバックルートを設定する
*
* @param array|string|callable $class
* @return void
*/
public static function setFallback(array|string|callable $class): void {
self::$fallback = [
'class' => $class,
'params' => [],
];
}
/**
* 適切なルートをマッチさせて実行する
*
* @param string $uri リクエストURI
* @return void
*/
public static function dispatch(string $uri): void {
// URIをパスとクエリ文字列に分割
$uriParts = explode('?', $uri, 2);
$path = trim($uriParts[0], " \t\n\r\0\x0B/");
// ルートパスの処理(/?page=2のようなクエリパラメータを含む場合も処理
if ($path === '') {
self::executeClass([
'class' => [new \Site\Controller\Home(), 'show'],
'params' => ['lang' => 'ja'],
]);
return;
}
if ($path === 'en') {
self::executeClass([
'class' => [new \Site\Controller\Home(), 'show'],
'params' => ['lang' => 'en'],
]);
return;
}
// パスに対してルートをマッチングする
foreach (self::$routes as $route) {
$matches = [];
if (self::matchRoute($route['path'], $path, $matches)) {
$params = self::extractParams($route['path'], $path);
$params = array_merge($route['params'], $params);
if (is_string($route['class'])) {
[ $class, $method ] = explode('@', $route['class']);
$controller = new $class();
self::executeClass([
'class' => [ $controller, $method ],
'params' => $params,
]);
return;
} elseif (is_callable($route['class'])) {
self::executeClass([
'class' => $route['class'],
'params' => $params,
]);
return;
}
}
}
// マッチするルートがない場合、フォールバックを実行
self::executeClass(self::$fallback);
}
/**
* ルートパターンとパスをマッチングする
*
* @param string $pattern ルートパターン
* @param string $path 現在のパス
* @param array $matches マッチを格納する参照
* @return bool
*/
protected static function matchRoute(string $pattern, string $path,
array &$matches = []): bool {
// ルートパターンを正規表現パターンに変換
$pattern = preg_replace('/\{([^:}]+)(?::([^}]+))?\}/', '(?P<$1>[^/]+)', $pattern);
$pattern = str_replace('/', '\/', $pattern);
return (bool)preg_match('/^'.$pattern.'$/', $path, $matches);
}
/**
* パターンに基づいてパスから名前付きパラメータを抽出する
*
* @param string $pattern ルートパターン
* @param string $path 現在のパス
* @return array
*/
protected static function extractParams(string $pattern, string $path): array {
$params = [];
$patternParts = explode('/', $pattern);
$pathParts = explode('/', $path);
foreach ($patternParts as $k => $v) {
if (preg_match('/\{([^:}]+)(?::([^}]+))?\}/', $v, $matches)) {
if (isset($pathParts[$k])) {
$params[$matches[1]] = $pathParts[$k];
}
}
}
return $params;
}
/**
* ルートクラスを実行する
*
* @param array $route ルート設定
* @return void
*/
protected static function executeClass(array $route): void {
if (is_callable($route['class'])) {
call_user_func($route['class'], $route['params'] ?? []);
}
}
}

243
src/Site/Lib/Template.php Normal file
View File

@@ -0,0 +1,243 @@
<?php
namespace Site\Lib;
class Template {
private string $tmplExt = '.maron';
private string $tmplPath;
private array $vars = [];
private array $blocks = [];
private array $custFunc = [];
private array $custCss = [];
/**
* テンプレートクラスのコンストラクタ
*
* @param string $tmplPath テンプレートのパス
* @return void
*/
public function __construct(string $tmplPath) {
$this->tmplPath = rtrim($tmplPath, '/');
if (substr($this->tmplPath, 0, 1) !== '/') {
$this->tmplPath = '/'.$this->tmplPath;
}
}
/**
* テンプレート変数に値を割り当てる
*
* @param string $name 変数名
* @param mixed $value 値
* @return void
*/
public function assign(string $name, mixed $value): void {
$this->vars[$name] = $value;
}
/**
* カスタムCSSファイルを追加する
*
* @param string $name CSSファイル名
* @return void
*/
public function addCss(string $name): void {
$this->custCss[] =
'<link rel="stylesheet" type="text/css" href="/static/style-'.$name.'.css" />';
$this->assign('custCss', $this->custCss);
}
/**
* カスタム関数を登録する
*
* @param string $name 関数名
* @param callable $callback コールバック関数
* @return void
*/
public function registerFunction(string $name, callable $callback): void {
$this->custFunc[$name] = $callback;
}
/**
* テンプレートブロックを定義する
*
* @param string $name ブロック名
* @param string $content ブロック内容
* @return void
*/
public function defineBlock(string $name, string $content): void {
if (!isset($this->blocks[$name]))
$this->blocks[$name] = $content;
}
/**
* テンプレートをレンダリングする
*
* @param string $tmplName テンプレート名
* @return void
*/
public function render(string $tmplName): void {
$tmplPath = ROOT.'/view'.$this->tmplPath.'/'.$tmplName.$this->tmplExt;
if (!file_exists($tmplPath))
throw new \RuntimeException("テンプレートファイルを見つけません:{$tmplPath}");
extract($this->vars);
$content = file_get_contents($tmplPath);
// インクルードディレクティブを処理
while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
$content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string {
$inclPath = ROOT.'/view/'.trim($m[1], "'\" ").$this->tmplExt;
if (!file_exists($inclPath))
throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
return file_get_contents($inclPath);
}, $content);
}
$content = $this->procDirs($content);
$content = $this->procVars($content);
$content = $this->procFuncs($content);
$tmpFile = tempnam(sys_get_temp_dir(), 'tmpl_');
file_put_contents($tmpFile, $content);
include $tmpFile;
unlink($tmpFile);
}
// 機能性メソッド
/**
* テンプレートディレクティブを処理する
*
* @param string $content テンプレート内容
* @return string|null 処理後の内容
*/
private function procDirs(string $content): string|null {
// includeディレクティブの処理
while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
$content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string {
$inclPath = ROOT.'/view/'.trim($m[1], "'\" ").'.php';
if (!file_exists($inclPath))
throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
return file_get_contents($inclPath);
}, $content);
}
$content = preg_replace('/\{@\s*if\s*\((.*?)\):\s*@\}/', '{@ if ($1) @}', $content);
$content = preg_replace('/\{@\s*endif;\s*@\}/', '{@ endif @}', $content);
$processDirectives = function($c): array|string|null {
// kysディレクティブの処理
$c = preg_replace_callback('/\{@\s*kys\((.*?)\)\s*@\}/s', function($m): string {
return "<?php echo '<pre>'; print_r({$m[1]}); echo '</pre>'; die(); ?>";
}, $c);
// foreachループとネストした内容の処理
$c = preg_replace_callback('/\{@\s*foreach\s*\((.*?)\)\s*@\}/s', function($m): string {
return "<?php foreach({$m[1]}): ?>";
}, $c);
$c = preg_replace_callback('/\{@\s*endforeach\s*@\}/s', function($m): string {
return "<?php endforeach; ?>";
}, $c);
// forループの処理
$c = preg_replace_callback('/\{@\s*for\s*\((.*?)\)\s*@\}/s', function($m): string {
return "<?php for({$m[1]}): ?>";
}, $c);
$c = preg_replace_callback('/\{@\s*endfor\s*@\}/s', function($m): string {
return "<?php endfor; ?>";
}, $c);
// if-elif-else-endifの処理
$c = preg_replace_callback('/\{@\s*if\s*\((.*?)\)\s*@\}/s', function($m): string {
return "<?php if ({$m[1]}): ?>";
}, $c);
$c = preg_replace_callback('/\{@\s*elif\s*\((.*?)\)\s*@\}/s', function($m): string {
return "<?php elseif ({$m[1]}): ?>";
}, $c);
$c = preg_replace_callback('/\{@\s*else\s*@\}/s', function($m): string {
return "<?php else: ?>";
}, $c);
$c = preg_replace_callback('/\{@\s*endif\s*@\}/s', function($m): string {
return "<?php endif; ?>";
}, $c);
return $c;
};
$previousContent = '';
$maxIterations = 10;
$iterations = 0;
while ($previousContent !== $content && $iterations < $maxIterations) {
$previousContent = $content;
$content = $processDirectives($content);
$iterations++;
}
return $content;
}
/**
* テンプレート変数を処理する
*
* @param string $content テンプレート内容
* @return string 処理後の内容
*/
private function procVars(string $content): string {
// 変数の出力(エスケープ処理なし)
$content = preg_replace_callback('/\{\{\{s*(.*?)\s*\}\}\}/', function($m): string {
return '<?= '.trim($m[1]).' ?>';
}, $content);
// 変数の出力(エスケープ処理あり)
$content = preg_replace_callback('/\{\{\s*(.*?)\s*\}\}/', function($m): string {
return '<?= htmlspecialchars('.trim($m[1]).', ENT_QUOTES, \'UTF-8\') ?>';
}, $content);
// 変数の代入
$content = preg_replace_callback('/\{\$\s*(.*?)\s*\$\}/', function($m): string {
$parts = explode('=', $m[1], 2);
if (count($parts) !== 2)
throw new \RuntimeException("不正な値の形式");
return '<?php '.trim($parts[0]).' = '.trim($parts[1]).'; ?>';
}, $content);
// コメント
$content = preg_replace_callback('/\{#\s*(.*?)\s*#\}/', function($m): string {
return '<?php /*'.trim($m[1]).'*/ ?>';
}, $content);
// PHPコードの実行
$content = preg_replace_callback('/\{\!\s*(.*?)\s*\!\}/', function($m): string {
return '<?php '.trim($m[1]).' ?>';
}, $content);
return $content;
}
/**
* カスタム関数を処理する
*
* @param string $content テンプレート内容
* @return string 処理後の内容
*/
private function procFuncs(string $content): string {
foreach ($this->custFunc as $name => $cb) {
$pattern = "/\{@\s*{$name}\((.*?)\)\s*@\}/";
$content = preg_replace_callback($pattern, function($m) use ($cb): mixed {
$args = explode(',', $m[1]);
$args = array_map('trim', $args);
return call_user_func_array($cb, $args);
}, $content);
}
return $content;
}
}

587
src/Site/Lib/Tester.php Normal file
View File

@@ -0,0 +1,587 @@
<?php
namespace Site\Lib;
/**
* 軽量なユニットテストフレームワーク
*/
class Tester {
// テスト統計
private int $testCount = 0;
private int $passCount = 0;
private int $failCount = 0;
private int $errorCount = 0;
// 現在のテストケース情報
private string $currentTestCase = '';
private string $currentTest = '';
// テストスイート設定
private bool $colorOutput = true;
private bool $verboseOutput = true;
private bool $stopOnFailure = false;
private array $beforeEachCallbacks = [];
private array $afterEachCallbacks = [];
private array $beforeAllCallbacks = [];
private array $afterAllCallbacks = [];
// ターミナル出力用の色
private array $colors = [
'reset' => "\033[0m",
'red' => "\033[31m",
'green' => "\033[32m",
'yellow' => "\033[33m",
'blue' => "\033[34m",
'magenta' => "\033[35m",
'cyan' => "\033[36m",
'white' => "\033[37m",
'bold' => "\033[1m",
];
private array $failures = [];
private array $errors = [];
/**
* コンストラクタ
*
* @param array $options 設定オプション
*/
public function __construct(array $options = []) {
// オプションを設定
if (isset($options['colorOutput'])) {
$this->colorOutput = (bool)$options['colorOutput'];
}
if (isset($options['verboseOutput'])) {
$this->verboseOutput = (bool)$options['verboseOutput'];
}
if (isset($options['stopOnFailure'])) {
$this->stopOnFailure = (bool)$options['stopOnFailure'];
}
// サポートされていない場合は色を無効にする
if (PHP_SAPI !== 'cli' || strtoupper(substr(PHP_OS, 0, 3)) == 'WIN'
&& !getenv('ANSICON')) {
$this->colorOutput = false;
}
}
/**
* 各テストの前に実行する関数を登録する
*
* @param callable $callback コールバック関数
* @return Tester このインスタンス
*/
public function beforeEach(callable $callback): Tester {
$this->beforeEachCallbacks[] = $callback;
return $this;
}
/**
* 各テストの後に実行する関数を登録する
*
* @param callable $callback コールバック関数
* @return Tester このインスタンス
*/
public function afterEach(callable $callback): Tester {
$this->afterEachCallbacks[] = $callback;
return $this;
}
/**
* すべてのテストの前に実行する関数を登録する
*
* @param callable $callback コールバック関数
* @return Tester このインスタンス
*/
public function beforeAll(callable $callback): Tester {
$this->beforeAllCallbacks[] = $callback;
return $this;
}
/**
* すべてのテストの後に実行する関数を登録する
*
* @param callable $callback コールバック関数
* @return Tester このインスタンス
*/
public function afterAll(callable $callback): Tester {
$this->afterAllCallbacks[] = $callback;
return $this;
}
/**
* テストケースを定義する
*
* @param string $description テストケースの説明
* @param callable $callback テストケース関数
* @return Tester このインスタンス
*/
public function describe(string $description, callable $callback): Tester {
$this->currentTestCase = $description;
$this->output($this->colorize('bold', "テストケース: {$description}"));
try {
foreach ($this->beforeAllCallbacks as $before) {
call_user_func($before);
}
call_user_func($callback, $this);
foreach ($this->afterAllCallbacks as $after) {
call_user_func($after);
}
} catch (\Throwable $e) {
$this->recordError(
"テストケースのセットアップ/ティアダウンでエラー: ".$e->getMessage(),
$e->getTraceAsString());
}
$this->output('');
return $this;
}
/**
* 単一のテストを実行する
*
* @param string $description テストの説明
* @param callable $callback テスト関数
* @return Tester このインスタンス
*/
public function it(string $description, callable $callback): Tester {
$this->currentTest = $description;
$this->testCount++;
if ($this->verboseOutput) {
$this->output(" ⋄ テスト中: {$description}... ", false);
}
try {
foreach ($this->beforeEachCallbacks as $before) {
call_user_func($before);
}
call_user_func($callback, $this);
foreach ($this->afterEachCallbacks as $after) {
call_user_func($after);
}
// Test has passed.
$this->passCount++;
if ($this->verboseOutput) {
$this->output($this->colorize('green', "合格"));
}
} catch (AssertionFailedException $e) {
$this->failCount++;
if ($this->verboseOutput) {
$this->output($this->colorize('red', "失敗"));
$this->output($this->colorize('red', "".$e->getMessage()));
}
$this->recordFailure($e->getMessage());
if ($this->stopOnFailure) {
$this->printSummary();
exit(1);
}
} catch (\Throwable $e) {
$this->errorCount++;
if ($this->verboseOutput) {
$this->output($this->colorize('yellow', "エラー"));
$this->output($this->colorize('yellow', "".$e->getMessage()));
}
$this->recordError($e->getMessage(), $e->getTraceAsString());
if ($this->stopOnFailure) {
$this->printSummary();
exit(1);
}
}
return $this;
}
/**
* テストをスキップする
*
* @param string $description テストの説明
* @param string $reason スキップする理由。デフォルト: "まだ実装されていません"
* @return Tester このインスタンス
*/
public function skip(string $description,
string $reason = 'まだ実装されていません'): Tester {
if ($this->verboseOutput) {
$this->output(" ⋄ スキップ: {$description}... "
.$this->colorize('cyan', "スキップ"));
$this->output($this->colorize('cyan', "{$reason}"));
}
return $this;
}
/**
* 条件がtrueである事をアサートする
*
* @param bool $condition チェックする条件
* @param string $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertTrue(bool $condition,
string $message = '条件がtrueであることを期待しました'): Tester {
if ($condition !== true) {
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 条件がfalseである事をアサートする
*
* @param bool $condition チェックする条件
* @param string $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertFalse(bool $condition,
string $message = '条件がfalseであることを期待しました'): Tester {
if ($condition !== false) {
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 二つの値が等しい事をアサートする
*
* @param mixed $expected 期待値
* @param mixed $actual 実際の値
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertEquals(mixed $expected, mixed $actual,
?string $message = null): Tester {
if ($expected != $actual) {
if ($message === null) {
$expected = $this->exportValue($expected);
$actual = $this->exportValue($actual);
$message = "{$expected}を期待しましたが、{$actual}が得られました";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 二つの値が同一である事をアサートする
*
* @param mixed $expected 期待値
* @param mixed $actual 実際の値
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertSame(mixed $expected, mixed $actual,
?string $message = null): Tester {
if ($expected !== $actual) {
if ($message === null) {
$expected = $this->exportValue($expected);
$actual = $this->exportValue($actual);
$message =
"{$expected}を期待しましたが、{$actual}が得られました(厳密な比較)";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 値がnullである事をアサートする
*
* @param mixed $actual チェックする値
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertNull(mixed $actual, ?string $message = null): Tester {
if ($actual !== null) {
if ($message === null) {
$actual = $this->exportValue($actual);
$message = "nullを期待しましたが、{$actual}が得られました";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 値がnullでない事をアサートする
*
* @param mixed $actual チェックする値
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertNotNull(mixed $actual, ?string $message = null): Tester {
if ($actual === null) {
if ($message === null) {
$message = "値がnullでない事を期待しました";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 値が特定のキーを持つ事をアサートする
*
* @param mixed $key チェックするキー
* @param array $array チェックする配列
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertArrayHasKey(mixed $key, array $array,
?string $message = null): Tester {
if (!is_array($array) && !($array instanceof \ArrayAccess)) {
throw new AssertionFailedException(
'第2引数は配列又はArrayAccessを実装している必要があります');
}
if (!array_key_exists($key, $array)) {
if ($message === null) {
$message = "配列がキー '{$key}' を持つ事を期待しました";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 文字列がサブ文字列を含むことをアサートする
*
* @param string $needle 検索するサブ文字列
* @param string $haystack 検索対象の文字列
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertStringContains(string $needle, string $haystack,
?string $message = null): Tester {
if (!is_string($needle) || !is_string($haystack)) {
throw new AssertionFailedException('両方の引数は文字列である必要があります');
}
if (strpos($haystack, $needle) === false) {
if ($message === null) {
$message = "文字列 '{$haystack}' が '{$needle}' を含む事を期待しました";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* コールバックが例外をスローする事をアサートする
*
* @param callable $callback 実行するコールバック
* @param string $exceptionClass 期待される例外クラス
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertThrows(callable $callback, string $exceptionClass,
?string $message = null): Tester {
try {
call_user_func($callback);
if ($message === null) {
$message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした";
}
throw new AssertionFailedException($message);
} catch (\Throwable $e) {
if (!($e instanceof $exceptionClass)) {
if ($message === null) {
$message = "'{$exceptionClass}' 型の例外を期待しましたが、"
.get_class($e)." が得られました";
}
throw new AssertionFailedException($message);
}
}
return $this;
}
/**
* Print a summary of the test results
*
* @return Tester
*/
public function printSummary(): Tester {
$this->output('');
$this->output($this->colorize('bold', "テスト結果の概要:"));
$this->output(" テスト総数: {$this->testCount}");
$this->output(" ".$this->colorize('green', "合格: {$this->passCount}"));
if ($this->failCount > 0) {
$this->output(" ".$this->colorize('red', "失敗: {$this->failCount}"));
} else {
$this->output(" 失敗: 0");
}
if ($this->errorCount > 0) {
$this->output(" ".$this->colorize('yellow', "エラー: {$this->errorCount}"));
} else {
$this->output(" エラー: 0");
}
$this->output('');
// 失敗を書き出す
if (count($this->failures) > 0) {
$this->output($this->colorize('bold', "失敗:"));
foreach ($this->failures as $i => $f) {
$num = $i + 1;
$this->output(" {$num}) {$f['testCase']}{$f['test']}");
$this->output(" ".$this->colorize('red', $f['message']));
$this->output('');
}
}
// エラーを書き出す
if (count($this->errors) > 0) {
$this->output($this->colorize('bold', "エラー:"));
foreach ($this->errors as $i => $e) {
$num = $i + 1;
$this->output(" {$num}) {$e['testCase']}{$e['test']}");
$this->output(" ".$this->colorize('yellow', $e['message']));
if (isset($e['trace'])) {
$this->output(" ".$this->colorize('yellow', "スタックトレース:"));
$this->output(" ".$this->colorize('yellow', $e['trace']));
}
$this->output('');
}
}
if ($this->failCount === 0 && $this->errorCount === 0) {
$this->output($this->colorize('green', "全てのテストに合格しました!"));
} else {
$this->output($this->colorize('red', "テストが失敗・エラーで完了しました。"));
}
return $this;
}
// 機能性メソッド
/**
* コンソールにテキストを出力する
*
* @param string $text 出力するテキスト
* @param bool $newline 改行を追加するかどうか
* @return void
*/
private function output(string $text, bool $newline = true): void {
echo $text.($newline ? PHP_EOL : '');
}
/**
* 有効な場合はテキストに色を適用する
*
* @param string $color 色名
* @param string $text 色付けするテキスト
* @return string
*/
private function colorize(string $color, string $text): string {
if (!$this->colorOutput || !isset($this->colors[$color])) {
return $text;
}
return $this->colors[$color].$text.$this->colors['reset'];
}
/**
* 値を表示用の文字列としてエクスポートする
*
* @param mixed $value エクスポートする値
* @return string
*/
private function exportValue(mixed $value): string {
if (is_null($value)) return 'null';
if (is_bool($value)) return $value ? 'true' : 'false';
if (is_array($value)) return 'Array('.count($value).')';
if (is_object($value)) return get_class($value).' Object';
if (is_string($value)) {
if (strlen($value) > 40) {
return "'".substr($value, 0, 37)."...'";
}
return "'{$value}'";
}
return (string)$value;
}
/**
* テストの失敗を記録する
*
* @param string $message 失敗メッセージ
* @return void
*/
private function recordFailure(string $message): void {
$this->failures[] = [
'testCase' => $this->currentTestCase,
'test' => $this->currentTest,
'message' => $message,
];
}
/**
* テストのエラーを記録する
*
* @param string $message エラーメッセージ
* @param string|null $trace スタックトレース
* @return void
*/
private function recordError(string $message, ?string $trace = null): void {
$this->errors[] = [
'testCase' => $this->currentTestCase,
'test' => $this->currentTest,
'message' => $message,
'trace' => $trace,
];
}
}
/**
* アサーション失敗用のカスタム例外
*/
class AssertionFailedException extends \Exception {
}