diff --git a/README-JP.md b/README-JP.md index d96ab71..7791492 100644 --- a/README-JP.md +++ b/README-JP.md @@ -1,188 +1,188 @@ -# Little Beast -シンプル、実践的、アンチ・ブロート - -## Little Beast とは? -Little Beast は PHP 8.3 以上向けのフレームワークで、076.moe(ゲーム開発会社)と technicalsuwako.moe(社長のブログ)向けに作られました。\ -メイン考え方は「必要な物だけ拡張し、不要な物は削除する」です。\ -各コア機能はライブラリに分割されている為、必要な物だけを選び易い設計になっています。\ -全てのモジュールはゼロから書かれており、データベースは一切必要ありません。 - -Little Beast はテクニカル諏訪子がゲーム開発に完全復帰する前の最後の Web プロジェクトです。 - -## Little Beast が「ではない」物 -* 汎用フレームワーク -* 使い憎い -* Web 開発者向け -* 万人向け -* インストールが面倒 -* 教条的 -* 民主的に運営される - -## Little Beast が「持っていない」物 -* データベース(全てファイルベース) -* 依存関係 -* パッケージマネージャー -* Docker/Kubernetes/Vagrant/Nix 等のコンテナ -* JavaScript -* ブロートウェア -* ORM -* 認証/認可システム -* キューやバックグラウンドジョブ -* クラウド/サーバーレス統合 -* スキャフォールディング -* コード生成 -* 抽象化レイヤー -* 不要なファイル -* DEI、行動規範、その他の差別的慣行 - -## 独自機能 -* データベース不要 -* Composer や PEAR 不要 -* サーバーオーバーヘッドゼロ -* コンテナ不要 -* Maron テンプレートエンジン -* カスタム Markdown -* ActivityPub -* 多言語対応 -* マルチブログ対応 -* 100% 綺麗で正しい HTML5 と CSS3 -* 100% 正しい PHP -* SEO フレンドリー -* モジュラー CSS -* Atom フィード -* 組み込みテストスイート - -## インストール方法 -```sh -cd /var/www/htdocs -git clone https://github.com/TechnicalSuwako/LittleBeast.git . -mv config/config.sample.php config/config.php -``` - -HTTP サーバーを設定して `/public` をルートとして `php-fpm` で実行して下さい。\ -それだけです! - -### OpenBSD サーバー -```sh -pkg_add php-8.4.14 php-gmp-8.4.14 -rcctl enable php84_fpm httpd relayd -rcctl start php84_fpm httpd relayd -``` - -#### httpd -``` -server "technicalsuwako.moe" { - listen on * tls port 8443 - gzip-static - tls { - certificate "/etc/ssl/technicalsuwako.moe.crt" - key "/etc/ssl/private/technicalsuwako.moe.key" - } - root "/htdocs/technicalsuwako.moe/www/public" - directory index "index.php" - location "/.well-known/acme-challenge/*" { - root "/acme" - request strip 2 - } - - location "/*.php" { - fastcgi socket "/run/php-fpm.sock" - } - - location "/*.php[/?]*" { - fastcgi socket "/run/php-fpm.sock" - } - - location "/" { - directory index "index.php" - } - location match "/blog/" { - request rewrite "/index.php" - } - location match "/about" { - request rewrite "/index.php" - } - location match "/monero" { - request rewrite "/index.php" - } - location match "/secret" { - request rewrite "/index.php" - } - location match "/ap/" { - request rewrite "/index.php" - } - location "/.well-known/webfinger" { - request rewrite "/index.php" - } - location "/blog.atom" { - request rewrite "/index.php" - } -} - -server "www.technicalsuwako.moe" { - listen on * tls port 8443 - gzip-static - tls { - certificate "/etc/ssl/technicalsuwako.moe.crt" - key "/etc/ssl/private/technicalsuwako.moe.key" - } - block return 301 "https://technicalsuwako.moe$REQUEST_URI" -} -``` - -#### relayd -``` -relayd_addr="0.0.0.0" -router_addr="192.168.10.106" - -table { $router_addr } - -http protocol reverse { - tcp { nodelay, sack, socket buffer 65536, backlog 100 } - tls ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256" - tls keypair "technicalsuwako.moe" - - return error - - match request header append "X-Forwarded-For" value "$REMOTE_ADDR" - match request header append "X-Forwarded-Port" value "$REMOTE_PORT" - - #match response header set "Referrer-Policy" value "same-origin" - match response header set "X-Frame-Options" value "deny" - match response header set "X-Content-Type-Options" value "nosniff" - match response header set "Referrer-Policy" value "strict-origin-when-cross-origin" - match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload" - match response header set "Cross-Origin-Opener-Policy" value "same-origin" - match response header set "Content-Security-Policy" value "img-src 'self' https://*.076.moe http://*.076.moe https://*.technicalsuwako.moe http://*.technicalsuwako.moe; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; object-src 'none';" - match response header append "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=()" - - match request header "Accept-Encoding" value "gzip" tag "gzip" - match response header "Content-Type" value "text/*" tag "compress" - match response header "Content-Type" value "text/html" tag "charset=UTF-8" - match response header "Content-Type" value "application/javascript" tag "compress" - match response header "Content-Type" value "application/json" tag "compress" - - pass request quick header "Host" value "technicalsuwako.moe" forward to - - pass -} - -relay www_tls { - listen on $relayd_addr port 443 tls - protocol reverse - - # Default - forward to port 8443 check tcp -} - -relay www_www { - listen on $relayd_addr port 80 - protocol reverse - - # Default - forward to port 8080 check tcp -} -``` - -## 必要な PHP モジュール -* php_gmp +# Little Beast +シンプル、実践的、アンチ・ブロート + +## Little Beast とは? +Little Beast は PHP 8.3 以上向けのフレームワークで、076.moe(ゲーム開発会社)と technicalsuwako.moe(社長のブログ)向けに作られました。\ +メイン考え方は「必要な物だけ拡張し、不要な物は削除する」です。\ +各コア機能はライブラリに分割されている為、必要な物だけを選び易い設計になっています。\ +全てのモジュールはゼロから書かれており、データベースは一切必要ありません。 + +Little Beast はテクニカル諏訪子がゲーム開発に完全復帰する前の最後の Web プロジェクトです。 + +## Little Beast が「ではない」物 +* 汎用フレームワーク +* 使い憎い +* Web 開発者向け +* 万人向け +* インストールが面倒 +* 教条的 +* 民主的に運営される + +## Little Beast が「持っていない」物 +* データベース(全てファイルベース) +* 依存関係 +* パッケージマネージャー +* Docker/Kubernetes/Vagrant/Nix 等のコンテナ +* JavaScript +* ブロートウェア +* ORM +* 認証/認可システム +* キューやバックグラウンドジョブ +* クラウド/サーバーレス統合 +* スキャフォールディング +* コード生成 +* 抽象化レイヤー +* 不要なファイル +* DEI、行動規範、その他の差別的慣行 + +## 独自機能 +* データベース不要 +* Composer や PEAR 不要 +* サーバーオーバーヘッドゼロ +* コンテナ不要 +* Maron テンプレートエンジン +* カスタム Markdown +* ActivityPub +* 多言語対応 +* マルチブログ対応 +* 100% 綺麗で正しい HTML5 と CSS3 +* 100% 正しい PHP +* SEO フレンドリー +* モジュラー CSS +* Atom フィード +* 組み込みテストスイート + +## インストール方法 +```sh +cd /var/www/htdocs +git clone https://github.com/TechnicalSuwako/LittleBeast.git . +mv config/config.sample.php config/config.php +``` + +HTTP サーバーを設定して `/public` をルートとして `php-fpm` で実行して下さい。\ +それだけです! + +### OpenBSD サーバー +```sh +pkg_add php-8.4.14 php-gmp-8.4.14 +rcctl enable php84_fpm httpd relayd +rcctl start php84_fpm httpd relayd +``` + +#### httpd +``` +server "technicalsuwako.moe" { + listen on * tls port 8443 + gzip-static + tls { + certificate "/etc/ssl/technicalsuwako.moe.crt" + key "/etc/ssl/private/technicalsuwako.moe.key" + } + root "/htdocs/technicalsuwako.moe/www/public" + directory index "index.php" + location "/.well-known/acme-challenge/*" { + root "/acme" + request strip 2 + } + + location "/*.php" { + fastcgi socket "/run/php-fpm.sock" + } + + location "/*.php[/?]*" { + fastcgi socket "/run/php-fpm.sock" + } + + location "/" { + directory index "index.php" + } + location match "/blog/" { + request rewrite "/index.php" + } + location match "/about" { + request rewrite "/index.php" + } + location match "/monero" { + request rewrite "/index.php" + } + location match "/secret" { + request rewrite "/index.php" + } + location match "/ap/" { + request rewrite "/index.php" + } + location "/.well-known/webfinger" { + request rewrite "/index.php" + } + location "/blog.atom" { + request rewrite "/index.php" + } +} + +server "www.technicalsuwako.moe" { + listen on * tls port 8443 + gzip-static + tls { + certificate "/etc/ssl/technicalsuwako.moe.crt" + key "/etc/ssl/private/technicalsuwako.moe.key" + } + block return 301 "https://technicalsuwako.moe$REQUEST_URI" +} +``` + +#### relayd +``` +relayd_addr="0.0.0.0" +router_addr="192.168.10.106" + +table { $router_addr } + +http protocol reverse { + tcp { nodelay, sack, socket buffer 65536, backlog 100 } + tls ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256" + tls keypair "technicalsuwako.moe" + + return error + + match request header append "X-Forwarded-For" value "$REMOTE_ADDR" + match request header append "X-Forwarded-Port" value "$REMOTE_PORT" + + #match response header set "Referrer-Policy" value "same-origin" + match response header set "X-Frame-Options" value "deny" + match response header set "X-Content-Type-Options" value "nosniff" + match response header set "Referrer-Policy" value "strict-origin-when-cross-origin" + match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload" + match response header set "Cross-Origin-Opener-Policy" value "same-origin" + match response header set "Content-Security-Policy" value "img-src 'self' https://*.076.moe http://*.076.moe https://*.technicalsuwako.moe http://*.technicalsuwako.moe; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; object-src 'none';" + match response header append "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=()" + + match request header "Accept-Encoding" value "gzip" tag "gzip" + match response header "Content-Type" value "text/*" tag "compress" + match response header "Content-Type" value "text/html" tag "charset=UTF-8" + match response header "Content-Type" value "application/javascript" tag "compress" + match response header "Content-Type" value "application/json" tag "compress" + + pass request quick header "Host" value "technicalsuwako.moe" forward to + + pass +} + +relay www_tls { + listen on $relayd_addr port 443 tls + protocol reverse + + # Default + forward to port 8443 check tcp +} + +relay www_www { + listen on $relayd_addr port 80 + protocol reverse + + # Default + forward to port 8080 check tcp +} +``` + +## 必要な PHP モジュール +* php_gmp diff --git a/blog/table-sample.md b/blog/table-sample.md index 9dbf114..7702d9b 100644 --- a/blog/table-sample.md +++ b/blog/table-sample.md @@ -1,22 +1,22 @@ -title: テーブルの例 -uuid: 684d234e-f0d6-4da0-8f38-71c7105262d5 -author: Little-san -date: 2025-04-11 08:50:57 -category: test,css -css: table ----- -テーブルの例だ。 -複数CSSファイルを含むには、コンマで分けて下さい。 -例えば: `css: table,search` - -## プログラミング言語のランキング -| プログラミング言語 | ランキング | 理由 | -|-------|-----|----| -| C | ★★★★★ | どこでも使える | -| C++ | ★★★★★ | たーのし~ | -| PHP | ★★★★☆ | Little BeastをPHPで作ったから | -| Go | ★★★☆☆ | 言語は問題ないけど、会社は親DEI・・・ | -| Ruby | ★☆☆☆☆ | 遅過ぎる | -| Javascript | ★☆☆☆☆ | 🤡 | -| Rust | ★☆☆☆☆ | ゲイ!! | +title: テーブルの例 +uuid: 684d234e-f0d6-4da0-8f38-71c7105262d5 +author: Little-san +date: 2025-04-11 08:50:57 +category: test,css +css: table +---- +テーブルの例だ。 +複数CSSファイルを含むには、コンマで分けて下さい。 +例えば: `css: table,search` + +## プログラミング言語のランキング +| プログラミング言語 | ランキング | 理由 | +|-------|-----|----| +| C | ★★★★★ | どこでも使える | +| C++ | ★★★★★ | たーのし~ | +| PHP | ★★★★☆ | Little BeastをPHPで作ったから | +| Go | ★★★☆☆ | 言語は問題ないけど、会社は親DEI・・・ | +| Ruby | ★☆☆☆☆ | 遅過ぎる | +| Javascript | ★☆☆☆☆ | 🤡 | +| Rust | ★☆☆☆☆ | ゲイ!! | | Zig | ★★★☆☆ | 良いけど、未だ開発中 | \ No newline at end of file diff --git a/public/static/style-algebra.css b/public/static/style-algebra.css index ee9ac2c..2616851 100644 --- a/public/static/style-algebra.css +++ b/public/static/style-algebra.css @@ -1,20 +1,20 @@ -.fraction { - display: inline-block; - vertical-align: middle; - text-align: center; - position: relative; - margin: 0 0.2em; -} - -.fraction .numerator, .fraction .denominator { - display: block; - font-size: 0.8em; -} - -.fraction .numerator { - border-bottom: 1px solid #fcfcfc; -} - -.algebraic { - font-family: 'Times New Roman', serif; +.fraction { + display: inline-block; + vertical-align: middle; + text-align: center; + position: relative; + margin: 0 0.2em; +} + +.fraction .numerator, .fraction .denominator { + display: block; + font-size: 0.8em; +} + +.fraction .numerator { + border-bottom: 1px solid #fcfcfc; +} + +.algebraic { + font-family: 'Times New Roman', serif; } \ No newline at end of file diff --git a/public/static/style-blink.css b/public/static/style-blink.css index 6dd902f..a128ac9 100644 --- a/public/static/style-blink.css +++ b/public/static/style-blink.css @@ -1,9 +1,9 @@ -.blink { - animation: blinker 1s linear infinite; -} - -@keyframes blinker { - 50% { - opacity: 0; - } +.blink { + animation: blinker 1s linear infinite; +} + +@keyframes blinker { + 50% { + opacity: 0; + } } \ No newline at end of file diff --git a/public/static/style-blockquote.css b/public/static/style-blockquote.css index 0b8f70f..73ff7fd 100644 --- a/public/static/style-blockquote.css +++ b/public/static/style-blockquote.css @@ -1,8 +1,8 @@ -blockquote { - background: #121012; - border: 2px solid #f545f5; - border-radius: 2px; - border-left: 12px solid #c016c6; - margin: 1.5em 10px; - padding: 0.5em 10px; +blockquote { + background: #121012; + border: 2px solid #f545f5; + border-radius: 2px; + border-left: 12px solid #c016c6; + margin: 1.5em 10px; + padding: 0.5em 10px; } \ No newline at end of file diff --git a/public/static/style-blogtype.css b/public/static/style-blogtype.css index 953866f..91e68f5 100644 --- a/public/static/style-blogtype.css +++ b/public/static/style-blogtype.css @@ -1,36 +1,36 @@ -.blog-type > p { - font-weight: bolder; -} - -a.blog-type-btn { - background-color: #550f75; - color: #120f12; - text-decoration: none; - border: 1px solid #fcfcfc; - border-radius: 4px; - padding: 4px; - margin: 4px; - transition: background-color 0.9s; -} - -a.blog-type-btn.active { - background-color: #c016c6; -} - -hr.blog-type-line { - border: 3px dotted #c016c6; -} - -a.blog-type-btn:hover { - background-color: #ea79d8; -} - -a.blog-type-btn.active:ae6bdb { - background-color: #ae6bdb; -} - -@media only screen and (max-width: 768px) { - a.blog-type-btn { - display: block; - } +.blog-type > p { + font-weight: bolder; +} + +a.blog-type-btn { + background-color: #550f75; + color: #120f12; + text-decoration: none; + border: 1px solid #fcfcfc; + border-radius: 4px; + padding: 4px; + margin: 4px; + transition: background-color 0.9s; +} + +a.blog-type-btn.active { + background-color: #c016c6; +} + +hr.blog-type-line { + border: 3px dotted #c016c6; +} + +a.blog-type-btn:hover { + background-color: #ea79d8; +} + +a.blog-type-btn.active:ae6bdb { + background-color: #ae6bdb; +} + +@media only screen and (max-width: 768px) { + a.blog-type-btn { + display: block; + } } \ No newline at end of file diff --git a/public/static/style-diffviewer.css b/public/static/style-diffviewer.css index dd89060..7c626c7 100644 --- a/public/static/style-diffviewer.css +++ b/public/static/style-diffviewer.css @@ -1,40 +1,40 @@ -.diff-table { - width: 100%; - border-collapse: collapse; - font-family: monospace; - color: #fcfcfc; -} - -.diff-table td { - border: 1px solid #bcb4bc; - padding: 5px; - vertical-align: top; -} - -.diff-header th { - border: 1px solid #bcb4bc; -} - -.line-number { - width: 50px; - text-align: right; - color: #c016c6; -} - -.removed { - background-color: #fa9faa; - color: #b61729; -} - -.added { - background-color: #88ecc1; - color: #2c980c; -} - -.context { - background-color: #232320; -} - -.empty { - background-color: #746c75; +.diff-table { + width: 100%; + border-collapse: collapse; + font-family: monospace; + color: #fcfcfc; +} + +.diff-table td { + border: 1px solid #bcb4bc; + padding: 5px; + vertical-align: top; +} + +.diff-header th { + border: 1px solid #bcb4bc; +} + +.line-number { + width: 50px; + text-align: right; + color: #c016c6; +} + +.removed { + background-color: #fa9faa; + color: #b61729; +} + +.added { + background-color: #88ecc1; + color: #2c980c; +} + +.context { + background-color: #232320; +} + +.empty { + background-color: #746c75; } \ No newline at end of file diff --git a/public/static/style-tutorial.css b/public/static/style-tutorial.css index 3ab25f2..5521989 100644 --- a/public/static/style-tutorial.css +++ b/public/static/style-tutorial.css @@ -1,16 +1,16 @@ -hr { - border: 1px solid #ea79d8; -} - -.pager { - display: grid; - grid-template-columns: repeat(2, 1fr); -} - -.prev { - text-align: left; -} - -.next { - text-align: right; +hr { + border: 1px solid #ea79d8; +} + +.pager { + display: grid; + grid-template-columns: repeat(2, 1fr); +} + +.prev { + text-align: left; +} + +.next { + text-align: right; } \ No newline at end of file diff --git a/src/Site/Controller/Atom.php b/src/Site/Controller/Atom.php index 7b0843c..cbb5d62 100644 --- a/src/Site/Controller/Atom.php +++ b/src/Site/Controller/Atom.php @@ -1,94 +1,94 @@ -getPosts('/blog/'); - // 最新の5件に制限 - $posts = array_slice($posts, 0, 5); - - // サイトのドメインを取得 - $domain = $_SERVER['HTTP_HOST']; - $baseUrl = 'https://'.$domain; - - // 現在の日時(RFC3339形式) - $published = date('c'); - - // XMLヘッダーとコンテンツタイプを設定 - header('Content-Type: application/atom+xml; charset=utf-8'); - - // Atomフィードの開始部分 - echo ''."\n"; - echo ''."\n"; - - // フィードの基本情報 - echo ' '.SITEINFO['title'].''."\n"; - echo ' '."\n"; - echo ' '."\n"; - echo ' '.$baseUrl.'/'."\n"; - echo ' '.$published.''."\n"; - echo ' '.$published.''."\n"; - echo ' '."\n"; - echo ' '.SITEINFO['title'].''."\n"; - echo ' '."\n"; - - // 各エントリー(記事) - foreach ($posts as $post) { - // 記事の本文を取得(プレーンテキスト) - $path = ROOT.'/blog/'.$post['slug'].'.md'; - $content = ''; - $postPublished = date('c', strtotime($post['date'])); - - if (file_exists($path)) { - $fileContent = file_get_contents($path); - $parts = explode('----', $fileContent, 2); - if (count($parts) > 1) { - // 本文をHTMLとして準備 - $md = new Markdown($post['slug'], '/blog/'); - $content = $md->parse(); - // HTMLタグを取り除かないようにCDATAで囲む - $content = ''; - } - } - - echo ' '."\n"; - echo ' '.htmlspecialchars($post['title']).''."\n"; - echo ' '."\n"; - echo ' '.$baseUrl.'/blog/'.$post['slug'].''."\n"; - echo ' '.$postPublished.''."\n"; - - // カテゴリ(タグ) - if (isset($post['category']) && is_array($post['category'])) { - foreach ($post['category'] as $category) { - echo ' '."\n"; - } - } - - // 本文(要約または全文) - echo ' '.$content.''."\n"; - echo ' '."\n"; - } - - // フィードの終了 - echo ''; - exit; - } catch (\Exception $e) { - header('Content-Type: text/plain; charset=utf-8'); - echo 'フィードの作成に失敗: '.$e->getMessage(); - exit; - } - } -} +getPosts('/blog/'); + // 最新の5件に制限 + $posts = array_slice($posts, 0, 5); + + // サイトのドメインを取得 + $domain = $_SERVER['HTTP_HOST']; + $baseUrl = 'https://'.$domain; + + // 現在の日時(RFC3339形式) + $published = date('c'); + + // XMLヘッダーとコンテンツタイプを設定 + header('Content-Type: application/atom+xml; charset=utf-8'); + + // Atomフィードの開始部分 + echo ''."\n"; + echo ''."\n"; + + // フィードの基本情報 + echo ' '.SITEINFO['title'].''."\n"; + echo ' '."\n"; + echo ' '."\n"; + echo ' '.$baseUrl.'/'."\n"; + echo ' '.$published.''."\n"; + echo ' '.$published.''."\n"; + echo ' '."\n"; + echo ' '.SITEINFO['title'].''."\n"; + echo ' '."\n"; + + // 各エントリー(記事) + foreach ($posts as $post) { + // 記事の本文を取得(プレーンテキスト) + $path = ROOT.'/blog/'.$post['slug'].'.md'; + $content = ''; + $postPublished = date('c', strtotime($post['date'])); + + if (file_exists($path)) { + $fileContent = file_get_contents($path); + $parts = explode('----', $fileContent, 2); + if (count($parts) > 1) { + // 本文をHTMLとして準備 + $md = new Markdown($post['slug'], '/blog/'); + $content = $md->parse(); + // HTMLタグを取り除かないようにCDATAで囲む + $content = ''; + } + } + + echo ' '."\n"; + echo ' '.htmlspecialchars($post['title']).''."\n"; + echo ' '."\n"; + echo ' '.$baseUrl.'/blog/'.$post['slug'].''."\n"; + echo ' '.$postPublished.''."\n"; + + // カテゴリ(タグ) + if (isset($post['category']) && is_array($post['category'])) { + foreach ($post['category'] as $category) { + echo ' '."\n"; + } + } + + // 本文(要約または全文) + echo ' '.$content.''."\n"; + echo ' '."\n"; + } + + // フィードの終了 + echo ''; + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フィードの作成に失敗: '.$e->getMessage(); + exit; + } + } +} diff --git a/src/Site/Controller/BlogPost.php b/src/Site/Controller/BlogPost.php index 4f96af1..5915ad5 100644 --- a/src/Site/Controller/BlogPost.php +++ b/src/Site/Controller/BlogPost.php @@ -1,66 +1,66 @@ - $metadata['title'] ?? '', - 'date' => $metadata['date'] ?? '', - 'thumbnail' => $metadata['thumbnail'] ?? '', - 'thumborient' => $metadata['thumborient'] ?? '', - 'category' => $metadata['category'] ?? [], - 'uuid' => $metadata['uuid'] ?? '', - 'preview' => $preview, - 'slug' => $slug, - ]; - } - - // 日付でソート(新しい順) - usort($posts, function($a, $b) { - return strtotime($b['date']) - strtotime($a['date']); - }); - - return $posts; - } -} + $metadata['title'] ?? '', + 'date' => $metadata['date'] ?? '', + 'thumbnail' => $metadata['thumbnail'] ?? '', + 'thumborient' => $metadata['thumborient'] ?? '', + 'category' => $metadata['category'] ?? [], + 'uuid' => $metadata['uuid'] ?? '', + 'preview' => $preview, + 'slug' => $slug, + ]; + } + + // 日付でソート(新しい順) + usort($posts, function($a, $b) { + return strtotime($b['date']) - strtotime($a['date']); + }); + + return $posts; + } +} diff --git a/src/Site/Controller/Fediverse.php b/src/Site/Controller/Fediverse.php index d8e7108..e422a67 100644 --- a/src/Site/Controller/Fediverse.php +++ b/src/Site/Controller/Fediverse.php @@ -1,151 +1,151 @@ -getWebfinger(); - exit; - } catch (\Exception $e) { - header('Content-Type: text/plain; charset=utf-8'); - echo 'フェディバースの作成に失敗: '.$e->getMessage(); - exit; - } - } - - /** - * @param array $params パラメータ配列 - * @return void - */ - public function apactor(array $params): void { - try { - header('Content-Type: application/activity+json'); - $ap = new Activitypub(); - echo $ap->getActor(); - exit; - } catch (\Exception $e) { - header('Content-Type: text/plain; charset=utf-8'); - echo 'フェディバースの作成に失敗: '.$e->getMessage(); - exit; - } - } - - /** - * @param array $params パラメータ配列 - * @return void - */ - public function apinbox(array $params): void { - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - header('HTTP/1.1 405 Method Not Allowed'); - header('Allow: POST'); - exit; - } - - $input = file_get_contents('php://input'); - $activity = json_decode($input, true); - if (!$activity || !isset($activity['type'])) { - header('HTTP/1.1 400 Bad Request'); - header('Content-Type: application/activity+json'); - echo json_encode(['error' => '不正なアクティビティ']); - exit; - } - - logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity)); - - try { - header('Content-Type: application/activity+json'); - $ap = new Activitypub(); - $ap->postInbox($activity); - exit; - } catch (\Exception $e) { - header('Content-Type: text/plain; charset=utf-8'); - echo 'フェディバースの作成に失敗: '.$e->getMessage(); - exit; - } - } - - /** - * @param array $params パラメータ配列 - * @return void - */ - public function apactivity(array $params): void { - $uuid = ''; - if (isset($params['uuid'])) $uuid = $params['uuid']; - - try { - header('Content-Type: application/activity+json'); - $posts = $this->getPosts('/blog/'); - $ap = new Activitypub($posts); - echo $ap->getActivity($uuid); - exit; - } catch (\Exception $e) { - header('Content-Type: text/plain; charset=utf-8'); - echo 'フェディバースの作成に失敗: '.$e->getMessage(); - exit; - } - } - - /** - * @param array $params パラメータ配列 - * @return void - */ - public function apoutbox(array $params): void { - try { - header('Content-Type: application/activity+json'); - $posts = $this->getPosts('/blog/'); - $ap = new Activitypub($posts); - echo $ap->getOutbox(); - exit; - } catch (\Exception $e) { - header('Content-Type: text/plain; charset=utf-8'); - echo 'フェディバースの作成に失敗: '.$e->getMessage(); - exit; - } - } - - /** - * @param array $params パラメータ配列 - * @return void - */ - public function apfollowers(array $params): void { - try { - header('Content-Type: application/activity+json'); - $ap = new Activitypub(); - echo $ap->getFollowers(); - exit; - } catch (\Exception $e) { - header('Content-Type: text/plain; charset=utf-8'); - echo 'フェディバースの作成に失敗: '.$e->getMessage(); - exit; - } - } - - /** - * @param array $params パラメータ配列 - * @return void - */ - public function apfollowing(array $params): void { - try { - header('Content-Type: application/activity+json'); - $ap = new Activitypub(); - echo $ap->getFollowing(); - exit; - } catch (\Exception $e) { - header('Content-Type: text/plain; charset=utf-8'); - echo 'フェディバースの作成に失敗: '.$e->getMessage(); - exit; - } - } -} +getWebfinger(); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apactor(array $params): void { + try { + header('Content-Type: application/activity+json'); + $ap = new Activitypub(); + echo $ap->getActor(); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apinbox(array $params): void { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('HTTP/1.1 405 Method Not Allowed'); + header('Allow: POST'); + exit; + } + + $input = file_get_contents('php://input'); + $activity = json_decode($input, true); + if (!$activity || !isset($activity['type'])) { + header('HTTP/1.1 400 Bad Request'); + header('Content-Type: application/activity+json'); + echo json_encode(['error' => '不正なアクティビティ']); + exit; + } + + logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity)); + + try { + header('Content-Type: application/activity+json'); + $ap = new Activitypub(); + $ap->postInbox($activity); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apactivity(array $params): void { + $uuid = ''; + if (isset($params['uuid'])) $uuid = $params['uuid']; + + try { + header('Content-Type: application/activity+json'); + $posts = $this->getPosts('/blog/'); + $ap = new Activitypub($posts); + echo $ap->getActivity($uuid); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apoutbox(array $params): void { + try { + header('Content-Type: application/activity+json'); + $posts = $this->getPosts('/blog/'); + $ap = new Activitypub($posts); + echo $ap->getOutbox(); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apfollowers(array $params): void { + try { + header('Content-Type: application/activity+json'); + $ap = new Activitypub(); + echo $ap->getFollowers(); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apfollowing(array $params): void { + try { + header('Content-Type: application/activity+json'); + $ap = new Activitypub(); + echo $ap->getFollowing(); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } +} diff --git a/src/Site/Lib/DiffViewer.php b/src/Site/Lib/DiffViewer.php index fad3110..07f8b82 100644 --- a/src/Site/Lib/DiffViewer.php +++ b/src/Site/Lib/DiffViewer.php @@ -1,130 +1,130 @@ -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 .= "

ファイル: ".htmlspecialchars($fileName)."

\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 = ''; - $html .= ''; - - $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 .= ''; - // 左(変更前) - $html .= ''; - $html .= ''; - // 右(変更後) - $html .= ''; - $html .= ''; - $html .= ''; - } - - $html .= '
' . ($left['line'] ?: ' ') . '' . ($left['content'] ?: ' ') . '' . ($right['line'] ?: ' ') . '' . ($right['content'] ?: ' ') . '
'; - return $html; - } +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 .= "

ファイル: ".htmlspecialchars($fileName)."

\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 = ''; + $html .= ''; + + $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 .= ''; + // 左(変更前) + $html .= ''; + $html .= ''; + // 右(変更後) + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
' . ($left['line'] ?: ' ') . '' . ($left['content'] ?: ' ') . '' . ($right['line'] ?: ' ') . '' . ($right['content'] ?: ' ') . '
'; + return $html; + } } \ No newline at end of file diff --git a/src/Site/Lib/Mysql.php b/src/Site/Lib/Mysql.php index 686ab8e..381deef 100644 --- a/src/Site/Lib/Mysql.php +++ b/src/Site/Lib/Mysql.php @@ -1,1559 +1,1559 @@ -host = DBINFO['host']; - $this->username = DBINFO['username']; - $this->password = DBINFO['password']; - $this->dbname = DBINFO['dbname']; - $this->port = DBINFO['port']; - $this->debug = DBINFO['debug']; - } - - /** - * デストラクタ - * - * オブジェクト破棄時に接続を閉じます。 - */ - public function __destruct() { - $this->close(); - } - - /** - * デバッギングの有無 - * - * @param bool $debug デバッグモードを有効にするかどうか - * @return Mysql 自身を返す(メソッドチェーン用) - */ - public function setDebug(bool $debug): Mysql { - $this->debug = (bool)$debug; - return $this; - } - - /** - * パケットログの取得 - * - * @return array 送信および受信したパケットのログ - */ - public function getPacketLog(): array { - return $this->packetLog; - } - - /** - * MySQLサーバーに接続 - * - * ソケットを作成し、サーバーに接続後、認証とデータベース選択を行います。 - * - * @return bool 接続成功時はtrue - * @throws \Exception 接続または認証に失敗した場合 - */ - public function connect(): bool { - if (!MYSQL_ENABLED) return false; - - $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); - if ($this->socket === false) { - $msg = 'ソケットの作成に失敗: '.socket_strerror(socket_last_error()); - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - $res = socket_connect($this->socket, $this->host, $this->port); - if ($res === false) { - $msg = 'ソケットに接続に失敗: ' - .socket_strerror(socket_last_error($this->socket)); - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - $greeting = $this->readPacket(); - $this->parseServerGreeting($greeting); - $this->authenticate(); - $response = $this->readPacket(); - - if (ord($response[0]) !== 0x00) { - $code = unpack('v', substr($response, 1, 2))[1]; - $mes = substr($response, 3); - $msg = "認証応答に失敗: {$code} - {$mes}"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - if (!empty($this->dbname)) { - $this->selectDatabase($this->dbname); - } - - $this->connected = true; - return true; - } - - /** - * 接続を閉じる - * - * COM_QUITコマンドを送信し、ソケットを閉じます。 - * - * @return void - */ - public function close(): void { - if (!MYSQL_ENABLED) return; - - if ($this->socket) { - $this->sendCommand(0x01); // COM_QUIT - socket_close($this->socket); - $this->socket = null; - $this->connected = false; - } - } - - /** - * 利用するデータベースを選択する - * - * COM_INIT_DBコマンドを使用してデータベースを選択します。 - * - * @param string $database データベース名 - * @return bool 成功時はtrue - * @throws \Exception データベース選択に失敗した場合 - */ - public function selectDatabase(string $database): bool { - if (!MYSQL_ENABLED) return false; - - $this->sendCommand(0x02, $database); // COM_INIT_DB - $res = $this->readPacket(); - - if (ord($res[0]) === 0xFF) { - $code = unpack('v', substr($res, 1, 2))[1]; - $mes = substr($res, 3); - $msg = "データベースの選択に失敗: {$code} - {$mes}"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - $this->dbname = $database; - - return true; - } - - /** - * プリペアドステートメントの準備 - * - * SQLクエリをプリペアドステートメントとして準備し、ステートメントIDを返します。 - * - * @param string $query プレースホルダ付きSQLクエリ(例: "SELECT * FROM users WHERE id = ?") - * @return int 成功時はステートメントID - * @throws \Exception 準備に失敗した場合 - */ - public function prepare(string $query): int { - if (!$this->connected || !MYSQL_ENABLED) return false; - - $this->sendCommand(0x16, $query); // COM_STMT_PREPARE - $res = $this->readPacket(); - - if (ord($res[0]) === 0xFF) { - $code = unpack('v', substr($res, 1, 2))[1]; - $mes = substr($res, 3); - $msg = "準備に失敗: {$code} - {$mes}"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - $pos = 0; - $statementId = unpack('V', substr($res, $pos + 1, 4))[1]; // ステートメントID - $pos += 5; - $numCols = unpack('v', substr($res, $pos, 2))[1]; // 列数 - $pos += 2; - $numParam = unpack('v', substr($res, $pos, 2))[1]; // パラメートル数 - $pos += 4; - - $this->prepared[$statementId] = [ - 'num_params' => $numParam, - 'num_columns' => $numCols, - 'params' => [], - 'columns' => [], - ]; - - if ($numParam > 0) { - for ($i = 0; $i < $numParam; $i++) { - $paramPacket = $this->readPacket(); - $this->prepared[$statementId]['params'][] = - $this->parseFieldPacket($paramPacket); - } - - $this->readPacket(); - } - - if ($numCols > 0) { - for ($i = 0; $i < $numCols; $i++) { - $columnPacket = $this->readPacket(); - $this->prepared[$statementId]['columns'][] = - $this->parseFieldPacket($columnPacket); - } - - $this->readPacket(); - } - - return $statementId; - } - - /** - * プリペアドステートメントの実行 - * - * 指定されたステートメントIDとパラメータを使用してクエリを実行します。 - * - * @param int $statementId プリペアドステートメントID - * @param array $params パラメータ値の配列 - * @return array 結果セットまたはOKパケットデータ - * @throws \Exception 実行に失敗した場合 - */ - public function execute(int $statementId, array $params = []): array { - if (!MYSQL_ENABLED) return []; - - if (!isset($this->prepared[$statementId])) { - $msg = "不正なステートメントID: {$statementId}"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - $stmtInfo = $this->prepared[$statementId]; - if (count($params) != $stmtInfo['num_params']) { - $msg = "パラメータ数が一致しません: 期待 {$stmtInfo['num_params']}, 取得 ".count($params); - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - $data = chr(0x17); // COM_STMT_EXECUTE - $data .= pack('V', $statementId); - $data .= chr(0); // 0 = カーソルなし - $data .= pack('V', 1); // 繰り返し数(常に1) - - if ($stmtInfo['num_params'] > 0) { - // NULLビットマップ - $nullBitmap = str_repeat("\0", ceil($stmtInfo['num_params'] / 8)); - foreach ($params as $k => $v) { - if ($v === NULL) { - $nullBitmap[$k >> 3] = chr(ord($nullBitmap[$k >> 3]) | (1 << ($k & 7))); - } - } - - $data .= $nullBitmap; - - $data .= chr(1); // 新パラメートルフラグ(1=はい) - - $paramTypes = ''; - $paramValues = ''; - foreach ($params as $param) { - /** - * MYSQL_TYPE_DECIMAL 0x00 - * MYSQL_TYPE_TINY 0x01 - * MYSQL_TYPE_SHORT 0x02 - * MYSQL_TYPE_LONG 0x03 - * MYSQL_TYPE_FLOAT 0x04 - * MYSQL_TYPE_DOUBLE 0x05 - * MYSQL_TYPE_NULL 0x06 - * MYSQL_TYPE_TIMESTAMP 0x07 - * MYSQL_TYPE_LONGLONG 0x08 - * MYSQL_TYPE_INT24 0x09 - * MYSQL_TYPE_DATE 0x0A - * MYSQL_TYPE_TIME 0x0B - * MYSQL_TYPE_DATETIME 0x0C - * MYSQL_TYPE_YEAR 0x0D - * MYSQL_TYPE_NEWDATE 0x0E - * MYSQL_TYPE_VARCHAR 0x0F - * MYSQL_TYPE_BIT 0x10 - * - * MYSQL_TYPE_NEWDECIMAL 0xF6 - * MYSQL_TYPE_ENUM 0xF7 - * MYSQL_TYPE_SET 0xF8 - * MYSQL_TYPE_TINY_BLOB 0xF9 - * MYSQL_TYPE_MEDIUM_BLOB 0xFA - * MYSQL_TYPE_LONG_BLOB 0xFB - * MYSQL_TYPE_BLOB 0xFC - * MYSQL_TYPE_VAR_STRING 0xFD - * MYSQL_TYPE_STRING 0xFE - * MYSQL_TYPE_GEOMETRY 0xFF - */ - if ($param === null) { - $paramType .= pack('v', 0x06); // MYSQL_TYPE_NULL - } else if (is_int($param)) { - $intLen = strlen((string)$param); - if ($intLen == 10) { - $paramTypes .= pack('v', 0x07); // MYSQL_TYPE_TIMESTAMP - } else if ($intLen >= -128 && $intLen < 127) { - $paramTypes .= pack('v', 0x01); // MYSQL_TYPE_TINY - } else if ($intLen >= -32768 && $intLen < 32767) { - $paramTypes .= pack('v', 0x02); // MYSQL_TYPE_SHORT - } else if ($intLen >= -8388608 && $intLen < 8388607) { - $paramTypes .= pack('v', 0x09); // MYSQL_TYPE_INT24 - } else if ($intLen >= -2147483648 && $intLen < 2147483647) { - $paramTypes .= pack('v', 0x03); // MYSQL_TYPE_LONG - } else if ($intLen >= -9223372036854775808 && $intLen < 9223372036854775807) { - $paramTypes .= pack('v', 0x08); // MYSQL_TYPE_LONGLONG - } - $paramValues .= pack('V', $param); - } else if (is_float($param)) { - $decLen = strpos(strrev((string)$param), '.'); - if ($decLen !== FALSE && $decLen < 25) { - $paramTypes .= pack('v', 0x04); // MYSQL_TYPE_FLOAT - } else if ($decLen !== FALSE && $decLen >= 25 && $decLen < 60) { - $paramTypes .= pack('v', 0x05); // MYSQL_TYPE_DOUBLE - } - $paramValues .= pack('d', $param); - } else { - $paramTypes .= pack('v', 0x0F); // MYSQL_TYPE_STRING - $len = strlen($param); - $paramValues .= $this->encodeLengthEncodedInteger($len).$param; - } - } - - $data .= $paramTypes.$paramValues; - } - - $this->sendPacket($data); - $res = $this->readPacket(); - - if (ord($res[0]) === 0xFF) { - $code = unpack('v', substr($res, 1, 2))[1]; - $mes = substr($res, 3); - $msg = "実行に失敗: {$code} - {$mes}"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - if (ord($res[0]) === 0x00) { - return $this->parseOkPacket($res); - } - - return $this->parseResultSet($res); - } - - /** - * プリペアドステートメントの解放 - * - * 指定されたステートメントIDを解放し、リソースをクリーンアップします。 - * - * @param int $statementId プリペアドステートメントID - * @return bool 成功時はtrue - */ - public function demolish(int $statementId): bool { - if (!MYSQL_ENABLED || !isset($this->prepared[$statementId])) return false; - - $data = chr(0x19).pack('V', $statementId); // COM_STMT_CLOSE - $this->sendPacket($data); - - unset($this->prepared[$statementId]); - return true; - } - - /** - * SQLクエリの実行 - * - * COM_QUERYを使用してSQLクエリを実行し、結果を返します。 - * - * @param string $query 実行するSQLクエリ - * @return array 結果セットまたはOKパケットデータ - * @throws \Exception クエリ実行に失敗した場合 - */ - public function query(string $query): array { - if (!MYSQL_ENABLED) return []; - - $this->sendCommand(0x03, $query); // COM_QUERY - $res = $this->readPacket(); - - if (ord($res[0]) === 0xFF) { - $code = unpack('v', substr($res, 1, 2))[1]; - $mes = substr($res, 3); - $msg = "クエリに失敗: {$code} - {$mes}"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - // レスポンスは0x00で始まったら、OKパケットだ - if (ord($res[0]) === 0x00) { - return $this->parseOkPacket($res); - } - - // レスポンスは0xFBで始まったら、、 LOCAL INFILEリクエストだ - // @todo LOCAL INFILEリクエストの処理を実装 - if (ord($res[0]) === 0xFB) { - $msg = "LOCAL INFOリクエストは未対応です"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - return $this->parseResultSet($res); - } - - /** - * パケットログをファイルに保存する - * - * デバッグ用に収集したパケットログを指定されたファイルに保存します。 - * - * @param string $filename 保存先ファイル名 - * @return bool|int 成功時は書き込んだバイト数、失敗時はfalse - */ - public function savePacketLogToFile(string $filename): bool|int { - if (!MYSQL_ENABLED) return 0; - - $output = ''; - - foreach ($this->packetLog as $index => $packetInfo) { - $direction = $packetInfo['direction']; - $length = $packetInfo['length']; - $seqNum = $packetInfo['seqNum']; - $data = $packetInfo['data']; - $timestamp = date('Y-m-d H:i:s', (int)$packetInfo['timestamp']); - - $output .= "=== パケット #{$index} ({$timestamp}) {$direction} - $output .= (長さ: {$length}, シーケンス: {$seqNum}) ===\n"; - $output .= "16進数: ".$this->hexDump($data)."\n"; - $output .= "ASCII: ".$this->asciiDump($data)."\n"; - $output .= "==========================================\n\n"; - } - - return file_put_contents(ROOT.'/log/'.$filename, $output); - } - - // 機能性メソッド - - /** - * MySQLサーバーで認証する - * - * クライアント機能フラグと認証情報を送信してサーバー認証を行います。 - * - * @return bool 認証成功時はtrue - * @throws \Exception 認証に失敗した場合 - */ - private function authenticate(): bool { - /** - * CLIENT_LONG_PASSWORD 0x00000001 - * CLIENT_PROTOCOL_41 0x00000200 - * CLIENT_SECURE_CONNECTION 0x00008000 - * CLIENT_CONNECT_WITH_DB 0x00000800 - * - * 0x00020D05 = CLIENT_LONG_PASSWORD | CLIENT_PROTOCOL_41 | - * CLIENT_SECURE_CONNECTION | CONNECT_WITH_DB - */ - $data = ''; - $data .= pack('L', 0x00020D05); // クライアント機能フラグ - $data .= pack('L', 16777216); // パケットサイズの大きさ - $data .= chr(33); // チャーセット(33 = utf8_general_ci) - $data .= str_repeat("\0", 23); // 予約バイト - $data .= $this->username."\0"; // ユーザー名 - - // パスワード - if (empty($this->password)) { - $data .= "\0"; // 空 - } else { - $pw = $this->scramblePassword($this->password, $this->serverInfo['scramble']); - $data .= chr(strlen($pw)).$pw; - } - - // データベース名 - if (!empty($this->dbname)) { - $data .= $this->dbname."\0"; - } - - // 認証パケットを送信する - $this->sendPacket($data, 1); - - // サーバー返事を送る - $res = $this->readPacket(); - - if (ord($res[0]) === 0xFF) { - $code = unpack('v', substr($res, 1, 2))[1]; - $mes = substr($res, 3); - $this->close(); - $msg = "認証に失敗: {$code} - {$mes}"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - return true; - } - - /** - * サーバーから結果セットを解析する - * - * クエリ結果のフィールドと行データを解析して返します。 - * - * @param string $firstPacket 最初の結果セットパケット - * @return array フィールドと行データの配列 - * @throws \Exception EOFパケットが期待通りに受信できない場合 - */ - private function parseResultSet(string $firstPacket): array { - $fieldCnt = ord($firstPacket[0]); - - $fields = []; - for ($i = 0; $i < $fieldCnt; $i++) { - $fieldPacket = $this->readPacket(); - $fields[] = $this->parseFieldPacket($fieldPacket); - } - - $eofPacket = $this->readPacket(); - if (ord($eofPacket[0]) !== 0xFE) { - $msg = "フィールド説明の後にEOFパケットが期待されます"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - $rows = []; - while (true) { - $rowPacket = $this->readPacket(); - - // 行データの終了を示すEOFパケットを確認 - if (ord($rowPacket[0]) === 0xFE && strlen($rowPacket) < 9) break; - $rows[] = $this->parseRowPacket($rowPacket, $fields); - } - - return [ - 'fields' => $fields, - 'rows' => $rows, - ]; - } - - /** - * フィールドパケットを解析する - * - * フィールドのメタデータを解析して返します。 - * - * @param string $packet フィールドパケット - * @return array フィールドのメタデータ - */ - private function parseFieldPacket(string $packet): array { - $pos = 0; - $field = []; - - // カタログのスキップ(def等) - $len = $this->getLengthEncodedIntegerValue($packet, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $field['catalog'] = substr($packet, $pos, $len); - $pos += 1 + $len; - - // データベース名 - $len = $this->getLengthEncodedIntegerValue($packet, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $field['db'] = substr($packet, $pos, $len); - $pos += $len; - - // テーブル名 - $len = $this->getLengthEncodedIntegerValue($packet, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $field['table'] = substr($packet, $pos, $len); - $pos += $len; - - // 元のテーブル名 - $len = $this->getLengthEncodedIntegerValue($packet, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $field['org_table'] = substr($packet, $pos, $len); - $pos += 1 + $len; - - // フィールド名 - $len = $this->getLengthEncodedIntegerValue($packet, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $field['name'] = substr($packet, $pos, $len); - $pos += $len; - - // 元のフィールド名 - $len = $this->getLengthEncodedIntegerValue($packet, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $field['org_name'] = substr($packet, $pos, $len); - $pos += $len; - - // フィルターバイトをスキップ(通常は0x0C) - $pos += 1; - - // 文字セット - $field['charset'] = unpack('v', substr($packet, $pos, 2))[1]; - $pos += 2; - - // 列の長さ - $field['length'] = unpack('V', substr($packet, $pos, 4))[1]; - $pos += 4; - - // フィールド種類 - $field['type'] = ord($packet[$pos]); - $pos += 1; - - // フラグ - $field['flags'] = unpack('v', substr($packet, $pos, 2))[1]; - $pos += 2; - - // 小数点以下の桁数 - $field['decimals'] = ord($packet[$pos]); - $pos += 1; - - // フィルターバイトをスキップ - $pos += 2; - - // デフォルト値(存在する場合、長さエンコード文字列) - if ($pos < strlen($packet)) { - $len = $this->getLengthEncodedIntegerValue($packet, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $field['default'] = substr($packet, $pos, $len); - } - - return $field; - } - - /** - * 行パケットを解析する - * - * 結果セットの行データを解析して返します。 - * - * @param string $packet 行パケット - * @param array $fields フィールドメタデータの配列 - * @return array 行データの連想配列 - */ - private function parseRowPacket(string $packet, array $fields): array { - $pos = 0; - $row = []; - - foreach ($fields as $field) { - // 0xFB = NULL - if (ord($packet[$pos]) === 0xFB) { - $row[$field['name']] = null; - $pos++; - continue; - } - - // 長さ - $len = ord($packet[$pos]); - $pos++; - $row[$field['name']] = substr($packet, $pos, $len); - $pos += $len; - } - - return $row; - } - - /** - * OKパケットを解析する - * - * OKパケットの内容を解析して影響を受けた行数や挿入IDなどを返します。 - * - * @param string $packet OKパケット - * @return array OKパケットのデータ - * @throws \Exception パケットが不完全な場合 - */ - private function parseOkPacket(string $packet): array { - if (strlen($packet) < 2) { - $msg = "OKパケットが短すぎます: ".strlen($packet)."バイト"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - $pos = 1; // ヘッダーバイト(0x00)をスキップする - - $affectedRows = $this->getLengthEncodedIntegerValue($packet, $pos); - $pos += $this->getLengthEncodedIntegerSize($affectedRows); - - $insertId = $this->getLengthEncodedIntegerValue($packet, $pos); - $pos += $this->getLengthEncodedIntegerSize($insertId); - - if (strlen($packet) < $pos + 2) { - $msg = "OKパケットにサーバーステータス用のデータが不足しています"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - $serverStatus = unpack('v', substr($packet, $pos, 2))[1]; - $pos += 2; - - if (strlen($packet) < $pos + 2) { - $msg = "OKパケットに警告カウント用のデータが不足しています"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - $warningCount = unpack('v', substr($packet, $pos, 2))[1]; - - return [ - 'affectedRows' => $affectedRows, - 'insertId' => $insertId, - 'serverStatus' => $serverStatus, - 'warningCount' => $warningCount, - ]; - } - - /** - * MySQLサーバーからパケットを読み込む - * - * ソケットからパケットを読み込み、完全なデータを受信するまで待機します。 - * - * @return string 受信したパケットデータ - * @throws \Exception 読み込みに失敗した場合 - */ - private function readPacket(): string { - $header = ''; // パケットのヘッダー=4バイト - $bytesRead = socket_recv($this->socket, $header, 4, MSG_WAITALL); - if ($bytesRead !== 4) { - $msg = "パケットヘッダーの読み込みに失敗: 期待 4 バイト, 取得 {$bytesRead}"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - // パケットの長さを最初3バイトからパーシングする - $len = ord($header[0]) + (ord($header[1]) << 8) + (ord($header[2]) << 16); - - // パケットの順序番号は第4目のバイト - $seqNum = ord($header[3]); - - // パケットの内容を読み込む - $data = ''; - $remaining = $len; - $timeout = 5; - socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, [ - 'sec' => $timeout, - 'usec' => 0, - ]); - - while ($remaining > 0) { - $buffer = ''; - $bytesRead = socket_recv($this->socket, $buffer, $remaining, 0); - - if ($bytesRead === false) { - $msg = "パケット内容の読み込みに失敗: エラー " - .socket_strerror(socket_last_error($this->socket)); - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - if ($bytesRead === 0) { - usleep(10000); - continue; - } - - $data .= $buffer; - $remaining -= $bytesRead; - } - - if (ord($data[0]) === 0x00 && strlen($data) < 7) { - $extra = ''; - $extraBytes = socket_recv($this->socket, $extra, 7 - strlen($data), 0); - if ($extraBytes !== false && $extraBytes > 0) { - $data .= $extra; - } - } - - // デバッグ - if ($this->debug) { - $packetInfo = [ - 'direction' => 'RECV', - 'length' => $len, - 'seqNum' => $seqNum, - 'data' => $data, - 'timestamp' => microtime(true), - ]; - - $this->logPacket($packetInfo); - } - - return $data; - } - - /** - * MySQLサーバーにパケットを送信する - * - * 指定されたデータとシーケンス番号でパケットを送信します。 - * - * @param string $data 送信するデータ - * @param int $seqNum シーケンス番号(デフォルトは0) - * @return bool 成功時はtrue - * @throws \Exception 送信に失敗した場合 - */ - private function sendPacket(string $data, $seqNum = 0): bool { - $len = strlen($data); - - // パケットヘッダー:長さ=3バイト、順序番号=1バイト - $header = chr($len & 0xFF) - .chr(($len >> 8) & 0xFF) - .chr(($len >> 16) & 0xFF) - .chr($seqNum); - - // デバッグ - if ($this->debug) { - $packetInfo = [ - 'direction' => 'SEND', - 'length' => $len, - 'seqNum' => $seqNum, - 'data' => $data, - 'timestamp' => microtime(true), - ]; - - $this->logPacket($packetInfo); - } - - // ヘッダーの送信 - $sent = socket_write($this->socket, $header, 4); - if ($sent !== 4) { - $msg = "パケットヘッダーの送信に失敗"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - // データの送信 - $sent = socket_write($this->socket, $data, $len); - if ($sent !== $len) { - $msg = "パケットデータの送信に失敗"; - logger(\LogType::MySQL, $msg); - throw new \Exception($msg); - } - - return true; - } - - /** - * MySQLサーバーにコマンドを送信する - * - * 指定されたコマンドとデータを送信します。 - * - * @param string $command コマンド(例: 0x03 = COM_QUERY) - * @param string $data 付加データ(デフォルトは空) - * @return bool 成功時はtrue - * @throws \Exception 送信に失敗した場合 - */ - private function sendCommand(string $command, string $data = ''): bool { - $packet = chr($command).$data; - return $this->sendPacket($packet); - } - - /** - * サーバーの挨拶パケットを解析する - * - * サーバーからの初期挨拶パケットを解析し、サーバー情報を保存します。 - * - * @param string $packet 挨拶パケット - * @return void - */ - private function parseServerGreeting(string $packet): void { - $pos = 0; - - // プロトコールバージョン(1バイト) - $this->serverInfo['protocol'] = ord($packet[$pos]); - $pos++; - - // サーバーバージョン - $end = strpos($packet, "\0", $pos); - $this->serverInfo['version'] = substr($packet, $pos, $end - $pos); - $pos = $end + 1; - - // スレッドID(4バイト) - $this->serverInfo['threadId'] = unpack('V', substr($packet, $pos, 4))[1]; - $pos += 4; - - // スクランブルバッファの最初の部分(8バイト) - $this->serverInfo['scramble'] = substr($packet, $pos, 8); - $pos += 8; - - // フィルターバイトをスキップする - $pos++; - - // サーバー機能(2バイト) - $this->serverInfo['capabilities'] = unpack('v', substr($packet, $pos, 2))[1]; - $pos += 2; - - // サーバー言語(1バイト) - $this->serverInfo['language'] = ord($packet[$pos]); - $pos++; - - // サーバー状況(2バイト) - $this->serverInfo['status'] = unpack('v', substr($packet, $pos, 2))[1]; - $pos += 2; - - // 13バイトのスキップ - $pos += 13; - - // その他(12バイト) - $this->serverInfo['scramble'] .= substr($packet, $pos, 12); - } - - /** - * パスワードをスクランブルする - * - * MySQL認証用のパスワードをスクランブルします。 - * - * @param string $password プレーンテキストのパスワード - * @param string $scramble サーバーから提供されたスクランブル文字列 - * @return string スクランブルされたパスワード(20バイト) - */ - private function scramblePassword(string $password, string $scramble): string { - $stage1 = sha1($password, true); - $stage2 = sha1($stage1, true); - $stage3 = sha1($scramble.$stage2, true); - - // $stage1 XOR $stage3 - $res = ''; - for ($i = 0; $i < 20; $i++) { - $res .= chr(ord($stage1[$i]) ^ ord($stage3[$i])); - } - - return $res; - } - - /** - * デバッグのためにパケットをログする - * - * パケット情報をログに追加し、デバッグ出力を表示します。 - * - * @param array $packetInfo パケット情報(方向、長さ、シーケンス番号、データ、タイムスタンプ) - * @return void - */ - private function logPacket(array $packetInfo): void { - $this->packetLog[] = $packetInfo; - - $direction = $packetInfo['direction']; - $length = $packetInfo['length']; - $seqNum = $packetInfo['seqNum']; - $data = $packetInfo['data']; - - echo "=== {$direction} パケット (長さ: {$length}, シーケンス: $seqNum) ===\n"; - echo $this->hexDumpWithAscii($data)."\n"; - - $this->interpretPacket($data); - - echo "==========================================\n\n"; - } - - /** - * バイナリデータを16進数で出力する - * - * デバッグ用にデータを16進数形式で表示します。 - * - * @param string $data バイナリデータ - * @return string 16進数文字列 - */ - private function hexDump(string $data): string { - $res = ''; - $len = strlen($data); - - for ($i = 0; $i < $len; $i++) { - $res .= sprintf('%02X ', ord($data[$i])); - - // 読み易さの為、各16バイトで新行列を入る - if (($i + 1) % 16 === 0 && $i !== $len - 1) { - $res .= "\n"; - } - } - - return $res; - } - - /** - * バイナリデータをASCIIで出力する - * - * デバッグ用にデータをASCII形式で表示します。 - * - * @param string $data バイナリデータ - * @return string ASCII文字列 - */ - private function asciiDump(string $data): string { - $res = ''; - $len = strlen($data); - - for ($i = 0; $i < $len; $i++) { - $char = ord($data[$i]); - - // 表示出来るASCII文字だけを書き出す - if ($char >= 32 && $char <= 126) { - $res .= $data[$i]; - } else { - $res .= '.'; - } - - // 読み易さの為、各16バイトで新行列を入る - if (($i + 1) % 16 === 0 && $i !== $len - 1) { - $res .= "\n"; - } - } - - return $res; - } - - /** - * バイナリデータを2進数で出力する - * - * デバッグ用にデータを2進数形式で表示します。 - * - * @param string $data バイナリデータ - * @return string 2進数文字列 - */ - private function binaryDump(string $data): string { - $res = ''; - $len = strlen($data); - - for ($i = 0; $i < $len; $i++) { - $res .= sprintf('%08b ', ord($data[$i])); - - // 読み易さの為、各88バイトで新行列を入る - if (($i + 1) % 8 === 0 && $i !== $len - 1) { - $res .= "\n"; - } - } - - return $res; - } - - /** - * パケットを最初のバイトに基づいて解釈する - * - * パケットの種類を特定し、デバッグ情報を出力します。 - * - * @param string $data パケットデータ - * @return void - */ - private function interpretPacket(string $data): void { - if (empty($data)) { - echo "解釈: 空パケット\n"; - return; - } - - $firstByte = ord($data[0]); - - switch ($firstByte) { - case 0x00: - echo "解釈: OKパケット\n"; - $this->debugOkPacket($data); - break; - case 0x17: - echo "解釈: COM_STMT_EXECUTEパケット\n"; - $this->debugStmtExecutePacket($data); - break; - case 0xFF: - echo "解釈: エラーパケット\n"; - $this->debugErrorPacket($data); - break; - case 0xFE: - echo "解釈: EOFパケット\n"; - break; - case 0xFB: - echo "解釈: LOCAL INFILEリクエスト\n"; - break; - default: - if ($firstByte === 3 - && $data[1] === 'd' - && $data[2] === 'e' - && $data[3] === 'f') { - // フィールドパケットかどうかの確認 - echo "解釈: フィールド説明パケット\n"; - $this->debugFieldPacket($data); - } else if ($firstByte > 0 && $firstByte < 251) { - // 結果セットパケットかどうかの確認 - echo "解釈: 結果セットヘッダーパケット(フィールド数: {$firstByte})\n"; - } else { - // 以上じゃないと、列データパケットでかもしん - echo "解釈: 列データパケット又はその他のパケット種類\n"; - $this->debugLengthEncodedStrings($data); - } - break; - } - } - - /** - * OKパケット構造をデバッグする - * - * OKパケットの内容を解析し、デバッグ情報を出力します。 - * - * @param string $data OKパケットデータ - * @return void - */ - private function debugOkPacket(string $data): void { - if (strlen($data) < 2) { - echo ' Error: OK packet too short ('.strlen($data)." bytes)\n"; - return; - } - - $pos = 1; // ヘッダーバイトをスキップ - - // 影響を受けた行数を取得 - $affectedRows = $this->getLengthEncodedIntegerValue($data, $pos); - echo " 影響を受けた行数: {$affectedRows}\n"; - $pos += $this->getLengthEncodedIntegerSize($affectedRows); - - // 最後の挿入IDを取得 - $insertId = $this->getLengthEncodedIntegerValue($data, $pos); - echo " 最後の挿入ID: {$insertId}\n"; - $pos += $this->getLengthEncodedIntegerSize($insertId); - - // サーバーステータス - if (strlen($data) >= $pos + 2) { - $serverStatus = unpack('v', substr($data, $pos, 2))[1]; - echo " サーバーステータス: ".sprintf('0x%04X', $serverStatus)."\n"; - $pos += 2; - } else { - echo " サーバーステータス: 利用不可\n"; - } - - // 警告カウント - if (strlen($data) >= $pos + 2) { - $warningCount = unpack('v', substr($data, $pos, 2))[1]; - echo " 警告カウント: {$warningCount}\n"; - $pos += 2; - } else { - echo " 警告カウント: 利用不可\n"; - } - - // サーバーメッセージ(存在する場合) - if (strlen($data) > $pos) { - $message = substr($data, $pos); - echo " メッセージ: ".$this->safeString($message)."\n"; - } - } - - /** - * エラーパケット構造をデバッグする - * - * エラーパケットの内容を解析し、デバッグ情報を出力します。 - * - * @param string $data エラーパケットデータ - * @return void - */ - private function debugErrorPacket(string $data): void { - $errorCode = unpack('v', substr($data, 1, 2))[1]; - echo " エラーコード: {$errorCode}\n"; - - // SQLステートマーカーをスキップ(#) - $sqlState = substr($data, 4, 5); - echo " SQLステート: {$sqlState}\n"; - - $errorMessage = substr($data, 9); - echo " エラーメッセージ: ".$this->safeString($errorMessage)."\n"; - } - - /** - * フィールドパケット構造をデバッグする - * - * フィールドパケットの内容を解析し、デバッグ情報を出力します。 - * - * @param string $data フィールドパケットデータ - * @return void - */ - private function debugFieldPacket(string $data): void { - $pos = 0; - - // パケットから長さエンコード文字列を抽出 - echo " フィールドパケット構造:\n"; - - // カタログを抽出 - $len = $this->getLengthEncodedIntegerValue($data, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $catalog = substr($data, $pos, $len); - echo " カタログ: ".$this->safeString($catalog)." (長さ: $len)\n"; - $pos += $len; - - // データベースを抽出 - $len = $this->getLengthEncodedIntegerValue($data, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $database = substr($data, $pos, $len); - echo " データベース: ".$this->safeString($database)." (長さ: $len)\n"; - $pos += $len; - - // テーブルを抽出 - $len = $this->getLengthEncodedIntegerValue($data, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $table = substr($data, $pos, $len); - echo " テーブル: ".$this->safeString($table)." (長さ: $len)\n"; - $pos += $len; - - // 元のテーブルを抽出 - $len = $this->getLengthEncodedIntegerValue($data, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $orgTable = substr($data, $pos, $len); - echo " 元のテーブル: ".$this->safeString($orgTable)." (長さ: $len)\n"; - $pos += $len; - - // 名前を抽出 - $len = $this->getLengthEncodedIntegerValue($data, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $name = substr($data, $pos, $len); - echo " 名前: ".$this->safeString($name)." (長さ: $len)\n"; - $pos += $len; - - // 元の名前を抽出 - $len = $this->getLengthEncodedIntegerValue($data, $pos); - $pos += $this->getLengthEncodedIntegerSize($len); - $orgName = substr($data, $pos, $len); - echo " 元の名前: ".$this->safeString($orgName)." (長さ: $len)\n"; - $pos += $len; - - // 次の長さエンコード整数を抽出(固定フィールドの長さ、通常は0x0C) - $fixedLength = $this->getLengthEncodedIntegerValue($data, $pos); - echo " 固定フィールドの長さ: {$fixedLength}\n"; - $pos += $this->getLengthEncodedIntegerSize($fixedLength); - - // 文字セット - $charSet = unpack('v', substr($data, $pos, 2))[1]; - echo " 文字セット: ".sprintf('0x%04X', $charSet)."\n"; - $pos += 2; - - // 列の長さ - $columnLength = unpack('V', substr($data, $pos, 4))[1]; - echo " 列の長さ: {$columnLength}\n"; - $pos += 4; - - // 列の種類 - $columnType = ord($data[$pos]); - echo " 列の種類: ".sprintf('0x%02X', $columnType)."\n"; - $pos++; - - // フラグ - $flags = unpack('v', substr($data, $pos, 2))[1]; - echo " フラグ: ".sprintf('0x%04X', $flags)."\n"; - $pos += 2; - - // 小数点以下の桁数 - $decimals = ord($data[$pos]); - echo " 小数点以下の桁数: {$decimals}\n"; - $pos++; - - // フィルター - echo " フィルター: ".sprintf('0x%04X', unpack('v', substr($data, $pos, 2))[1])."\n"; - } - - /** - * パケット内の長さエンコード文字列をデバッグする - * - * パケットからすべての長さエンコード文字列を抽出し、デバッグ情報を出力します。 - * - * @param string $data パケットデータ - * @return void - */ - private function debugLengthEncodedStrings(string $data): void { - $pos = 0; - $length = strlen($data); - $stringCount = 0; - - echo " 長さエンコード文字列:\n"; - - while ($pos < $length) { - // 長さエンコード文字列を特定 - if ($pos >= $length) break; - - $firstByte = ord($data[$pos]); - - // MySQLプロトコルに基づく長さエンコーディング - if ($firstByte < 251) { - // 1バイト長 - $len = $firstByte; - $pos++; - - if ($pos + $len <= $length) { - $value = substr($data, $pos, $len); - echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) - ." (長さ: {$len})\n"; - $pos += $len; - } else { - echo " 位置 {$pos} での無効な長さエンコーディング\n"; - break; - } - } else if ($firstByte == 251) { - // NULL値 - echo " 文字列 ".(++$stringCount).": NULL\n"; - $pos++; - } else if ($firstByte == 252) { - // 2バイト長 - if ($pos + 3 <= $length) { - $len = unpack('v', substr($data, $pos + 1, 2))[1]; - $pos += 3; - - if ($pos + $len <= $length) { - $value = substr($data, $pos, $len); - echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) - ." (長さ: {$len})\n"; - $pos += $len; - } else { - echo " 位置 {$pos} での無効な長さエンコーディング\n"; - break; - } - } else { - echo " 位置 {$pos} での不完全な2バイト長\n"; - break; - } - } else if ($firstByte == 253) { - // 3バイト長 - if ($pos + 4 <= $length) { - $len = unpack('V', substr($data, $pos + 1, 3)."\0")[1]; - $pos += 4; - - if ($pos + $len <= $length) { - $value = substr($data, $pos, $len); - echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) - ." (長さ: {$len})\n"; - $pos += $len; - } else { - echo " 位置 {$pos} での無効な長さエンコーディング\n"; - break; - } - } else { - echo " 位置 {$pos} での不完全な3バイト長\n"; - break; - } - } else if ($firstByte == 254) { - // 8バイト長 - if ($pos + 9 <= $length) { - // PHPでは8バイト整数を完全に扱えないため、最初の4バイトのみ読み取る - $len = unpack('V', substr($data, $pos + 1, 4))[1]; - $pos += 9; - - if ($pos + $len <= $length) { - $value = substr($data, $pos, $len); - echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) - ." (長さ: {$len})\n"; - $pos += $len; - } else { - echo " 位置 {$pos} での無効な長さエンコーディング\n"; - break; - } - } else { - echo " 位置 {$pos} での不完全な8バイト長\n"; - break; - } - } else { - // 長さエンコード文字列でない場合、次のバイトへ - $pos++; - } - } - - if ($stringCount === 0) { - echo " No length-encoded strings found\n"; - } - } - - /** - * 文字列を安全に出力用に変換する - * - * 表示可能な文字のみを含み、非表示文字は16進数で表現します。 - * - * @param string $str 変換する文字列 - * @return string 安全な文字列 - */ - private function safeString(string $str): string { - $result = ''; - $length = strlen($str); - - for ($i = 0; $i < $length; $i++) { - $char = ord($str[$i]); - if ($char >= 32 && $char <= 126) { - $result .= $str[$i]; - } else { - $result .= '\\x'.sprintf('%02X', $char); - } - } - - return $result; - } - - /** - * 指定位置の長さエンコード整数値を取得する - * - * パケット内の長さエンコード整数を読み取ります。 - * - * @param string $data パケットデータ - * @param int $pos 開始位置 - * @return mixed 整数値または0(不明な場合) - */ - private function getLengthEncodedIntegerValue(string $data, int $pos): mixed { - $firstByte = ord($data[$pos]); - - if ($firstByte < 251) { - return $firstByte; - } else if ($firstByte == 252) { - return unpack('v', substr($data, $pos + 1, 2))[1]; - } else if ($firstByte == 253) { - return unpack('V', substr($data, $pos + 1, 3)."\0")[1]; - } else if ($firstByte == 254) { - // 簡略化のため、8バイト整数の最初の4バイトのみ読み取る - return unpack('V', substr($data, $pos + 1, 4))[1]; - } - - return 0; - } - - /** - * 長さエンコード整数のサイズを取得する - * - * 値に基づいて長さエンコードに必要なバイト数を返します。 - * - * @param int $value 整数値 - * @return int バイト数 - */ - private function getLengthEncodedIntegerSize(int $value): int { - if ($value < 251) { - return 1; - } else if ($value < 65536) { - return 3; // 0xFCマーカー1バイト + 値2バイト - } else if ($value < 16777216) { - return 4; // 0xFDマーカー1バイト + 値3バイト - } else { - return 9; // 0xFEマーカー1バイト + 値8バイト - } - } - - /** - * 整数を長さエンコード形式に変換する - * - * MySQLプロトコルに基づいて整数を長さエンコードします。 - * - * @param int $value 変換する整数 - * @return string 長さエンコードされた文字列 - */ - private function encodeLengthEncodedInteger(int $value): string { - if ($value < 251) { - return chr($value); - } else if ($value < 65536) { - return chr(0xFC).pack('v', $value); - } else if ($value < 16777216) { - return chr(0xFD).pack('V', $value & 0xFFFFFF); - } else { - return chr(0xFE).pack('P', $value); - } - } - - /** - * STMTパケットをデバッグする - * - * COM_STMT_EXECUTEパケットの内容を解析し、デバッグ情報を出力します。 - * - * @param string $data パケットデータ - * @return void - */ - private function debugStmtExecutePacket(string $data): void { - $pos = 1; // コマンドバイトをスキップ - $statementId = unpack('V', substr($data, $pos, 4))[1]; - echo " ステートメントID: {$statementId}\n"; - $pos += 5; // ステートメントIDとカーソルフラグをスキップ - $iterationCount = unpack('V', substr($data, $pos, 4))[1]; - echo " 繰り返し数: {$iterationCount}\n"; - $pos += 4; - - $numParams = $this->prepared[$statementId]['num_params'] ?? 0; - if ($numParams > 0) { - $nullBitmapLen = ceil($numParams / 8); - $nullBitmap = substr($data, $pos, $nullBitmapLen); - echo " NULLビットマップ: ".$this->hexDump($nullBitmap)."\n"; - $pos += $nullBitmapLen; - - $newParamsFlag = ord($data[$pos]); - echo " 新パラメータフラグ: {$newParamsFlag}\n"; - $pos++; - - if ($newParamsFlag) { - echo " パラメータ種類:\n"; - - for ($i = 0; $i < $numParams; $i++) { - $type = unpack('v', substr($data, $pos, 2))[1]; - $typeName = $this->getMysqlTypeName($type); - echo " パラメータ {$i}: ".sprintf("0x%02X", $type)." ({$typeName})\n"; - $pos += 2; - } - - echo " パラメータ値: (生バイナリが続く)\n"; - } - } - } - - /** - * 16進値をMySQLタイプ名に変換する - * - * MySQLのデータ型コードを対応する型名に変換します。 - * - * @param int $type 16進数の型コード - * @return string 型名(不明な場合は'UNKNOWN') - */ - private function getMysqlTypeName(int $type): string { - $types = [ - 0x00 => 'MYSQL_TYPE_DECIMAL', - 0x01 => 'MYSQL_TYPE_TINY', - 0x02 => 'MYSQL_TYPE_SHORT', - 0x03 => 'MYSQL_TYPE_LONG', - 0x04 => 'MYSQL_TYPE_FLOAT', - 0x05 => 'MYSQL_TYPE_DOUBLE', - 0x06 => 'MYSQL_TYPE_NULL', - 0x07 => 'MYSQL_TYPE_TIMESTAMP', - 0x08 => 'MYSQL_TYPE_LONGLONG', - 0x09 => 'MYSQL_TYPE_INT24', - 0x0A => 'MYSQL_TYPE_DATE', - 0x0B => 'MYSQL_TYPE_TIME', - 0x0C => 'MYSQL_TYPE_DATETIME', - 0x0D => 'MYSQL_TYPE_YEAR', - 0x0E => 'MYSQL_TYPE_NEWDATE', - 0x0F => 'MYSQL_TYPE_VARCHAR', - 0x10 => 'MYSQL_TYPE_BIT', - 0xF6 => 'MYSQL_TYPE_NEWDECIMAL', - 0xF7 => 'MYSQL_TYPE_ENUM', - 0xF8 => 'MYSQL_TYPE_SET', - 0xF9 => 'MYSQL_TYPE_TINY_BLOB', - 0xFA => 'MYSQL_TYPE_MEDIUM_BLOB', - 0xFB => 'MYSQL_TYPE_LONG_BLOB', - 0xFC => 'MYSQL_TYPE_BLOB', - 0xFD => 'MYSQL_TYPE_VAR_STRING', - 0xFE => 'MYSQL_TYPE_STRING', - 0xFF => 'MYSQL_TYPE_GEOMETRY', - ]; - - return $types[$type] ?? '不明'; - } - - /** - * 16進数とASCII値を横に並べて出力する - * - * 16進エディタのように、16進数とASCII値を並べて表示します。 - * - * @param string $data バイナリデータ - * @return string フォーマットされた文字列 - */ - private function hexDumpWithAscii(string $data): string { - $output = ''; - $len = strlen($data); - $offset = 0; - - while ($offset < $len) { - $hex = ''; - $ascii = ''; - $bytesInLine = min(16, $len - $offset); // 1行あたり16バイト - - // 16進数部分 - for ($i = 0; $i < 16; $i++) { - if ($i < $bytesInLine) { - $hex .= sprintf('%02X ', ord($data[$offset + $i])); - } else { - $hex .= ' '; // 揃えのためにスペースを埋める - } - } - - // ASCII部分 - for ($i = 0; $i < $bytesInLine; $i++) { - $char = ord($data[$offset + $i]); - $ascii .= ($char >= 32 && $char <= 126) ? chr($char) : '.'; - } - - $output .= sprintf("%08X %s |%s|\n", $offset, $hex, $ascii); - $offset += 16; - } - - return $output; - } +host = DBINFO['host']; + $this->username = DBINFO['username']; + $this->password = DBINFO['password']; + $this->dbname = DBINFO['dbname']; + $this->port = DBINFO['port']; + $this->debug = DBINFO['debug']; + } + + /** + * デストラクタ + * + * オブジェクト破棄時に接続を閉じます。 + */ + public function __destruct() { + $this->close(); + } + + /** + * デバッギングの有無 + * + * @param bool $debug デバッグモードを有効にするかどうか + * @return Mysql 自身を返す(メソッドチェーン用) + */ + public function setDebug(bool $debug): Mysql { + $this->debug = (bool)$debug; + return $this; + } + + /** + * パケットログの取得 + * + * @return array 送信および受信したパケットのログ + */ + public function getPacketLog(): array { + return $this->packetLog; + } + + /** + * MySQLサーバーに接続 + * + * ソケットを作成し、サーバーに接続後、認証とデータベース選択を行います。 + * + * @return bool 接続成功時はtrue + * @throws \Exception 接続または認証に失敗した場合 + */ + public function connect(): bool { + if (!MYSQL_ENABLED) return false; + + $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + if ($this->socket === false) { + $msg = 'ソケットの作成に失敗: '.socket_strerror(socket_last_error()); + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $res = socket_connect($this->socket, $this->host, $this->port); + if ($res === false) { + $msg = 'ソケットに接続に失敗: ' + .socket_strerror(socket_last_error($this->socket)); + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $greeting = $this->readPacket(); + $this->parseServerGreeting($greeting); + $this->authenticate(); + $response = $this->readPacket(); + + if (ord($response[0]) !== 0x00) { + $code = unpack('v', substr($response, 1, 2))[1]; + $mes = substr($response, 3); + $msg = "認証応答に失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + if (!empty($this->dbname)) { + $this->selectDatabase($this->dbname); + } + + $this->connected = true; + return true; + } + + /** + * 接続を閉じる + * + * COM_QUITコマンドを送信し、ソケットを閉じます。 + * + * @return void + */ + public function close(): void { + if (!MYSQL_ENABLED) return; + + if ($this->socket) { + $this->sendCommand(0x01); // COM_QUIT + socket_close($this->socket); + $this->socket = null; + $this->connected = false; + } + } + + /** + * 利用するデータベースを選択する + * + * COM_INIT_DBコマンドを使用してデータベースを選択します。 + * + * @param string $database データベース名 + * @return bool 成功時はtrue + * @throws \Exception データベース選択に失敗した場合 + */ + public function selectDatabase(string $database): bool { + if (!MYSQL_ENABLED) return false; + + $this->sendCommand(0x02, $database); // COM_INIT_DB + $res = $this->readPacket(); + + if (ord($res[0]) === 0xFF) { + $code = unpack('v', substr($res, 1, 2))[1]; + $mes = substr($res, 3); + $msg = "データベースの選択に失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $this->dbname = $database; + + return true; + } + + /** + * プリペアドステートメントの準備 + * + * SQLクエリをプリペアドステートメントとして準備し、ステートメントIDを返します。 + * + * @param string $query プレースホルダ付きSQLクエリ(例: "SELECT * FROM users WHERE id = ?") + * @return int 成功時はステートメントID + * @throws \Exception 準備に失敗した場合 + */ + public function prepare(string $query): int { + if (!$this->connected || !MYSQL_ENABLED) return false; + + $this->sendCommand(0x16, $query); // COM_STMT_PREPARE + $res = $this->readPacket(); + + if (ord($res[0]) === 0xFF) { + $code = unpack('v', substr($res, 1, 2))[1]; + $mes = substr($res, 3); + $msg = "準備に失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $pos = 0; + $statementId = unpack('V', substr($res, $pos + 1, 4))[1]; // ステートメントID + $pos += 5; + $numCols = unpack('v', substr($res, $pos, 2))[1]; // 列数 + $pos += 2; + $numParam = unpack('v', substr($res, $pos, 2))[1]; // パラメートル数 + $pos += 4; + + $this->prepared[$statementId] = [ + 'num_params' => $numParam, + 'num_columns' => $numCols, + 'params' => [], + 'columns' => [], + ]; + + if ($numParam > 0) { + for ($i = 0; $i < $numParam; $i++) { + $paramPacket = $this->readPacket(); + $this->prepared[$statementId]['params'][] = + $this->parseFieldPacket($paramPacket); + } + + $this->readPacket(); + } + + if ($numCols > 0) { + for ($i = 0; $i < $numCols; $i++) { + $columnPacket = $this->readPacket(); + $this->prepared[$statementId]['columns'][] = + $this->parseFieldPacket($columnPacket); + } + + $this->readPacket(); + } + + return $statementId; + } + + /** + * プリペアドステートメントの実行 + * + * 指定されたステートメントIDとパラメータを使用してクエリを実行します。 + * + * @param int $statementId プリペアドステートメントID + * @param array $params パラメータ値の配列 + * @return array 結果セットまたはOKパケットデータ + * @throws \Exception 実行に失敗した場合 + */ + public function execute(int $statementId, array $params = []): array { + if (!MYSQL_ENABLED) return []; + + if (!isset($this->prepared[$statementId])) { + $msg = "不正なステートメントID: {$statementId}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $stmtInfo = $this->prepared[$statementId]; + if (count($params) != $stmtInfo['num_params']) { + $msg = "パラメータ数が一致しません: 期待 {$stmtInfo['num_params']}, 取得 ".count($params); + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $data = chr(0x17); // COM_STMT_EXECUTE + $data .= pack('V', $statementId); + $data .= chr(0); // 0 = カーソルなし + $data .= pack('V', 1); // 繰り返し数(常に1) + + if ($stmtInfo['num_params'] > 0) { + // NULLビットマップ + $nullBitmap = str_repeat("\0", ceil($stmtInfo['num_params'] / 8)); + foreach ($params as $k => $v) { + if ($v === NULL) { + $nullBitmap[$k >> 3] = chr(ord($nullBitmap[$k >> 3]) | (1 << ($k & 7))); + } + } + + $data .= $nullBitmap; + + $data .= chr(1); // 新パラメートルフラグ(1=はい) + + $paramTypes = ''; + $paramValues = ''; + foreach ($params as $param) { + /** + * MYSQL_TYPE_DECIMAL 0x00 + * MYSQL_TYPE_TINY 0x01 + * MYSQL_TYPE_SHORT 0x02 + * MYSQL_TYPE_LONG 0x03 + * MYSQL_TYPE_FLOAT 0x04 + * MYSQL_TYPE_DOUBLE 0x05 + * MYSQL_TYPE_NULL 0x06 + * MYSQL_TYPE_TIMESTAMP 0x07 + * MYSQL_TYPE_LONGLONG 0x08 + * MYSQL_TYPE_INT24 0x09 + * MYSQL_TYPE_DATE 0x0A + * MYSQL_TYPE_TIME 0x0B + * MYSQL_TYPE_DATETIME 0x0C + * MYSQL_TYPE_YEAR 0x0D + * MYSQL_TYPE_NEWDATE 0x0E + * MYSQL_TYPE_VARCHAR 0x0F + * MYSQL_TYPE_BIT 0x10 + * + * MYSQL_TYPE_NEWDECIMAL 0xF6 + * MYSQL_TYPE_ENUM 0xF7 + * MYSQL_TYPE_SET 0xF8 + * MYSQL_TYPE_TINY_BLOB 0xF9 + * MYSQL_TYPE_MEDIUM_BLOB 0xFA + * MYSQL_TYPE_LONG_BLOB 0xFB + * MYSQL_TYPE_BLOB 0xFC + * MYSQL_TYPE_VAR_STRING 0xFD + * MYSQL_TYPE_STRING 0xFE + * MYSQL_TYPE_GEOMETRY 0xFF + */ + if ($param === null) { + $paramType .= pack('v', 0x06); // MYSQL_TYPE_NULL + } else if (is_int($param)) { + $intLen = strlen((string)$param); + if ($intLen == 10) { + $paramTypes .= pack('v', 0x07); // MYSQL_TYPE_TIMESTAMP + } else if ($intLen >= -128 && $intLen < 127) { + $paramTypes .= pack('v', 0x01); // MYSQL_TYPE_TINY + } else if ($intLen >= -32768 && $intLen < 32767) { + $paramTypes .= pack('v', 0x02); // MYSQL_TYPE_SHORT + } else if ($intLen >= -8388608 && $intLen < 8388607) { + $paramTypes .= pack('v', 0x09); // MYSQL_TYPE_INT24 + } else if ($intLen >= -2147483648 && $intLen < 2147483647) { + $paramTypes .= pack('v', 0x03); // MYSQL_TYPE_LONG + } else if ($intLen >= -9223372036854775808 && $intLen < 9223372036854775807) { + $paramTypes .= pack('v', 0x08); // MYSQL_TYPE_LONGLONG + } + $paramValues .= pack('V', $param); + } else if (is_float($param)) { + $decLen = strpos(strrev((string)$param), '.'); + if ($decLen !== FALSE && $decLen < 25) { + $paramTypes .= pack('v', 0x04); // MYSQL_TYPE_FLOAT + } else if ($decLen !== FALSE && $decLen >= 25 && $decLen < 60) { + $paramTypes .= pack('v', 0x05); // MYSQL_TYPE_DOUBLE + } + $paramValues .= pack('d', $param); + } else { + $paramTypes .= pack('v', 0x0F); // MYSQL_TYPE_STRING + $len = strlen($param); + $paramValues .= $this->encodeLengthEncodedInteger($len).$param; + } + } + + $data .= $paramTypes.$paramValues; + } + + $this->sendPacket($data); + $res = $this->readPacket(); + + if (ord($res[0]) === 0xFF) { + $code = unpack('v', substr($res, 1, 2))[1]; + $mes = substr($res, 3); + $msg = "実行に失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + if (ord($res[0]) === 0x00) { + return $this->parseOkPacket($res); + } + + return $this->parseResultSet($res); + } + + /** + * プリペアドステートメントの解放 + * + * 指定されたステートメントIDを解放し、リソースをクリーンアップします。 + * + * @param int $statementId プリペアドステートメントID + * @return bool 成功時はtrue + */ + public function demolish(int $statementId): bool { + if (!MYSQL_ENABLED || !isset($this->prepared[$statementId])) return false; + + $data = chr(0x19).pack('V', $statementId); // COM_STMT_CLOSE + $this->sendPacket($data); + + unset($this->prepared[$statementId]); + return true; + } + + /** + * SQLクエリの実行 + * + * COM_QUERYを使用してSQLクエリを実行し、結果を返します。 + * + * @param string $query 実行するSQLクエリ + * @return array 結果セットまたはOKパケットデータ + * @throws \Exception クエリ実行に失敗した場合 + */ + public function query(string $query): array { + if (!MYSQL_ENABLED) return []; + + $this->sendCommand(0x03, $query); // COM_QUERY + $res = $this->readPacket(); + + if (ord($res[0]) === 0xFF) { + $code = unpack('v', substr($res, 1, 2))[1]; + $mes = substr($res, 3); + $msg = "クエリに失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + // レスポンスは0x00で始まったら、OKパケットだ + if (ord($res[0]) === 0x00) { + return $this->parseOkPacket($res); + } + + // レスポンスは0xFBで始まったら、、 LOCAL INFILEリクエストだ + // @todo LOCAL INFILEリクエストの処理を実装 + if (ord($res[0]) === 0xFB) { + $msg = "LOCAL INFOリクエストは未対応です"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + return $this->parseResultSet($res); + } + + /** + * パケットログをファイルに保存する + * + * デバッグ用に収集したパケットログを指定されたファイルに保存します。 + * + * @param string $filename 保存先ファイル名 + * @return bool|int 成功時は書き込んだバイト数、失敗時はfalse + */ + public function savePacketLogToFile(string $filename): bool|int { + if (!MYSQL_ENABLED) return 0; + + $output = ''; + + foreach ($this->packetLog as $index => $packetInfo) { + $direction = $packetInfo['direction']; + $length = $packetInfo['length']; + $seqNum = $packetInfo['seqNum']; + $data = $packetInfo['data']; + $timestamp = date('Y-m-d H:i:s', (int)$packetInfo['timestamp']); + + $output .= "=== パケット #{$index} ({$timestamp}) {$direction} + $output .= (長さ: {$length}, シーケンス: {$seqNum}) ===\n"; + $output .= "16進数: ".$this->hexDump($data)."\n"; + $output .= "ASCII: ".$this->asciiDump($data)."\n"; + $output .= "==========================================\n\n"; + } + + return file_put_contents(ROOT.'/log/'.$filename, $output); + } + + // 機能性メソッド + + /** + * MySQLサーバーで認証する + * + * クライアント機能フラグと認証情報を送信してサーバー認証を行います。 + * + * @return bool 認証成功時はtrue + * @throws \Exception 認証に失敗した場合 + */ + private function authenticate(): bool { + /** + * CLIENT_LONG_PASSWORD 0x00000001 + * CLIENT_PROTOCOL_41 0x00000200 + * CLIENT_SECURE_CONNECTION 0x00008000 + * CLIENT_CONNECT_WITH_DB 0x00000800 + * + * 0x00020D05 = CLIENT_LONG_PASSWORD | CLIENT_PROTOCOL_41 | + * CLIENT_SECURE_CONNECTION | CONNECT_WITH_DB + */ + $data = ''; + $data .= pack('L', 0x00020D05); // クライアント機能フラグ + $data .= pack('L', 16777216); // パケットサイズの大きさ + $data .= chr(33); // チャーセット(33 = utf8_general_ci) + $data .= str_repeat("\0", 23); // 予約バイト + $data .= $this->username."\0"; // ユーザー名 + + // パスワード + if (empty($this->password)) { + $data .= "\0"; // 空 + } else { + $pw = $this->scramblePassword($this->password, $this->serverInfo['scramble']); + $data .= chr(strlen($pw)).$pw; + } + + // データベース名 + if (!empty($this->dbname)) { + $data .= $this->dbname."\0"; + } + + // 認証パケットを送信する + $this->sendPacket($data, 1); + + // サーバー返事を送る + $res = $this->readPacket(); + + if (ord($res[0]) === 0xFF) { + $code = unpack('v', substr($res, 1, 2))[1]; + $mes = substr($res, 3); + $this->close(); + $msg = "認証に失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + return true; + } + + /** + * サーバーから結果セットを解析する + * + * クエリ結果のフィールドと行データを解析して返します。 + * + * @param string $firstPacket 最初の結果セットパケット + * @return array フィールドと行データの配列 + * @throws \Exception EOFパケットが期待通りに受信できない場合 + */ + private function parseResultSet(string $firstPacket): array { + $fieldCnt = ord($firstPacket[0]); + + $fields = []; + for ($i = 0; $i < $fieldCnt; $i++) { + $fieldPacket = $this->readPacket(); + $fields[] = $this->parseFieldPacket($fieldPacket); + } + + $eofPacket = $this->readPacket(); + if (ord($eofPacket[0]) !== 0xFE) { + $msg = "フィールド説明の後にEOFパケットが期待されます"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $rows = []; + while (true) { + $rowPacket = $this->readPacket(); + + // 行データの終了を示すEOFパケットを確認 + if (ord($rowPacket[0]) === 0xFE && strlen($rowPacket) < 9) break; + $rows[] = $this->parseRowPacket($rowPacket, $fields); + } + + return [ + 'fields' => $fields, + 'rows' => $rows, + ]; + } + + /** + * フィールドパケットを解析する + * + * フィールドのメタデータを解析して返します。 + * + * @param string $packet フィールドパケット + * @return array フィールドのメタデータ + */ + private function parseFieldPacket(string $packet): array { + $pos = 0; + $field = []; + + // カタログのスキップ(def等) + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['catalog'] = substr($packet, $pos, $len); + $pos += 1 + $len; + + // データベース名 + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['db'] = substr($packet, $pos, $len); + $pos += $len; + + // テーブル名 + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['table'] = substr($packet, $pos, $len); + $pos += $len; + + // 元のテーブル名 + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['org_table'] = substr($packet, $pos, $len); + $pos += 1 + $len; + + // フィールド名 + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['name'] = substr($packet, $pos, $len); + $pos += $len; + + // 元のフィールド名 + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['org_name'] = substr($packet, $pos, $len); + $pos += $len; + + // フィルターバイトをスキップ(通常は0x0C) + $pos += 1; + + // 文字セット + $field['charset'] = unpack('v', substr($packet, $pos, 2))[1]; + $pos += 2; + + // 列の長さ + $field['length'] = unpack('V', substr($packet, $pos, 4))[1]; + $pos += 4; + + // フィールド種類 + $field['type'] = ord($packet[$pos]); + $pos += 1; + + // フラグ + $field['flags'] = unpack('v', substr($packet, $pos, 2))[1]; + $pos += 2; + + // 小数点以下の桁数 + $field['decimals'] = ord($packet[$pos]); + $pos += 1; + + // フィルターバイトをスキップ + $pos += 2; + + // デフォルト値(存在する場合、長さエンコード文字列) + if ($pos < strlen($packet)) { + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['default'] = substr($packet, $pos, $len); + } + + return $field; + } + + /** + * 行パケットを解析する + * + * 結果セットの行データを解析して返します。 + * + * @param string $packet 行パケット + * @param array $fields フィールドメタデータの配列 + * @return array 行データの連想配列 + */ + private function parseRowPacket(string $packet, array $fields): array { + $pos = 0; + $row = []; + + foreach ($fields as $field) { + // 0xFB = NULL + if (ord($packet[$pos]) === 0xFB) { + $row[$field['name']] = null; + $pos++; + continue; + } + + // 長さ + $len = ord($packet[$pos]); + $pos++; + $row[$field['name']] = substr($packet, $pos, $len); + $pos += $len; + } + + return $row; + } + + /** + * OKパケットを解析する + * + * OKパケットの内容を解析して影響を受けた行数や挿入IDなどを返します。 + * + * @param string $packet OKパケット + * @return array OKパケットのデータ + * @throws \Exception パケットが不完全な場合 + */ + private function parseOkPacket(string $packet): array { + if (strlen($packet) < 2) { + $msg = "OKパケットが短すぎます: ".strlen($packet)."バイト"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $pos = 1; // ヘッダーバイト(0x00)をスキップする + + $affectedRows = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($affectedRows); + + $insertId = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($insertId); + + if (strlen($packet) < $pos + 2) { + $msg = "OKパケットにサーバーステータス用のデータが不足しています"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + $serverStatus = unpack('v', substr($packet, $pos, 2))[1]; + $pos += 2; + + if (strlen($packet) < $pos + 2) { + $msg = "OKパケットに警告カウント用のデータが不足しています"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + $warningCount = unpack('v', substr($packet, $pos, 2))[1]; + + return [ + 'affectedRows' => $affectedRows, + 'insertId' => $insertId, + 'serverStatus' => $serverStatus, + 'warningCount' => $warningCount, + ]; + } + + /** + * MySQLサーバーからパケットを読み込む + * + * ソケットからパケットを読み込み、完全なデータを受信するまで待機します。 + * + * @return string 受信したパケットデータ + * @throws \Exception 読み込みに失敗した場合 + */ + private function readPacket(): string { + $header = ''; // パケットのヘッダー=4バイト + $bytesRead = socket_recv($this->socket, $header, 4, MSG_WAITALL); + if ($bytesRead !== 4) { + $msg = "パケットヘッダーの読み込みに失敗: 期待 4 バイト, 取得 {$bytesRead}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + // パケットの長さを最初3バイトからパーシングする + $len = ord($header[0]) + (ord($header[1]) << 8) + (ord($header[2]) << 16); + + // パケットの順序番号は第4目のバイト + $seqNum = ord($header[3]); + + // パケットの内容を読み込む + $data = ''; + $remaining = $len; + $timeout = 5; + socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, [ + 'sec' => $timeout, + 'usec' => 0, + ]); + + while ($remaining > 0) { + $buffer = ''; + $bytesRead = socket_recv($this->socket, $buffer, $remaining, 0); + + if ($bytesRead === false) { + $msg = "パケット内容の読み込みに失敗: エラー " + .socket_strerror(socket_last_error($this->socket)); + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + if ($bytesRead === 0) { + usleep(10000); + continue; + } + + $data .= $buffer; + $remaining -= $bytesRead; + } + + if (ord($data[0]) === 0x00 && strlen($data) < 7) { + $extra = ''; + $extraBytes = socket_recv($this->socket, $extra, 7 - strlen($data), 0); + if ($extraBytes !== false && $extraBytes > 0) { + $data .= $extra; + } + } + + // デバッグ + if ($this->debug) { + $packetInfo = [ + 'direction' => 'RECV', + 'length' => $len, + 'seqNum' => $seqNum, + 'data' => $data, + 'timestamp' => microtime(true), + ]; + + $this->logPacket($packetInfo); + } + + return $data; + } + + /** + * MySQLサーバーにパケットを送信する + * + * 指定されたデータとシーケンス番号でパケットを送信します。 + * + * @param string $data 送信するデータ + * @param int $seqNum シーケンス番号(デフォルトは0) + * @return bool 成功時はtrue + * @throws \Exception 送信に失敗した場合 + */ + private function sendPacket(string $data, $seqNum = 0): bool { + $len = strlen($data); + + // パケットヘッダー:長さ=3バイト、順序番号=1バイト + $header = chr($len & 0xFF) + .chr(($len >> 8) & 0xFF) + .chr(($len >> 16) & 0xFF) + .chr($seqNum); + + // デバッグ + if ($this->debug) { + $packetInfo = [ + 'direction' => 'SEND', + 'length' => $len, + 'seqNum' => $seqNum, + 'data' => $data, + 'timestamp' => microtime(true), + ]; + + $this->logPacket($packetInfo); + } + + // ヘッダーの送信 + $sent = socket_write($this->socket, $header, 4); + if ($sent !== 4) { + $msg = "パケットヘッダーの送信に失敗"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + // データの送信 + $sent = socket_write($this->socket, $data, $len); + if ($sent !== $len) { + $msg = "パケットデータの送信に失敗"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + return true; + } + + /** + * MySQLサーバーにコマンドを送信する + * + * 指定されたコマンドとデータを送信します。 + * + * @param string $command コマンド(例: 0x03 = COM_QUERY) + * @param string $data 付加データ(デフォルトは空) + * @return bool 成功時はtrue + * @throws \Exception 送信に失敗した場合 + */ + private function sendCommand(string $command, string $data = ''): bool { + $packet = chr($command).$data; + return $this->sendPacket($packet); + } + + /** + * サーバーの挨拶パケットを解析する + * + * サーバーからの初期挨拶パケットを解析し、サーバー情報を保存します。 + * + * @param string $packet 挨拶パケット + * @return void + */ + private function parseServerGreeting(string $packet): void { + $pos = 0; + + // プロトコールバージョン(1バイト) + $this->serverInfo['protocol'] = ord($packet[$pos]); + $pos++; + + // サーバーバージョン + $end = strpos($packet, "\0", $pos); + $this->serverInfo['version'] = substr($packet, $pos, $end - $pos); + $pos = $end + 1; + + // スレッドID(4バイト) + $this->serverInfo['threadId'] = unpack('V', substr($packet, $pos, 4))[1]; + $pos += 4; + + // スクランブルバッファの最初の部分(8バイト) + $this->serverInfo['scramble'] = substr($packet, $pos, 8); + $pos += 8; + + // フィルターバイトをスキップする + $pos++; + + // サーバー機能(2バイト) + $this->serverInfo['capabilities'] = unpack('v', substr($packet, $pos, 2))[1]; + $pos += 2; + + // サーバー言語(1バイト) + $this->serverInfo['language'] = ord($packet[$pos]); + $pos++; + + // サーバー状況(2バイト) + $this->serverInfo['status'] = unpack('v', substr($packet, $pos, 2))[1]; + $pos += 2; + + // 13バイトのスキップ + $pos += 13; + + // その他(12バイト) + $this->serverInfo['scramble'] .= substr($packet, $pos, 12); + } + + /** + * パスワードをスクランブルする + * + * MySQL認証用のパスワードをスクランブルします。 + * + * @param string $password プレーンテキストのパスワード + * @param string $scramble サーバーから提供されたスクランブル文字列 + * @return string スクランブルされたパスワード(20バイト) + */ + private function scramblePassword(string $password, string $scramble): string { + $stage1 = sha1($password, true); + $stage2 = sha1($stage1, true); + $stage3 = sha1($scramble.$stage2, true); + + // $stage1 XOR $stage3 + $res = ''; + for ($i = 0; $i < 20; $i++) { + $res .= chr(ord($stage1[$i]) ^ ord($stage3[$i])); + } + + return $res; + } + + /** + * デバッグのためにパケットをログする + * + * パケット情報をログに追加し、デバッグ出力を表示します。 + * + * @param array $packetInfo パケット情報(方向、長さ、シーケンス番号、データ、タイムスタンプ) + * @return void + */ + private function logPacket(array $packetInfo): void { + $this->packetLog[] = $packetInfo; + + $direction = $packetInfo['direction']; + $length = $packetInfo['length']; + $seqNum = $packetInfo['seqNum']; + $data = $packetInfo['data']; + + echo "=== {$direction} パケット (長さ: {$length}, シーケンス: $seqNum) ===\n"; + echo $this->hexDumpWithAscii($data)."\n"; + + $this->interpretPacket($data); + + echo "==========================================\n\n"; + } + + /** + * バイナリデータを16進数で出力する + * + * デバッグ用にデータを16進数形式で表示します。 + * + * @param string $data バイナリデータ + * @return string 16進数文字列 + */ + private function hexDump(string $data): string { + $res = ''; + $len = strlen($data); + + for ($i = 0; $i < $len; $i++) { + $res .= sprintf('%02X ', ord($data[$i])); + + // 読み易さの為、各16バイトで新行列を入る + if (($i + 1) % 16 === 0 && $i !== $len - 1) { + $res .= "\n"; + } + } + + return $res; + } + + /** + * バイナリデータをASCIIで出力する + * + * デバッグ用にデータをASCII形式で表示します。 + * + * @param string $data バイナリデータ + * @return string ASCII文字列 + */ + private function asciiDump(string $data): string { + $res = ''; + $len = strlen($data); + + for ($i = 0; $i < $len; $i++) { + $char = ord($data[$i]); + + // 表示出来るASCII文字だけを書き出す + if ($char >= 32 && $char <= 126) { + $res .= $data[$i]; + } else { + $res .= '.'; + } + + // 読み易さの為、各16バイトで新行列を入る + if (($i + 1) % 16 === 0 && $i !== $len - 1) { + $res .= "\n"; + } + } + + return $res; + } + + /** + * バイナリデータを2進数で出力する + * + * デバッグ用にデータを2進数形式で表示します。 + * + * @param string $data バイナリデータ + * @return string 2進数文字列 + */ + private function binaryDump(string $data): string { + $res = ''; + $len = strlen($data); + + for ($i = 0; $i < $len; $i++) { + $res .= sprintf('%08b ', ord($data[$i])); + + // 読み易さの為、各88バイトで新行列を入る + if (($i + 1) % 8 === 0 && $i !== $len - 1) { + $res .= "\n"; + } + } + + return $res; + } + + /** + * パケットを最初のバイトに基づいて解釈する + * + * パケットの種類を特定し、デバッグ情報を出力します。 + * + * @param string $data パケットデータ + * @return void + */ + private function interpretPacket(string $data): void { + if (empty($data)) { + echo "解釈: 空パケット\n"; + return; + } + + $firstByte = ord($data[0]); + + switch ($firstByte) { + case 0x00: + echo "解釈: OKパケット\n"; + $this->debugOkPacket($data); + break; + case 0x17: + echo "解釈: COM_STMT_EXECUTEパケット\n"; + $this->debugStmtExecutePacket($data); + break; + case 0xFF: + echo "解釈: エラーパケット\n"; + $this->debugErrorPacket($data); + break; + case 0xFE: + echo "解釈: EOFパケット\n"; + break; + case 0xFB: + echo "解釈: LOCAL INFILEリクエスト\n"; + break; + default: + if ($firstByte === 3 + && $data[1] === 'd' + && $data[2] === 'e' + && $data[3] === 'f') { + // フィールドパケットかどうかの確認 + echo "解釈: フィールド説明パケット\n"; + $this->debugFieldPacket($data); + } else if ($firstByte > 0 && $firstByte < 251) { + // 結果セットパケットかどうかの確認 + echo "解釈: 結果セットヘッダーパケット(フィールド数: {$firstByte})\n"; + } else { + // 以上じゃないと、列データパケットでかもしん + echo "解釈: 列データパケット又はその他のパケット種類\n"; + $this->debugLengthEncodedStrings($data); + } + break; + } + } + + /** + * OKパケット構造をデバッグする + * + * OKパケットの内容を解析し、デバッグ情報を出力します。 + * + * @param string $data OKパケットデータ + * @return void + */ + private function debugOkPacket(string $data): void { + if (strlen($data) < 2) { + echo ' Error: OK packet too short ('.strlen($data)." bytes)\n"; + return; + } + + $pos = 1; // ヘッダーバイトをスキップ + + // 影響を受けた行数を取得 + $affectedRows = $this->getLengthEncodedIntegerValue($data, $pos); + echo " 影響を受けた行数: {$affectedRows}\n"; + $pos += $this->getLengthEncodedIntegerSize($affectedRows); + + // 最後の挿入IDを取得 + $insertId = $this->getLengthEncodedIntegerValue($data, $pos); + echo " 最後の挿入ID: {$insertId}\n"; + $pos += $this->getLengthEncodedIntegerSize($insertId); + + // サーバーステータス + if (strlen($data) >= $pos + 2) { + $serverStatus = unpack('v', substr($data, $pos, 2))[1]; + echo " サーバーステータス: ".sprintf('0x%04X', $serverStatus)."\n"; + $pos += 2; + } else { + echo " サーバーステータス: 利用不可\n"; + } + + // 警告カウント + if (strlen($data) >= $pos + 2) { + $warningCount = unpack('v', substr($data, $pos, 2))[1]; + echo " 警告カウント: {$warningCount}\n"; + $pos += 2; + } else { + echo " 警告カウント: 利用不可\n"; + } + + // サーバーメッセージ(存在する場合) + if (strlen($data) > $pos) { + $message = substr($data, $pos); + echo " メッセージ: ".$this->safeString($message)."\n"; + } + } + + /** + * エラーパケット構造をデバッグする + * + * エラーパケットの内容を解析し、デバッグ情報を出力します。 + * + * @param string $data エラーパケットデータ + * @return void + */ + private function debugErrorPacket(string $data): void { + $errorCode = unpack('v', substr($data, 1, 2))[1]; + echo " エラーコード: {$errorCode}\n"; + + // SQLステートマーカーをスキップ(#) + $sqlState = substr($data, 4, 5); + echo " SQLステート: {$sqlState}\n"; + + $errorMessage = substr($data, 9); + echo " エラーメッセージ: ".$this->safeString($errorMessage)."\n"; + } + + /** + * フィールドパケット構造をデバッグする + * + * フィールドパケットの内容を解析し、デバッグ情報を出力します。 + * + * @param string $data フィールドパケットデータ + * @return void + */ + private function debugFieldPacket(string $data): void { + $pos = 0; + + // パケットから長さエンコード文字列を抽出 + echo " フィールドパケット構造:\n"; + + // カタログを抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $catalog = substr($data, $pos, $len); + echo " カタログ: ".$this->safeString($catalog)." (長さ: $len)\n"; + $pos += $len; + + // データベースを抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $database = substr($data, $pos, $len); + echo " データベース: ".$this->safeString($database)." (長さ: $len)\n"; + $pos += $len; + + // テーブルを抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $table = substr($data, $pos, $len); + echo " テーブル: ".$this->safeString($table)." (長さ: $len)\n"; + $pos += $len; + + // 元のテーブルを抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $orgTable = substr($data, $pos, $len); + echo " 元のテーブル: ".$this->safeString($orgTable)." (長さ: $len)\n"; + $pos += $len; + + // 名前を抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $name = substr($data, $pos, $len); + echo " 名前: ".$this->safeString($name)." (長さ: $len)\n"; + $pos += $len; + + // 元の名前を抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $orgName = substr($data, $pos, $len); + echo " 元の名前: ".$this->safeString($orgName)." (長さ: $len)\n"; + $pos += $len; + + // 次の長さエンコード整数を抽出(固定フィールドの長さ、通常は0x0C) + $fixedLength = $this->getLengthEncodedIntegerValue($data, $pos); + echo " 固定フィールドの長さ: {$fixedLength}\n"; + $pos += $this->getLengthEncodedIntegerSize($fixedLength); + + // 文字セット + $charSet = unpack('v', substr($data, $pos, 2))[1]; + echo " 文字セット: ".sprintf('0x%04X', $charSet)."\n"; + $pos += 2; + + // 列の長さ + $columnLength = unpack('V', substr($data, $pos, 4))[1]; + echo " 列の長さ: {$columnLength}\n"; + $pos += 4; + + // 列の種類 + $columnType = ord($data[$pos]); + echo " 列の種類: ".sprintf('0x%02X', $columnType)."\n"; + $pos++; + + // フラグ + $flags = unpack('v', substr($data, $pos, 2))[1]; + echo " フラグ: ".sprintf('0x%04X', $flags)."\n"; + $pos += 2; + + // 小数点以下の桁数 + $decimals = ord($data[$pos]); + echo " 小数点以下の桁数: {$decimals}\n"; + $pos++; + + // フィルター + echo " フィルター: ".sprintf('0x%04X', unpack('v', substr($data, $pos, 2))[1])."\n"; + } + + /** + * パケット内の長さエンコード文字列をデバッグする + * + * パケットからすべての長さエンコード文字列を抽出し、デバッグ情報を出力します。 + * + * @param string $data パケットデータ + * @return void + */ + private function debugLengthEncodedStrings(string $data): void { + $pos = 0; + $length = strlen($data); + $stringCount = 0; + + echo " 長さエンコード文字列:\n"; + + while ($pos < $length) { + // 長さエンコード文字列を特定 + if ($pos >= $length) break; + + $firstByte = ord($data[$pos]); + + // MySQLプロトコルに基づく長さエンコーディング + if ($firstByte < 251) { + // 1バイト長 + $len = $firstByte; + $pos++; + + if ($pos + $len <= $length) { + $value = substr($data, $pos, $len); + echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) + ." (長さ: {$len})\n"; + $pos += $len; + } else { + echo " 位置 {$pos} での無効な長さエンコーディング\n"; + break; + } + } else if ($firstByte == 251) { + // NULL値 + echo " 文字列 ".(++$stringCount).": NULL\n"; + $pos++; + } else if ($firstByte == 252) { + // 2バイト長 + if ($pos + 3 <= $length) { + $len = unpack('v', substr($data, $pos + 1, 2))[1]; + $pos += 3; + + if ($pos + $len <= $length) { + $value = substr($data, $pos, $len); + echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) + ." (長さ: {$len})\n"; + $pos += $len; + } else { + echo " 位置 {$pos} での無効な長さエンコーディング\n"; + break; + } + } else { + echo " 位置 {$pos} での不完全な2バイト長\n"; + break; + } + } else if ($firstByte == 253) { + // 3バイト長 + if ($pos + 4 <= $length) { + $len = unpack('V', substr($data, $pos + 1, 3)."\0")[1]; + $pos += 4; + + if ($pos + $len <= $length) { + $value = substr($data, $pos, $len); + echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) + ." (長さ: {$len})\n"; + $pos += $len; + } else { + echo " 位置 {$pos} での無効な長さエンコーディング\n"; + break; + } + } else { + echo " 位置 {$pos} での不完全な3バイト長\n"; + break; + } + } else if ($firstByte == 254) { + // 8バイト長 + if ($pos + 9 <= $length) { + // PHPでは8バイト整数を完全に扱えないため、最初の4バイトのみ読み取る + $len = unpack('V', substr($data, $pos + 1, 4))[1]; + $pos += 9; + + if ($pos + $len <= $length) { + $value = substr($data, $pos, $len); + echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) + ." (長さ: {$len})\n"; + $pos += $len; + } else { + echo " 位置 {$pos} での無効な長さエンコーディング\n"; + break; + } + } else { + echo " 位置 {$pos} での不完全な8バイト長\n"; + break; + } + } else { + // 長さエンコード文字列でない場合、次のバイトへ + $pos++; + } + } + + if ($stringCount === 0) { + echo " No length-encoded strings found\n"; + } + } + + /** + * 文字列を安全に出力用に変換する + * + * 表示可能な文字のみを含み、非表示文字は16進数で表現します。 + * + * @param string $str 変換する文字列 + * @return string 安全な文字列 + */ + private function safeString(string $str): string { + $result = ''; + $length = strlen($str); + + for ($i = 0; $i < $length; $i++) { + $char = ord($str[$i]); + if ($char >= 32 && $char <= 126) { + $result .= $str[$i]; + } else { + $result .= '\\x'.sprintf('%02X', $char); + } + } + + return $result; + } + + /** + * 指定位置の長さエンコード整数値を取得する + * + * パケット内の長さエンコード整数を読み取ります。 + * + * @param string $data パケットデータ + * @param int $pos 開始位置 + * @return mixed 整数値または0(不明な場合) + */ + private function getLengthEncodedIntegerValue(string $data, int $pos): mixed { + $firstByte = ord($data[$pos]); + + if ($firstByte < 251) { + return $firstByte; + } else if ($firstByte == 252) { + return unpack('v', substr($data, $pos + 1, 2))[1]; + } else if ($firstByte == 253) { + return unpack('V', substr($data, $pos + 1, 3)."\0")[1]; + } else if ($firstByte == 254) { + // 簡略化のため、8バイト整数の最初の4バイトのみ読み取る + return unpack('V', substr($data, $pos + 1, 4))[1]; + } + + return 0; + } + + /** + * 長さエンコード整数のサイズを取得する + * + * 値に基づいて長さエンコードに必要なバイト数を返します。 + * + * @param int $value 整数値 + * @return int バイト数 + */ + private function getLengthEncodedIntegerSize(int $value): int { + if ($value < 251) { + return 1; + } else if ($value < 65536) { + return 3; // 0xFCマーカー1バイト + 値2バイト + } else if ($value < 16777216) { + return 4; // 0xFDマーカー1バイト + 値3バイト + } else { + return 9; // 0xFEマーカー1バイト + 値8バイト + } + } + + /** + * 整数を長さエンコード形式に変換する + * + * MySQLプロトコルに基づいて整数を長さエンコードします。 + * + * @param int $value 変換する整数 + * @return string 長さエンコードされた文字列 + */ + private function encodeLengthEncodedInteger(int $value): string { + if ($value < 251) { + return chr($value); + } else if ($value < 65536) { + return chr(0xFC).pack('v', $value); + } else if ($value < 16777216) { + return chr(0xFD).pack('V', $value & 0xFFFFFF); + } else { + return chr(0xFE).pack('P', $value); + } + } + + /** + * STMTパケットをデバッグする + * + * COM_STMT_EXECUTEパケットの内容を解析し、デバッグ情報を出力します。 + * + * @param string $data パケットデータ + * @return void + */ + private function debugStmtExecutePacket(string $data): void { + $pos = 1; // コマンドバイトをスキップ + $statementId = unpack('V', substr($data, $pos, 4))[1]; + echo " ステートメントID: {$statementId}\n"; + $pos += 5; // ステートメントIDとカーソルフラグをスキップ + $iterationCount = unpack('V', substr($data, $pos, 4))[1]; + echo " 繰り返し数: {$iterationCount}\n"; + $pos += 4; + + $numParams = $this->prepared[$statementId]['num_params'] ?? 0; + if ($numParams > 0) { + $nullBitmapLen = ceil($numParams / 8); + $nullBitmap = substr($data, $pos, $nullBitmapLen); + echo " NULLビットマップ: ".$this->hexDump($nullBitmap)."\n"; + $pos += $nullBitmapLen; + + $newParamsFlag = ord($data[$pos]); + echo " 新パラメータフラグ: {$newParamsFlag}\n"; + $pos++; + + if ($newParamsFlag) { + echo " パラメータ種類:\n"; + + for ($i = 0; $i < $numParams; $i++) { + $type = unpack('v', substr($data, $pos, 2))[1]; + $typeName = $this->getMysqlTypeName($type); + echo " パラメータ {$i}: ".sprintf("0x%02X", $type)." ({$typeName})\n"; + $pos += 2; + } + + echo " パラメータ値: (生バイナリが続く)\n"; + } + } + } + + /** + * 16進値をMySQLタイプ名に変換する + * + * MySQLのデータ型コードを対応する型名に変換します。 + * + * @param int $type 16進数の型コード + * @return string 型名(不明な場合は'UNKNOWN') + */ + private function getMysqlTypeName(int $type): string { + $types = [ + 0x00 => 'MYSQL_TYPE_DECIMAL', + 0x01 => 'MYSQL_TYPE_TINY', + 0x02 => 'MYSQL_TYPE_SHORT', + 0x03 => 'MYSQL_TYPE_LONG', + 0x04 => 'MYSQL_TYPE_FLOAT', + 0x05 => 'MYSQL_TYPE_DOUBLE', + 0x06 => 'MYSQL_TYPE_NULL', + 0x07 => 'MYSQL_TYPE_TIMESTAMP', + 0x08 => 'MYSQL_TYPE_LONGLONG', + 0x09 => 'MYSQL_TYPE_INT24', + 0x0A => 'MYSQL_TYPE_DATE', + 0x0B => 'MYSQL_TYPE_TIME', + 0x0C => 'MYSQL_TYPE_DATETIME', + 0x0D => 'MYSQL_TYPE_YEAR', + 0x0E => 'MYSQL_TYPE_NEWDATE', + 0x0F => 'MYSQL_TYPE_VARCHAR', + 0x10 => 'MYSQL_TYPE_BIT', + 0xF6 => 'MYSQL_TYPE_NEWDECIMAL', + 0xF7 => 'MYSQL_TYPE_ENUM', + 0xF8 => 'MYSQL_TYPE_SET', + 0xF9 => 'MYSQL_TYPE_TINY_BLOB', + 0xFA => 'MYSQL_TYPE_MEDIUM_BLOB', + 0xFB => 'MYSQL_TYPE_LONG_BLOB', + 0xFC => 'MYSQL_TYPE_BLOB', + 0xFD => 'MYSQL_TYPE_VAR_STRING', + 0xFE => 'MYSQL_TYPE_STRING', + 0xFF => 'MYSQL_TYPE_GEOMETRY', + ]; + + return $types[$type] ?? '不明'; + } + + /** + * 16進数とASCII値を横に並べて出力する + * + * 16進エディタのように、16進数とASCII値を並べて表示します。 + * + * @param string $data バイナリデータ + * @return string フォーマットされた文字列 + */ + private function hexDumpWithAscii(string $data): string { + $output = ''; + $len = strlen($data); + $offset = 0; + + while ($offset < $len) { + $hex = ''; + $ascii = ''; + $bytesInLine = min(16, $len - $offset); // 1行あたり16バイト + + // 16進数部分 + for ($i = 0; $i < 16; $i++) { + if ($i < $bytesInLine) { + $hex .= sprintf('%02X ', ord($data[$offset + $i])); + } else { + $hex .= ' '; // 揃えのためにスペースを埋める + } + } + + // ASCII部分 + for ($i = 0; $i < $bytesInLine; $i++) { + $char = ord($data[$offset + $i]); + $ascii .= ($char >= 32 && $char <= 126) ? chr($char) : '.'; + } + + $output .= sprintf("%08X %s |%s|\n", $offset, $hex, $ascii); + $offset += 16; + } + + return $output; + } } \ No newline at end of file diff --git a/src/Site/Test/LibMysql.php b/src/Site/Test/LibMysql.php index 9d9b6c5..1bb1d1f 100644 --- a/src/Site/Test/LibMysql.php +++ b/src/Site/Test/LibMysql.php @@ -1,64 +1,64 @@ - true, - 'verboseOutput' => true -]); - -$test->describe('パケットのデバッグ', function($test): void { - try { - $db = new Mysql(); - - $db->setDebug(true); - $db->connect(); - - $result = $db->query('SELECT * FROM user WHERE id = 1'); - - foreach ($result['rows'] as $row) { - echo "ユーザー名: ".$row['nickname']."\n"; - } - - $db->savePacketLogToFile('mysql_log.txt'); - $db->close(); - } catch (\Exception $e) { - echo 'エラー: '.$e->getMessage()."\n"; - } -}); - -$test->describe('プリペアドステートメント', function($test): void { - try { - $db = new Mysql(); - $db->connect(); - - // データの入り - $stmt = $db->prepare('INSERT INTO users (name, age) VALUES (?, ?)'); - $test->assertTrue($stmt); - - $db->execute($stmt, ['山田太郎', 25]); - // TODO: assert - - $close = $db->demolish($stmt); - $this->assertTrue($close); - - // データの受け取り - $stmt = $db->prepare('SELECT * FROM users WHERE age > ?'); - $test->assertTrue($stmt); - - $res = $db->execute($stmt, [20]); - // TODO: assert - print_r($res); - - $close = $db->demolish($stmt); - $this->assertTrue($close); - - $db->close(); - } catch (\Exception $e) { - echo 'エラー: '.$e->getMessage()."\n"; - } + true, + 'verboseOutput' => true +]); + +$test->describe('パケットのデバッグ', function($test): void { + try { + $db = new Mysql(); + + $db->setDebug(true); + $db->connect(); + + $result = $db->query('SELECT * FROM user WHERE id = 1'); + + foreach ($result['rows'] as $row) { + echo "ユーザー名: ".$row['nickname']."\n"; + } + + $db->savePacketLogToFile('mysql_log.txt'); + $db->close(); + } catch (\Exception $e) { + echo 'エラー: '.$e->getMessage()."\n"; + } +}); + +$test->describe('プリペアドステートメント', function($test): void { + try { + $db = new Mysql(); + $db->connect(); + + // データの入り + $stmt = $db->prepare('INSERT INTO users (name, age) VALUES (?, ?)'); + $test->assertTrue($stmt); + + $db->execute($stmt, ['山田太郎', 25]); + // TODO: assert + + $close = $db->demolish($stmt); + $this->assertTrue($close); + + // データの受け取り + $stmt = $db->prepare('SELECT * FROM users WHERE age > ?'); + $test->assertTrue($stmt); + + $res = $db->execute($stmt, [20]); + // TODO: assert + print_r($res); + + $close = $db->demolish($stmt); + $this->assertTrue($close); + + $db->close(); + } catch (\Exception $e) { + echo 'エラー: '.$e->getMessage()."\n"; + } }); \ No newline at end of file diff --git a/view/common/newscard.maron b/view/common/newscard.maron index 58190a5..97cb86c 100644 --- a/view/common/newscard.maron +++ b/view/common/newscard.maron @@ -1,23 +1,23 @@ -
- {@ if (isset($post['thumbnail']) && $post['thumbnail'] != '') @} -
- - {{ $post['title'] }} - -
- {@ endif @} -
-
- {{ $post['date'] }} - {# {@ if (isset($post['category']) && is_array($post['category'])) @} #} - {@ foreach ($post['category'] as $cat) @} - {{ $cat }} - {@ endforeach @} - {# {@ endif @} #} -
-

- {{{ $post['title'] }}} -

-

{{{ $post['preview'] }}}

-
+
+ {@ if (isset($post['thumbnail']) && $post['thumbnail'] != '') @} +
+ + {{ $post['title'] }} + +
+ {@ endif @} +
+
+ {{ $post['date'] }} + {# {@ if (isset($post['category']) && is_array($post['category'])) @} #} + {@ foreach ($post['category'] as $cat) @} + {{ $cat }} + {@ endforeach @} + {# {@ endif @} #} +
+

+ {{{ $post['title'] }}} +

+

{{{ $post['preview'] }}}

+
\ No newline at end of file diff --git a/view/common/pagination.maron b/view/common/pagination.maron index bdffdd9..c16a471 100644 --- a/view/common/pagination.maron +++ b/view/common/pagination.maron @@ -1,71 +1,71 @@ - {@ if (isset($totalPages) && $totalPages > 1) @} - + {@ if (isset($totalPages) && $totalPages > 1) @} + {@ endif @} \ No newline at end of file diff --git a/view/common/search.maron b/view/common/search.maron index 2b90440..2d5ab6d 100644 --- a/view/common/search.maron +++ b/view/common/search.maron @@ -1,6 +1,6 @@ -
-
- - -
+
+
+ + +
\ No newline at end of file diff --git a/view/monero.maron b/view/monero.maron index dcf3eaa..5ee612d 100644 --- a/view/monero.maron +++ b/view/monero.maron @@ -1,15 +1,15 @@ -{@ include(common/header) @} -

Moneroで支援♡

-

- 欲しければ、モネロ(XMR)でご支援お願い申し上げます。 -

- -

- - -

- -

-

88daW9ANXGVg9zHe6tzHSpQjxHN6JPFDz9wvZBecL1BfTFwmkuLYm9xRsLUt1WAVGPQ6h5pZX6nyu9zXFwE5efSz1gtE1oz
-

-{@ include(common/footer) @} +{@ include(common/header) @} +

Moneroで支援♡

+

+ 欲しければ、モネロ(XMR)でご支援お願い申し上げます。 +

+ +

+ + +

+ +

+

88daW9ANXGVg9zHe6tzHSpQjxHN6JPFDz9wvZBecL1BfTFwmkuLYm9xRsLUt1WAVGPQ6h5pZX6nyu9zXFwE5efSz1gtE1oz
+

+{@ include(common/footer) @} diff --git a/view/secret.maron b/view/secret.maron index fe600ec..d3864f9 100644 --- a/view/secret.maron +++ b/view/secret.maron @@ -1,6 +1,6 @@ -{@ include(common/header) @} -

秘密のページ

-

- 内緒だね~ -

-{@ include(common/footer) @} +{@ include(common/header) @} +

秘密のページ

+

+ 内緒だね~ +

+{@ include(common/footer) @}