ミラーる

諏訪子 <suwako@076.moe>、2025-11-07 22:50
コミット b1ef7f218e0d200a9d17a2de2fc85d346843dc1e ・親 438c7d8 ・ファイルを見る

19個のファイル、+2511 -2511

変更 README-JP.md
@@ -1,188 +1,188 @@
1-# Little Beast
2-シンプル、実践的、アンチ・ブロート
3-
4-## Little Beast とは?
5-Little Beast は PHP 8.3 以上向けのフレームワークで、076.moe(ゲーム開発会社)と technicalsuwako.moe(社長のブログ)向けに作られました。\
6-メイン考え方は「必要な物だけ拡張し、不要な物は削除する」です。\
7-各コア機能はライブラリに分割されている為、必要な物だけを選び易い設計になっています。\
8-全てのモジュールはゼロから書かれており、データベースは一切必要ありません。
9-
10-Little Beast はテクニカル諏訪子がゲーム開発に完全復帰する前の最後の Web プロジェクトです。
11-
12-## Little Beast が「ではない」物
13-* 汎用フレームワーク
14-* 使い憎い
15-* Web 開発者向け
16-* 万人向け
17-* インストールが面倒
18-* 教条的
19-* 民主的に運営される
20-
21-## Little Beast が「持っていない」物
22-* データベース(全てファイルベース)
23-* 依存関係
24-* パッケージマネージャー
25-* Docker/Kubernetes/Vagrant/Nix 等のコンテナ
26-* JavaScript
27-* ブロートウェア
28-* ORM
29-* 認証/認可システム
30-* キューやバックグラウンドジョブ
31-* クラウド/サーバーレス統合
32-* スキャフォールディング
33-* コード生成
34-* 抽象化レイヤー
35-* 不要なファイル
36-* DEI、行動規範、その他の差別的慣行
37-
38-## 独自機能
39-* データベース不要
40-* Composer や PEAR 不要
41-* サーバーオーバーヘッドゼロ
42-* コンテナ不要
43-* Maron テンプレートエンジン
44-* カスタム Markdown
45-* ActivityPub
46-* 多言語対応
47-* マルチブログ対応
48-* 100% 綺麗で正しい HTML5 と CSS3
49-* 100% 正しい PHP
50-* SEO フレンドリー
51-* モジュラー CSS
52-* Atom フィード
53-* 組み込みテストスイート
54-
55-## インストール方法
56-```sh
57-cd /var/www/htdocs
58-git clone https://github.com/TechnicalSuwako/LittleBeast.git .
59-mv config/config.sample.php config/config.php
60-```
61-
62-HTTP サーバーを設定して `/public` をルートとして `php-fpm` で実行して下さい。\
63-それだけです!
64-
65-### OpenBSD サーバー
66-```sh
67-pkg_add php-8.4.14 php-gmp-8.4.14
68-rcctl enable php84_fpm httpd relayd
69-rcctl start php84_fpm httpd relayd
70-```
71-
72-#### httpd
73-```
74-server "technicalsuwako.moe" {
75- listen on * tls port 8443
76- gzip-static
77- tls {
78- certificate "/etc/ssl/technicalsuwako.moe.crt"
79- key "/etc/ssl/private/technicalsuwako.moe.key"
80- }
81- root "/htdocs/technicalsuwako.moe/www/public"
82- directory index "index.php"
83- location "/.well-known/acme-challenge/*" {
84- root "/acme"
85- request strip 2
86- }
87-
88- location "/*.php" {
89- fastcgi socket "/run/php-fpm.sock"
90- }
91-
92- location "/*.php[/?]*" {
93- fastcgi socket "/run/php-fpm.sock"
94- }
95-
96- location "/" {
97- directory index "index.php"
98- }
99- location match "/blog/" {
100- request rewrite "/index.php"
101- }
102- location match "/about" {
103- request rewrite "/index.php"
104- }
105- location match "/monero" {
106- request rewrite "/index.php"
107- }
108- location match "/secret" {
109- request rewrite "/index.php"
110- }
111- location match "/ap/" {
112- request rewrite "/index.php"
113- }
114- location "/.well-known/webfinger" {
115- request rewrite "/index.php"
116- }
117- location "/blog.atom" {
118- request rewrite "/index.php"
119- }
120-}
121-
122-server "www.technicalsuwako.moe" {
123- listen on * tls port 8443
124- gzip-static
125- tls {
126- certificate "/etc/ssl/technicalsuwako.moe.crt"
127- key "/etc/ssl/private/technicalsuwako.moe.key"
128- }
129- block return 301 "https://technicalsuwako.moe$REQUEST_URI"
130-}
131-```
132-
133-#### relayd
134-```
135-relayd_addr="0.0.0.0"
136-router_addr="192.168.10.106"
137-
138-table <httpd> { $router_addr }
139-
140-http protocol reverse {
141- tcp { nodelay, sack, socket buffer 65536, backlog 100 }
142- tls ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256"
143- tls keypair "technicalsuwako.moe"
144-
145- return error
146-
147- match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
148- match request header append "X-Forwarded-Port" value "$REMOTE_PORT"
149-
150- #match response header set "Referrer-Policy" value "same-origin"
151- match response header set "X-Frame-Options" value "deny"
152- match response header set "X-Content-Type-Options" value "nosniff"
153- match response header set "Referrer-Policy" value "strict-origin-when-cross-origin"
154- match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload"
155- match response header set "Cross-Origin-Opener-Policy" value "same-origin"
156- 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';"
157- match response header append "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=()"
158-
159- match request header "Accept-Encoding" value "gzip" tag "gzip"
160- match response header "Content-Type" value "text/*" tag "compress"
161- match response header "Content-Type" value "text/html" tag "charset=UTF-8"
162- match response header "Content-Type" value "application/javascript" tag "compress"
163- match response header "Content-Type" value "application/json" tag "compress"
164-
165- pass request quick header "Host" value "technicalsuwako.moe" forward to <httpd>
166-
167- pass
168-}
169-
170-relay www_tls {
171- listen on $relayd_addr port 443 tls
172- protocol reverse
173-
174- # Default
175- forward to <httpd> port 8443 check tcp
176-}
177-
178-relay www_www {
179- listen on $relayd_addr port 80
180- protocol reverse
181-
182- # Default
183- forward to <httpd> port 8080 check tcp
184-}
185-```
186-
187-## 必要な PHP モジュール
188-* php_gmp
1+# Little Beast
2+シンプル、実践的、アンチ・ブロート
3+
4+## Little Beast とは?
5+Little Beast は PHP 8.3 以上向けのフレームワークで、076.moe(ゲーム開発会社)と technicalsuwako.moe(社長のブログ)向けに作られました。\
6+メイン考え方は「必要な物だけ拡張し、不要な物は削除する」です。\
7+各コア機能はライブラリに分割されている為、必要な物だけを選び易い設計になっています。\
8+全てのモジュールはゼロから書かれており、データベースは一切必要ありません。
9+
10+Little Beast はテクニカル諏訪子がゲーム開発に完全復帰する前の最後の Web プロジェクトです。
11+
12+## Little Beast が「ではない」物
13+* 汎用フレームワーク
14+* 使い憎い
15+* Web 開発者向け
16+* 万人向け
17+* インストールが面倒
18+* 教条的
19+* 民主的に運営される
20+
21+## Little Beast が「持っていない」物
22+* データベース(全てファイルベース)
23+* 依存関係
24+* パッケージマネージャー
25+* Docker/Kubernetes/Vagrant/Nix 等のコンテナ
26+* JavaScript
27+* ブロートウェア
28+* ORM
29+* 認証/認可システム
30+* キューやバックグラウンドジョブ
31+* クラウド/サーバーレス統合
32+* スキャフォールディング
33+* コード生成
34+* 抽象化レイヤー
35+* 不要なファイル
36+* DEI、行動規範、その他の差別的慣行
37+
38+## 独自機能
39+* データベース不要
40+* Composer や PEAR 不要
41+* サーバーオーバーヘッドゼロ
42+* コンテナ不要
43+* Maron テンプレートエンジン
44+* カスタム Markdown
45+* ActivityPub
46+* 多言語対応
47+* マルチブログ対応
48+* 100% 綺麗で正しい HTML5 と CSS3
49+* 100% 正しい PHP
50+* SEO フレンドリー
51+* モジュラー CSS
52+* Atom フィード
53+* 組み込みテストスイート
54+
55+## インストール方法
56+```sh
57+cd /var/www/htdocs
58+git clone https://github.com/TechnicalSuwako/LittleBeast.git .
59+mv config/config.sample.php config/config.php
60+```
61+
62+HTTP サーバーを設定して `/public` をルートとして `php-fpm` で実行して下さい。\
63+それだけです!
64+
65+### OpenBSD サーバー
66+```sh
67+pkg_add php-8.4.14 php-gmp-8.4.14
68+rcctl enable php84_fpm httpd relayd
69+rcctl start php84_fpm httpd relayd
70+```
71+
72+#### httpd
73+```
74+server "technicalsuwako.moe" {
75+ listen on * tls port 8443
76+ gzip-static
77+ tls {
78+ certificate "/etc/ssl/technicalsuwako.moe.crt"
79+ key "/etc/ssl/private/technicalsuwako.moe.key"
80+ }
81+ root "/htdocs/technicalsuwako.moe/www/public"
82+ directory index "index.php"
83+ location "/.well-known/acme-challenge/*" {
84+ root "/acme"
85+ request strip 2
86+ }
87+
88+ location "/*.php" {
89+ fastcgi socket "/run/php-fpm.sock"
90+ }
91+
92+ location "/*.php[/?]*" {
93+ fastcgi socket "/run/php-fpm.sock"
94+ }
95+
96+ location "/" {
97+ directory index "index.php"
98+ }
99+ location match "/blog/" {
100+ request rewrite "/index.php"
101+ }
102+ location match "/about" {
103+ request rewrite "/index.php"
104+ }
105+ location match "/monero" {
106+ request rewrite "/index.php"
107+ }
108+ location match "/secret" {
109+ request rewrite "/index.php"
110+ }
111+ location match "/ap/" {
112+ request rewrite "/index.php"
113+ }
114+ location "/.well-known/webfinger" {
115+ request rewrite "/index.php"
116+ }
117+ location "/blog.atom" {
118+ request rewrite "/index.php"
119+ }
120+}
121+
122+server "www.technicalsuwako.moe" {
123+ listen on * tls port 8443
124+ gzip-static
125+ tls {
126+ certificate "/etc/ssl/technicalsuwako.moe.crt"
127+ key "/etc/ssl/private/technicalsuwako.moe.key"
128+ }
129+ block return 301 "https://technicalsuwako.moe$REQUEST_URI"
130+}
131+```
132+
133+#### relayd
134+```
135+relayd_addr="0.0.0.0"
136+router_addr="192.168.10.106"
137+
138+table <httpd> { $router_addr }
139+
140+http protocol reverse {
141+ tcp { nodelay, sack, socket buffer 65536, backlog 100 }
142+ tls ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256"
143+ tls keypair "technicalsuwako.moe"
144+
145+ return error
146+
147+ match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
148+ match request header append "X-Forwarded-Port" value "$REMOTE_PORT"
149+
150+ #match response header set "Referrer-Policy" value "same-origin"
151+ match response header set "X-Frame-Options" value "deny"
152+ match response header set "X-Content-Type-Options" value "nosniff"
153+ match response header set "Referrer-Policy" value "strict-origin-when-cross-origin"
154+ match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload"
155+ match response header set "Cross-Origin-Opener-Policy" value "same-origin"
156+ 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';"
157+ match response header append "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=()"
158+
159+ match request header "Accept-Encoding" value "gzip" tag "gzip"
160+ match response header "Content-Type" value "text/*" tag "compress"
161+ match response header "Content-Type" value "text/html" tag "charset=UTF-8"
162+ match response header "Content-Type" value "application/javascript" tag "compress"
163+ match response header "Content-Type" value "application/json" tag "compress"
164+
165+ pass request quick header "Host" value "technicalsuwako.moe" forward to <httpd>
166+
167+ pass
168+}
169+
170+relay www_tls {
171+ listen on $relayd_addr port 443 tls
172+ protocol reverse
173+
174+ # Default
175+ forward to <httpd> port 8443 check tcp
176+}
177+
178+relay www_www {
179+ listen on $relayd_addr port 80
180+ protocol reverse
181+
182+ # Default
183+ forward to <httpd> port 8080 check tcp
184+}
185+```
186+
187+## 必要な PHP モジュール
188+* php_gmp
@@ -1,22 +1,22 @@
1-title: テーブルの例
2-uuid: 684d234e-f0d6-4da0-8f38-71c7105262d5
3-author: Little-san
4-date: 2025-04-11 08:50:57
5-category: test,css
6-css: table
7-----
8-テーブルの例だ。
9-複数CSSファイルを含むには、コンマで分けて下さい。
10-例えば: `css: table,search`
11-
12-## プログラミング言語のランキング
13-| プログラミング言語 | ランキング | 理由 |
14-|-------|-----|----|
15-| C | ★★★★★ | どこでも使える |
16-| C++ | ★★★★★ | たーのし~ |
17-| PHP | ★★★★☆ | Little BeastをPHPで作ったから |
18-| Go | ★★★☆☆ | 言語は問題ないけど、会社は親DEI・・・ |
19-| Ruby | ★☆☆☆☆ | 遅過ぎる |
20-| Javascript | ★☆☆☆☆ | 🤡 |
21-| Rust | ★☆☆☆☆ | ゲイ!! |
1+title: テーブルの例
2+uuid: 684d234e-f0d6-4da0-8f38-71c7105262d5
3+author: Little-san
4+date: 2025-04-11 08:50:57
5+category: test,css
6+css: table
7+----
8+テーブルの例だ。
9+複数CSSファイルを含むには、コンマで分けて下さい。
10+例えば: `css: table,search`
11+
12+## プログラミング言語のランキング
13+| プログラミング言語 | ランキング | 理由 |
14+|-------|-----|----|
15+| C | ★★★★★ | どこでも使える |
16+| C++ | ★★★★★ | たーのし~ |
17+| PHP | ★★★★☆ | Little BeastをPHPで作ったから |
18+| Go | ★★★☆☆ | 言語は問題ないけど、会社は親DEI・・・ |
19+| Ruby | ★☆☆☆☆ | 遅過ぎる |
20+| Javascript | ★☆☆☆☆ | 🤡 |
21+| Rust | ★☆☆☆☆ | ゲイ!! |
2222 | Zig | ★★★☆☆ | 良いけど、未だ開発中 |
@@ -1,20 +1,20 @@
1-.fraction {
2- display: inline-block;
3- vertical-align: middle;
4- text-align: center;
5- position: relative;
6- margin: 0 0.2em;
7-}
8-
9-.fraction .numerator, .fraction .denominator {
10- display: block;
11- font-size: 0.8em;
12-}
13-
14-.fraction .numerator {
15- border-bottom: 1px solid #fcfcfc;
16-}
17-
18-.algebraic {
19- font-family: 'Times New Roman', serif;
1+.fraction {
2+ display: inline-block;
3+ vertical-align: middle;
4+ text-align: center;
5+ position: relative;
6+ margin: 0 0.2em;
7+}
8+
9+.fraction .numerator, .fraction .denominator {
10+ display: block;
11+ font-size: 0.8em;
12+}
13+
14+.fraction .numerator {
15+ border-bottom: 1px solid #fcfcfc;
16+}
17+
18+.algebraic {
19+ font-family: 'Times New Roman', serif;
2020 }
@@ -1,9 +1,9 @@
1-.blink {
2- animation: blinker 1s linear infinite;
3-}
4-
5-@keyframes blinker {
6- 50% {
7- opacity: 0;
8- }
1+.blink {
2+ animation: blinker 1s linear infinite;
3+}
4+
5+@keyframes blinker {
6+ 50% {
7+ opacity: 0;
8+ }
99 }
@@ -1,8 +1,8 @@
1-blockquote {
2- background: #121012;
3- border: 2px solid #f545f5;
4- border-radius: 2px;
5- border-left: 12px solid #c016c6;
6- margin: 1.5em 10px;
7- padding: 0.5em 10px;
1+blockquote {
2+ background: #121012;
3+ border: 2px solid #f545f5;
4+ border-radius: 2px;
5+ border-left: 12px solid #c016c6;
6+ margin: 1.5em 10px;
7+ padding: 0.5em 10px;
88 }
@@ -1,36 +1,36 @@
1-.blog-type > p {
2- font-weight: bolder;
3-}
4-
5-a.blog-type-btn {
6- background-color: #550f75;
7- color: #120f12;
8- text-decoration: none;
9- border: 1px solid #fcfcfc;
10- border-radius: 4px;
11- padding: 4px;
12- margin: 4px;
13- transition: background-color 0.9s;
14-}
15-
16-a.blog-type-btn.active {
17- background-color: #c016c6;
18-}
19-
20-hr.blog-type-line {
21- border: 3px dotted #c016c6;
22-}
23-
24-a.blog-type-btn:hover {
25- background-color: #ea79d8;
26-}
27-
28-a.blog-type-btn.active:ae6bdb {
29- background-color: #ae6bdb;
30-}
31-
32-@media only screen and (max-width: 768px) {
33- a.blog-type-btn {
34- display: block;
35- }
1+.blog-type > p {
2+ font-weight: bolder;
3+}
4+
5+a.blog-type-btn {
6+ background-color: #550f75;
7+ color: #120f12;
8+ text-decoration: none;
9+ border: 1px solid #fcfcfc;
10+ border-radius: 4px;
11+ padding: 4px;
12+ margin: 4px;
13+ transition: background-color 0.9s;
14+}
15+
16+a.blog-type-btn.active {
17+ background-color: #c016c6;
18+}
19+
20+hr.blog-type-line {
21+ border: 3px dotted #c016c6;
22+}
23+
24+a.blog-type-btn:hover {
25+ background-color: #ea79d8;
26+}
27+
28+a.blog-type-btn.active:ae6bdb {
29+ background-color: #ae6bdb;
30+}
31+
32+@media only screen and (max-width: 768px) {
33+ a.blog-type-btn {
34+ display: block;
35+ }
3636 }
@@ -1,40 +1,40 @@
1-.diff-table {
2- width: 100%;
3- border-collapse: collapse;
4- font-family: monospace;
5- color: #fcfcfc;
6-}
7-
8-.diff-table td {
9- border: 1px solid #bcb4bc;
10- padding: 5px;
11- vertical-align: top;
12-}
13-
14-.diff-header th {
15- border: 1px solid #bcb4bc;
16-}
17-
18-.line-number {
19- width: 50px;
20- text-align: right;
21- color: #c016c6;
22-}
23-
24-.removed {
25- background-color: #fa9faa;
26- color: #b61729;
27-}
28-
29-.added {
30- background-color: #88ecc1;
31- color: #2c980c;
32-}
33-
34-.context {
35- background-color: #232320;
36-}
37-
38-.empty {
39- background-color: #746c75;
1+.diff-table {
2+ width: 100%;
3+ border-collapse: collapse;
4+ font-family: monospace;
5+ color: #fcfcfc;
6+}
7+
8+.diff-table td {
9+ border: 1px solid #bcb4bc;
10+ padding: 5px;
11+ vertical-align: top;
12+}
13+
14+.diff-header th {
15+ border: 1px solid #bcb4bc;
16+}
17+
18+.line-number {
19+ width: 50px;
20+ text-align: right;
21+ color: #c016c6;
22+}
23+
24+.removed {
25+ background-color: #fa9faa;
26+ color: #b61729;
27+}
28+
29+.added {
30+ background-color: #88ecc1;
31+ color: #2c980c;
32+}
33+
34+.context {
35+ background-color: #232320;
36+}
37+
38+.empty {
39+ background-color: #746c75;
4040 }
@@ -1,16 +1,16 @@
1-hr {
2- border: 1px solid #ea79d8;
3-}
4-
5-.pager {
6- display: grid;
7- grid-template-columns: repeat(2, 1fr);
8-}
9-
10-.prev {
11- text-align: left;
12-}
13-
14-.next {
15- text-align: right;
1+hr {
2+ border: 1px solid #ea79d8;
3+}
4+
5+.pager {
6+ display: grid;
7+ grid-template-columns: repeat(2, 1fr);
8+}
9+
10+.prev {
11+ text-align: left;
12+}
13+
14+.next {
15+ text-align: right;
1616 }
@@ -1,94 +1,94 @@
1-<?php
2-namespace Site\Controller;
3-
4-use Site\Controller\BlogPost;
5-use Site\Lib\Markdown;
6-
7-class Atom extends BlogPost {
8- private string $domain = 'technicalsuwako.moe';
9-
10- /**
11- * 最新の5記事のAtomフィードを生成する
12- *
13- * @param array $params パラメータ配列
14- * @return void
15- */
16- public function feed(array $params): void {
17- try {
18- // 最新の投稿を取得
19- $posts = $this->getPosts('/blog/');
20- // 最新の5件に制限
21- $posts = array_slice($posts, 0, 5);
22-
23- // サイトのドメインを取得
24- $domain = $_SERVER['HTTP_HOST'];
25- $baseUrl = 'https://'.$domain;
26-
27- // 現在の日時(RFC3339形式)
28- $published = date('c');
29-
30- // XMLヘッダーとコンテンツタイプを設定
31- header('Content-Type: application/atom+xml; charset=utf-8');
32-
33- // Atomフィードの開始部分
34- echo '<?xml version="1.0" encoding="utf-8"?>'."\n";
35- echo '<feed xmlns="http://www.w3.org/2005/Atom">'."\n";
36-
37- // フィードの基本情報
38- echo ' <title>'.SITEINFO['title'].'</title>'."\n";
39- echo ' <link href="'.$baseUrl.'" />'."\n";
40- echo ' <link href="'.$baseUrl.'/blog.atom" rel="self" />'."\n";
41- echo ' <id>'.$baseUrl.'/</id>'."\n";
42- echo ' <published>'.$published.'</published>'."\n";
43- echo ' <updated>'.$published.'</updated>'."\n";
44- echo ' <author>'."\n";
45- echo ' <name>'.SITEINFO['title'].'</name>'."\n";
46- echo ' </author>'."\n";
47-
48- // 各エントリー(記事)
49- foreach ($posts as $post) {
50- // 記事の本文を取得(プレーンテキスト)
51- $path = ROOT.'/blog/'.$post['slug'].'.md';
52- $content = '';
53- $postPublished = date('c', strtotime($post['date']));
54-
55- if (file_exists($path)) {
56- $fileContent = file_get_contents($path);
57- $parts = explode('----', $fileContent, 2);
58- if (count($parts) > 1) {
59- // 本文をHTMLとして準備
60- $md = new Markdown($post['slug'], '/blog/');
61- $content = $md->parse();
62- // HTMLタグを取り除かないようにCDATAで囲む
63- $content = '<![CDATA['.$content.']]>';
64- }
65- }
66-
67- echo ' <entry>'."\n";
68- echo ' <title>'.htmlspecialchars($post['title']).'</title>'."\n";
69- echo ' <link href="'.$baseUrl.'/blog/'.$post['slug'].'" />'."\n";
70- echo ' <id>'.$baseUrl.'/blog/'.$post['slug'].'</id>'."\n";
71- echo ' <published>'.$postPublished.'</published>'."\n";
72-
73- // カテゴリ(タグ)
74- if (isset($post['category']) && is_array($post['category'])) {
75- foreach ($post['category'] as $category) {
76- echo ' <category term="'.htmlspecialchars($category).'" />'."\n";
77- }
78- }
79-
80- // 本文(要約または全文)
81- echo ' <content type="html">'.$content.'</content>'."\n";
82- echo ' </entry>'."\n";
83- }
84-
85- // フィードの終了
86- echo '</feed>';
87- exit;
88- } catch (\Exception $e) {
89- header('Content-Type: text/plain; charset=utf-8');
90- echo 'フィードの作成に失敗: '.$e->getMessage();
91- exit;
92- }
93- }
94-}
1+<?php
2+namespace Site\Controller;
3+
4+use Site\Controller\BlogPost;
5+use Site\Lib\Markdown;
6+
7+class Atom extends BlogPost {
8+ private string $domain = 'technicalsuwako.moe';
9+
10+ /**
11+ * 最新の5記事のAtomフィードを生成する
12+ *
13+ * @param array $params パラメータ配列
14+ * @return void
15+ */
16+ public function feed(array $params): void {
17+ try {
18+ // 最新の投稿を取得
19+ $posts = $this->getPosts('/blog/');
20+ // 最新の5件に制限
21+ $posts = array_slice($posts, 0, 5);
22+
23+ // サイトのドメインを取得
24+ $domain = $_SERVER['HTTP_HOST'];
25+ $baseUrl = 'https://'.$domain;
26+
27+ // 現在の日時(RFC3339形式)
28+ $published = date('c');
29+
30+ // XMLヘッダーとコンテンツタイプを設定
31+ header('Content-Type: application/atom+xml; charset=utf-8');
32+
33+ // Atomフィードの開始部分
34+ echo '<?xml version="1.0" encoding="utf-8"?>'."\n";
35+ echo '<feed xmlns="http://www.w3.org/2005/Atom">'."\n";
36+
37+ // フィードの基本情報
38+ echo ' <title>'.SITEINFO['title'].'</title>'."\n";
39+ echo ' <link href="'.$baseUrl.'" />'."\n";
40+ echo ' <link href="'.$baseUrl.'/blog.atom" rel="self" />'."\n";
41+ echo ' <id>'.$baseUrl.'/</id>'."\n";
42+ echo ' <published>'.$published.'</published>'."\n";
43+ echo ' <updated>'.$published.'</updated>'."\n";
44+ echo ' <author>'."\n";
45+ echo ' <name>'.SITEINFO['title'].'</name>'."\n";
46+ echo ' </author>'."\n";
47+
48+ // 各エントリー(記事)
49+ foreach ($posts as $post) {
50+ // 記事の本文を取得(プレーンテキスト)
51+ $path = ROOT.'/blog/'.$post['slug'].'.md';
52+ $content = '';
53+ $postPublished = date('c', strtotime($post['date']));
54+
55+ if (file_exists($path)) {
56+ $fileContent = file_get_contents($path);
57+ $parts = explode('----', $fileContent, 2);
58+ if (count($parts) > 1) {
59+ // 本文をHTMLとして準備
60+ $md = new Markdown($post['slug'], '/blog/');
61+ $content = $md->parse();
62+ // HTMLタグを取り除かないようにCDATAで囲む
63+ $content = '<![CDATA['.$content.']]>';
64+ }
65+ }
66+
67+ echo ' <entry>'."\n";
68+ echo ' <title>'.htmlspecialchars($post['title']).'</title>'."\n";
69+ echo ' <link href="'.$baseUrl.'/blog/'.$post['slug'].'" />'."\n";
70+ echo ' <id>'.$baseUrl.'/blog/'.$post['slug'].'</id>'."\n";
71+ echo ' <published>'.$postPublished.'</published>'."\n";
72+
73+ // カテゴリ(タグ)
74+ if (isset($post['category']) && is_array($post['category'])) {
75+ foreach ($post['category'] as $category) {
76+ echo ' <category term="'.htmlspecialchars($category).'" />'."\n";
77+ }
78+ }
79+
80+ // 本文(要約または全文)
81+ echo ' <content type="html">'.$content.'</content>'."\n";
82+ echo ' </entry>'."\n";
83+ }
84+
85+ // フィードの終了
86+ echo '</feed>';
87+ exit;
88+ } catch (\Exception $e) {
89+ header('Content-Type: text/plain; charset=utf-8');
90+ echo 'フィードの作成に失敗: '.$e->getMessage();
91+ exit;
92+ }
93+ }
94+}
@@ -1,66 +1,66 @@
1-<?php
2-namespace Site\Controller;
3-
4-class BlogPost {
5- /**
6- * ブログ投稿を取得する
7- *
8- * @return array 投稿の配列
9- */
10- public function getPosts(string $section): array {
11- $path = ROOT.$section;
12- $posts = [];
13-
14- if (!is_dir($path)) return $posts;
15- $files = glob($path.'/*.md');
16-
17- foreach ($files as $file) {
18- $content = file_get_contents($file);
19- $parts = explode('----', $content, 2);
20- if (count($parts) != 2) continue;
21-
22- $metadata = [];
23- $meta = explode("\n", trim($parts[0]));
24-
25- foreach ($meta as $line) {
26- $line = trim($line);
27- if (empty($line)) continue;
28-
29- $colonPos = strpos($line, ':');
30- if ($colonPos === false) continue;
31-
32- $key = trim(substr($line, 0, $colonPos));
33- $value = trim(substr($line, $colonPos + 1));
34- $value = trim($value, '"\'');
35-
36- if ($key == 'category') {
37- $metadata[$key] = array_map('trim', explode(',', $value));
38- } else {
39- $metadata[$key] = $value;
40- }
41- }
42-
43- $articleBody = trim($parts[1]);
44- $preview = mb_substr(strip_tags($articleBody), 0, 50).'...';
45- $slug = basename($file, '.md');
46-
47- $posts[] = [
48- 'title' => $metadata['title'] ?? '',
49- 'date' => $metadata['date'] ?? '',
50- 'thumbnail' => $metadata['thumbnail'] ?? '',
51- 'thumborient' => $metadata['thumborient'] ?? '',
52- 'category' => $metadata['category'] ?? [],
53- 'uuid' => $metadata['uuid'] ?? '',
54- 'preview' => $preview,
55- 'slug' => $slug,
56- ];
57- }
58-
59- // 日付でソート(新しい順)
60- usort($posts, function($a, $b) {
61- return strtotime($b['date']) - strtotime($a['date']);
62- });
63-
64- return $posts;
65- }
66-}
1+<?php
2+namespace Site\Controller;
3+
4+class BlogPost {
5+ /**
6+ * ブログ投稿を取得する
7+ *
8+ * @return array 投稿の配列
9+ */
10+ public function getPosts(string $section): array {
11+ $path = ROOT.$section;
12+ $posts = [];
13+
14+ if (!is_dir($path)) return $posts;
15+ $files = glob($path.'/*.md');
16+
17+ foreach ($files as $file) {
18+ $content = file_get_contents($file);
19+ $parts = explode('----', $content, 2);
20+ if (count($parts) != 2) continue;
21+
22+ $metadata = [];
23+ $meta = explode("\n", trim($parts[0]));
24+
25+ foreach ($meta as $line) {
26+ $line = trim($line);
27+ if (empty($line)) continue;
28+
29+ $colonPos = strpos($line, ':');
30+ if ($colonPos === false) continue;
31+
32+ $key = trim(substr($line, 0, $colonPos));
33+ $value = trim(substr($line, $colonPos + 1));
34+ $value = trim($value, '"\'');
35+
36+ if ($key == 'category') {
37+ $metadata[$key] = array_map('trim', explode(',', $value));
38+ } else {
39+ $metadata[$key] = $value;
40+ }
41+ }
42+
43+ $articleBody = trim($parts[1]);
44+ $preview = mb_substr(strip_tags($articleBody), 0, 50).'...';
45+ $slug = basename($file, '.md');
46+
47+ $posts[] = [
48+ 'title' => $metadata['title'] ?? '',
49+ 'date' => $metadata['date'] ?? '',
50+ 'thumbnail' => $metadata['thumbnail'] ?? '',
51+ 'thumborient' => $metadata['thumborient'] ?? '',
52+ 'category' => $metadata['category'] ?? [],
53+ 'uuid' => $metadata['uuid'] ?? '',
54+ 'preview' => $preview,
55+ 'slug' => $slug,
56+ ];
57+ }
58+
59+ // 日付でソート(新しい順)
60+ usort($posts, function($a, $b) {
61+ return strtotime($b['date']) - strtotime($a['date']);
62+ });
63+
64+ return $posts;
65+ }
66+}
@@ -1,151 +1,151 @@
1-<?php
2-namespace Site\Controller;
3-
4-use Site\Controller\BlogPost;
5-use Site\Controller\Mods;
6-use Site\Lib\Activitypub;
7-
8-class Fediverse extends BlogPost {
9- use Mods;
10-
11- /**
12- * @param array $params パラメータ配列
13- * @return void
14- */
15- public function apfinger(array $params): void {
16- try {
17- header('Content-Type: application/jrd+json');
18- $ap = new Activitypub();
19- echo $ap->getWebfinger();
20- exit;
21- } catch (\Exception $e) {
22- header('Content-Type: text/plain; charset=utf-8');
23- echo 'フェディバースの作成に失敗: '.$e->getMessage();
24- exit;
25- }
26- }
27-
28- /**
29- * @param array $params パラメータ配列
30- * @return void
31- */
32- public function apactor(array $params): void {
33- try {
34- header('Content-Type: application/activity+json');
35- $ap = new Activitypub();
36- echo $ap->getActor();
37- exit;
38- } catch (\Exception $e) {
39- header('Content-Type: text/plain; charset=utf-8');
40- echo 'フェディバースの作成に失敗: '.$e->getMessage();
41- exit;
42- }
43- }
44-
45- /**
46- * @param array $params パラメータ配列
47- * @return void
48- */
49- public function apinbox(array $params): void {
50- if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
51- header('HTTP/1.1 405 Method Not Allowed');
52- header('Allow: POST');
53- exit;
54- }
55-
56- $input = file_get_contents('php://input');
57- $activity = json_decode($input, true);
58- if (!$activity || !isset($activity['type'])) {
59- header('HTTP/1.1 400 Bad Request');
60- header('Content-Type: application/activity+json');
61- echo json_encode(['error' => '不正なアクティビティ']);
62- exit;
63- }
64-
65- logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity));
66-
67- try {
68- header('Content-Type: application/activity+json');
69- $ap = new Activitypub();
70- $ap->postInbox($activity);
71- exit;
72- } catch (\Exception $e) {
73- header('Content-Type: text/plain; charset=utf-8');
74- echo 'フェディバースの作成に失敗: '.$e->getMessage();
75- exit;
76- }
77- }
78-
79- /**
80- * @param array $params パラメータ配列
81- * @return void
82- */
83- public function apactivity(array $params): void {
84- $uuid = '';
85- if (isset($params['uuid'])) $uuid = $params['uuid'];
86-
87- try {
88- header('Content-Type: application/activity+json');
89- $posts = $this->getPosts('/blog/');
90- $ap = new Activitypub($posts);
91- echo $ap->getActivity($uuid);
92- exit;
93- } catch (\Exception $e) {
94- header('Content-Type: text/plain; charset=utf-8');
95- echo 'フェディバースの作成に失敗: '.$e->getMessage();
96- exit;
97- }
98- }
99-
100- /**
101- * @param array $params パラメータ配列
102- * @return void
103- */
104- public function apoutbox(array $params): void {
105- try {
106- header('Content-Type: application/activity+json');
107- $posts = $this->getPosts('/blog/');
108- $ap = new Activitypub($posts);
109- echo $ap->getOutbox();
110- exit;
111- } catch (\Exception $e) {
112- header('Content-Type: text/plain; charset=utf-8');
113- echo 'フェディバースの作成に失敗: '.$e->getMessage();
114- exit;
115- }
116- }
117-
118- /**
119- * @param array $params パラメータ配列
120- * @return void
121- */
122- public function apfollowers(array $params): void {
123- try {
124- header('Content-Type: application/activity+json');
125- $ap = new Activitypub();
126- echo $ap->getFollowers();
127- exit;
128- } catch (\Exception $e) {
129- header('Content-Type: text/plain; charset=utf-8');
130- echo 'フェディバースの作成に失敗: '.$e->getMessage();
131- exit;
132- }
133- }
134-
135- /**
136- * @param array $params パラメータ配列
137- * @return void
138- */
139- public function apfollowing(array $params): void {
140- try {
141- header('Content-Type: application/activity+json');
142- $ap = new Activitypub();
143- echo $ap->getFollowing();
144- exit;
145- } catch (\Exception $e) {
146- header('Content-Type: text/plain; charset=utf-8');
147- echo 'フェディバースの作成に失敗: '.$e->getMessage();
148- exit;
149- }
150- }
151-}
1+<?php
2+namespace Site\Controller;
3+
4+use Site\Controller\BlogPost;
5+use Site\Controller\Mods;
6+use Site\Lib\Activitypub;
7+
8+class Fediverse extends BlogPost {
9+ use Mods;
10+
11+ /**
12+ * @param array $params パラメータ配列
13+ * @return void
14+ */
15+ public function apfinger(array $params): void {
16+ try {
17+ header('Content-Type: application/jrd+json');
18+ $ap = new Activitypub();
19+ echo $ap->getWebfinger();
20+ exit;
21+ } catch (\Exception $e) {
22+ header('Content-Type: text/plain; charset=utf-8');
23+ echo 'フェディバースの作成に失敗: '.$e->getMessage();
24+ exit;
25+ }
26+ }
27+
28+ /**
29+ * @param array $params パラメータ配列
30+ * @return void
31+ */
32+ public function apactor(array $params): void {
33+ try {
34+ header('Content-Type: application/activity+json');
35+ $ap = new Activitypub();
36+ echo $ap->getActor();
37+ exit;
38+ } catch (\Exception $e) {
39+ header('Content-Type: text/plain; charset=utf-8');
40+ echo 'フェディバースの作成に失敗: '.$e->getMessage();
41+ exit;
42+ }
43+ }
44+
45+ /**
46+ * @param array $params パラメータ配列
47+ * @return void
48+ */
49+ public function apinbox(array $params): void {
50+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
51+ header('HTTP/1.1 405 Method Not Allowed');
52+ header('Allow: POST');
53+ exit;
54+ }
55+
56+ $input = file_get_contents('php://input');
57+ $activity = json_decode($input, true);
58+ if (!$activity || !isset($activity['type'])) {
59+ header('HTTP/1.1 400 Bad Request');
60+ header('Content-Type: application/activity+json');
61+ echo json_encode(['error' => '不正なアクティビティ']);
62+ exit;
63+ }
64+
65+ logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity));
66+
67+ try {
68+ header('Content-Type: application/activity+json');
69+ $ap = new Activitypub();
70+ $ap->postInbox($activity);
71+ exit;
72+ } catch (\Exception $e) {
73+ header('Content-Type: text/plain; charset=utf-8');
74+ echo 'フェディバースの作成に失敗: '.$e->getMessage();
75+ exit;
76+ }
77+ }
78+
79+ /**
80+ * @param array $params パラメータ配列
81+ * @return void
82+ */
83+ public function apactivity(array $params): void {
84+ $uuid = '';
85+ if (isset($params['uuid'])) $uuid = $params['uuid'];
86+
87+ try {
88+ header('Content-Type: application/activity+json');
89+ $posts = $this->getPosts('/blog/');
90+ $ap = new Activitypub($posts);
91+ echo $ap->getActivity($uuid);
92+ exit;
93+ } catch (\Exception $e) {
94+ header('Content-Type: text/plain; charset=utf-8');
95+ echo 'フェディバースの作成に失敗: '.$e->getMessage();
96+ exit;
97+ }
98+ }
99+
100+ /**
101+ * @param array $params パラメータ配列
102+ * @return void
103+ */
104+ public function apoutbox(array $params): void {
105+ try {
106+ header('Content-Type: application/activity+json');
107+ $posts = $this->getPosts('/blog/');
108+ $ap = new Activitypub($posts);
109+ echo $ap->getOutbox();
110+ exit;
111+ } catch (\Exception $e) {
112+ header('Content-Type: text/plain; charset=utf-8');
113+ echo 'フェディバースの作成に失敗: '.$e->getMessage();
114+ exit;
115+ }
116+ }
117+
118+ /**
119+ * @param array $params パラメータ配列
120+ * @return void
121+ */
122+ public function apfollowers(array $params): void {
123+ try {
124+ header('Content-Type: application/activity+json');
125+ $ap = new Activitypub();
126+ echo $ap->getFollowers();
127+ exit;
128+ } catch (\Exception $e) {
129+ header('Content-Type: text/plain; charset=utf-8');
130+ echo 'フェディバースの作成に失敗: '.$e->getMessage();
131+ exit;
132+ }
133+ }
134+
135+ /**
136+ * @param array $params パラメータ配列
137+ * @return void
138+ */
139+ public function apfollowing(array $params): void {
140+ try {
141+ header('Content-Type: application/activity+json');
142+ $ap = new Activitypub();
143+ echo $ap->getFollowing();
144+ exit;
145+ } catch (\Exception $e) {
146+ header('Content-Type: text/plain; charset=utf-8');
147+ echo 'フェディバースの作成に失敗: '.$e->getMessage();
148+ exit;
149+ }
150+ }
151+}
@@ -1,130 +1,130 @@
1-<?php
2-namespace Site\Lib;
3-
4-class DiffViewer {
5- private $diffContent;
6-
7- public function __construct(string $filePath) {
8- if (!file_exists($filePath)) {
9- throw new \Exception("Diff file not found: $filePath");
10- }
11- $this->diffContent = file_get_contents($filePath);
12- }
13-
14- public function displaySideBySide(): string {
15- $lines = explode("\n", $this->diffContent);
16- $fileDiffs = [];
17- $currentFile = null;
18- $hunk = [];
19- $lineNumbers = ['left' => 0, 'right' => 0];
20- $currentLeftLines = [];
21- $currentRightLines = [];
22-
23- foreach ($lines as $line) {
24- // ファイルヘッダーの処理
25- if (preg_match('/^---\s+(.+)/', $line, $matches)) {
26- // ファイルを処理する場合、データを保存する
27- if ($currentFile !== null) {
28- $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
29- $fileDiffs[$currentFile] = [
30- 'leftLines' => $currentLeftLines,
31- 'rightLines' => $currentRightLines
32- ];
33- $hunk = [];
34- $currentLeftLines = [];
35- $currentRightLines = [];
36- $lineNumbers = ['left' => 0, 'right' => 0];
37- }
38- $currentFile = $matches[1];
39- continue;
40- }
41- if (preg_match('/^\+\+\+\s+(.+)/', $line)) {
42- continue;
43- }
44-
45- // ハンクヘッダーの処理 (例:@@ -10,6 +10,7 @@)
46- if (preg_match('/^@@\s+-(\d+),\d+\s+\+(\d+),\d+\s+@@/', $line, $matches)) {
47- $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
48- $hunk = [];
49- $lineNumbers['left'] = (int)$matches[1];
50- $lineNumbers['right'] = (int)$matches[2];
51- continue;
52- }
53-
54- // ハンクでの行列の集まり
55- if (substr($line, 0, 1) === '-' || substr($line, 0, 1) === '+' || substr($line, 0, 1) === ' ') {
56- $hunk[] = $line;
57- }
58- }
59-
60- // 最後のハンク・ファイルの処理
61- if ($currentFile !== null) {
62- $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
63- $fileDiffs[$currentFile] = [
64- 'leftLines' => $currentLeftLines,
65- 'rightLines' => $currentRightLines
66- ];
67- }
68-
69- // 各ファイルにHTMLの出力の作成
70- $html = '';
71- foreach ($fileDiffs as $fileName => $diff) {
72- $html .= "<h2>ファイル: ".htmlspecialchars($fileName)."</h2>\n";
73- $html .= $this->generateHtml($diff['leftLines'], $diff['rightLines']);
74- }
75-
76- return $html;
77- }
78-
79- private function processHunk(array $hunk, array &$leftLines, array &$rightLines, array &$lineNumbers): void {
80- foreach ($hunk as $line) {
81- $prefix = substr($line, 0, 1);
82- $content = substr($line, 1);
83-
84- if ($prefix === '-') {
85- $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'removed', 'line' => $lineNumbers['left']];
86- $lineNumbers['left']++;
87- } elseif ($prefix === '+') {
88- $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'added', 'line' => $lineNumbers['right']];
89- $lineNumbers['right']++;
90- } elseif ($prefix === ' ') {
91- // 両側のコンテキストは同じ行列があるかの確認
92- while ($lineNumbers['left'] < $lineNumbers['right']) {
93- $leftLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['left']];
94- $lineNumbers['left']++;
95- }
96- while ($lineNumbers['right'] < $lineNumbers['left']) {
97- $rightLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['right']];
98- $lineNumbers['right']++;
99- }
100- $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['left']];
101- $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['right']];
102- $lineNumbers['left']++;
103- $lineNumbers['right']++;
104- }
105- }
106- }
107-
108- private function generateHtml(array $leftLines, array $rightLines): string {
109- $html = '<table class="diff-table">';
110- $html .= '<tr class="diff-header"><th colspan="2">前</th><th colspan="2">新</th></tr>';
111-
112- $maxLines = max(count($leftLines), count($rightLines));
113- for ($i = 0; $i < $maxLines; $i++) {
114- $left = isset($leftLines[$i]) ? $leftLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
115- $right = isset($rightLines[$i]) ? $rightLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
116-
117- $html .= '<tr>';
118- // 左(変更前)
119- $html .= '<td class="line-number">' . ($left['line'] ?: '&nbsp;') . '</td>';
120- $html .= '<td class="' . $left['type'] . '">' . ($left['content'] ?: '&nbsp;') . '</td>';
121- // 右(変更後)
122- $html .= '<td class="line-number">' . ($right['line'] ?: '&nbsp;') . '</td>';
123- $html .= '<td class="' . $right['type'] . '">' . ($right['content'] ?: '&nbsp;') . '</td>';
124- $html .= '</tr>';
125- }
126-
127- $html .= '</table>';
128- return $html;
129- }
1+<?php
2+namespace Site\Lib;
3+
4+class DiffViewer {
5+ private $diffContent;
6+
7+ public function __construct(string $filePath) {
8+ if (!file_exists($filePath)) {
9+ throw new \Exception("Diff file not found: $filePath");
10+ }
11+ $this->diffContent = file_get_contents($filePath);
12+ }
13+
14+ public function displaySideBySide(): string {
15+ $lines = explode("\n", $this->diffContent);
16+ $fileDiffs = [];
17+ $currentFile = null;
18+ $hunk = [];
19+ $lineNumbers = ['left' => 0, 'right' => 0];
20+ $currentLeftLines = [];
21+ $currentRightLines = [];
22+
23+ foreach ($lines as $line) {
24+ // ファイルヘッダーの処理
25+ if (preg_match('/^---\s+(.+)/', $line, $matches)) {
26+ // ファイルを処理する場合、データを保存する
27+ if ($currentFile !== null) {
28+ $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
29+ $fileDiffs[$currentFile] = [
30+ 'leftLines' => $currentLeftLines,
31+ 'rightLines' => $currentRightLines
32+ ];
33+ $hunk = [];
34+ $currentLeftLines = [];
35+ $currentRightLines = [];
36+ $lineNumbers = ['left' => 0, 'right' => 0];
37+ }
38+ $currentFile = $matches[1];
39+ continue;
40+ }
41+ if (preg_match('/^\+\+\+\s+(.+)/', $line)) {
42+ continue;
43+ }
44+
45+ // ハンクヘッダーの処理 (例:@@ -10,6 +10,7 @@)
46+ if (preg_match('/^@@\s+-(\d+),\d+\s+\+(\d+),\d+\s+@@/', $line, $matches)) {
47+ $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
48+ $hunk = [];
49+ $lineNumbers['left'] = (int)$matches[1];
50+ $lineNumbers['right'] = (int)$matches[2];
51+ continue;
52+ }
53+
54+ // ハンクでの行列の集まり
55+ if (substr($line, 0, 1) === '-' || substr($line, 0, 1) === '+' || substr($line, 0, 1) === ' ') {
56+ $hunk[] = $line;
57+ }
58+ }
59+
60+ // 最後のハンク・ファイルの処理
61+ if ($currentFile !== null) {
62+ $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
63+ $fileDiffs[$currentFile] = [
64+ 'leftLines' => $currentLeftLines,
65+ 'rightLines' => $currentRightLines
66+ ];
67+ }
68+
69+ // 各ファイルにHTMLの出力の作成
70+ $html = '';
71+ foreach ($fileDiffs as $fileName => $diff) {
72+ $html .= "<h2>ファイル: ".htmlspecialchars($fileName)."</h2>\n";
73+ $html .= $this->generateHtml($diff['leftLines'], $diff['rightLines']);
74+ }
75+
76+ return $html;
77+ }
78+
79+ private function processHunk(array $hunk, array &$leftLines, array &$rightLines, array &$lineNumbers): void {
80+ foreach ($hunk as $line) {
81+ $prefix = substr($line, 0, 1);
82+ $content = substr($line, 1);
83+
84+ if ($prefix === '-') {
85+ $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'removed', 'line' => $lineNumbers['left']];
86+ $lineNumbers['left']++;
87+ } elseif ($prefix === '+') {
88+ $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'added', 'line' => $lineNumbers['right']];
89+ $lineNumbers['right']++;
90+ } elseif ($prefix === ' ') {
91+ // 両側のコンテキストは同じ行列があるかの確認
92+ while ($lineNumbers['left'] < $lineNumbers['right']) {
93+ $leftLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['left']];
94+ $lineNumbers['left']++;
95+ }
96+ while ($lineNumbers['right'] < $lineNumbers['left']) {
97+ $rightLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['right']];
98+ $lineNumbers['right']++;
99+ }
100+ $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['left']];
101+ $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['right']];
102+ $lineNumbers['left']++;
103+ $lineNumbers['right']++;
104+ }
105+ }
106+ }
107+
108+ private function generateHtml(array $leftLines, array $rightLines): string {
109+ $html = '<table class="diff-table">';
110+ $html .= '<tr class="diff-header"><th colspan="2">前</th><th colspan="2">新</th></tr>';
111+
112+ $maxLines = max(count($leftLines), count($rightLines));
113+ for ($i = 0; $i < $maxLines; $i++) {
114+ $left = isset($leftLines[$i]) ? $leftLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
115+ $right = isset($rightLines[$i]) ? $rightLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
116+
117+ $html .= '<tr>';
118+ // 左(変更前)
119+ $html .= '<td class="line-number">' . ($left['line'] ?: '&nbsp;') . '</td>';
120+ $html .= '<td class="' . $left['type'] . '">' . ($left['content'] ?: '&nbsp;') . '</td>';
121+ // 右(変更後)
122+ $html .= '<td class="line-number">' . ($right['line'] ?: '&nbsp;') . '</td>';
123+ $html .= '<td class="' . $right['type'] . '">' . ($right['content'] ?: '&nbsp;') . '</td>';
124+ $html .= '</tr>';
125+ }
126+
127+ $html .= '</table>';
128+ return $html;
129+ }
130130 }

長いので途中までです

@@ -1,1559 +1,1559 @@
1-<?php
2-namespace Site\Lib;
3-
4-/**
5- * PHP純正のMySQLドライバ。php-mysql拡張モジュールが不要。
6- * TODO: execute()で他のMySQLタイプを処理 (MYSQL_TYPE_DATA, MYSQL_TYPE_BLOB)
7- * TODO: トランザクション管理
8- * TODO: 各種接続オプション
9- * TODO: プロトコルの完全なカバレッジ
10- */
11-class Mysql {
12- private $socket = null;
13- private bool $connected = false;
14- private bool $debug = false;
15- private array $packetLog = [];
16- private array $serverInfo = [];
17- public string $username;
18- private string $password;
19- public string $dbname;
20- public string $host;
21- private int $port;
22-
23- private array $prepared = [];
24-
25- /**
26- * コンストラクタ
27- *
28- * MySQL接続に必要な設定をDBINFOから初期化します。
29- */
30- public function __construct() {
31- if (!MYSQL_ENABLED) return;
32-
33- $this->host = DBINFO['host'];
34- $this->username = DBINFO['username'];
35- $this->password = DBINFO['password'];
36- $this->dbname = DBINFO['dbname'];
37- $this->port = DBINFO['port'];
38- $this->debug = DBINFO['debug'];
39- }
40-
41- /**
42- * デストラクタ
43- *
44- * オブジェクト破棄時に接続を閉じます。
45- */
46- public function __destruct() {
47- $this->close();
48- }
49-
50- /**
51- * デバッギングの有無
52- *
53- * @param bool $debug デバッグモードを有効にするかどうか
54- * @return Mysql 自身を返す(メソッドチェーン用)
55- */
56- public function setDebug(bool $debug): Mysql {
57- $this->debug = (bool)$debug;
58- return $this;
59- }
60-
61- /**
62- * パケットログの取得
63- *
64- * @return array 送信および受信したパケットのログ
65- */
66- public function getPacketLog(): array {
67- return $this->packetLog;
68- }
69-
70- /**
71- * MySQLサーバーに接続
72- *
73- * ソケットを作成し、サーバーに接続後、認証とデータベース選択を行います。
74- *
75- * @return bool 接続成功時はtrue
76- * @throws \Exception 接続または認証に失敗した場合
77- */
78- public function connect(): bool {
79- if (!MYSQL_ENABLED) return false;
80-
81- $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
82- if ($this->socket === false) {
83- $msg = 'ソケットの作成に失敗: '.socket_strerror(socket_last_error());
84- logger(\LogType::MySQL, $msg);
85- throw new \Exception($msg);
86- }
87-
88- $res = socket_connect($this->socket, $this->host, $this->port);
89- if ($res === false) {
90- $msg = 'ソケットに接続に失敗: '
91- .socket_strerror(socket_last_error($this->socket));
92- logger(\LogType::MySQL, $msg);
93- throw new \Exception($msg);
94- }
95-
96- $greeting = $this->readPacket();
97- $this->parseServerGreeting($greeting);
98- $this->authenticate();
99- $response = $this->readPacket();
100-
101- if (ord($response[0]) !== 0x00) {
102- $code = unpack('v', substr($response, 1, 2))[1];
103- $mes = substr($response, 3);
104- $msg = "認証応答に失敗: {$code} - {$mes}";
105- logger(\LogType::MySQL, $msg);
106- throw new \Exception($msg);
107- }
108-
109- if (!empty($this->dbname)) {
110- $this->selectDatabase($this->dbname);
111- }
112-
113- $this->connected = true;
114- return true;
115- }
116-
117- /**
118- * 接続を閉じる
119- *
120- * COM_QUITコマンドを送信し、ソケットを閉じます。
121- *
122- * @return void
123- */
124- public function close(): void {
125- if (!MYSQL_ENABLED) return;
126-
127- if ($this->socket) {
128- $this->sendCommand(0x01); // COM_QUIT
129- socket_close($this->socket);
130- $this->socket = null;
131- $this->connected = false;
132- }
133- }
134-
135- /**
136- * 利用するデータベースを選択する
137- *
138- * COM_INIT_DBコマンドを使用してデータベースを選択します。
139- *
140- * @param string $database データベース名
141- * @return bool 成功時はtrue
142- * @throws \Exception データベース選択に失敗した場合
143- */
144- public function selectDatabase(string $database): bool {
145- if (!MYSQL_ENABLED) return false;
146-
147- $this->sendCommand(0x02, $database); // COM_INIT_DB
148- $res = $this->readPacket();
149-
150- if (ord($res[0]) === 0xFF) {
151- $code = unpack('v', substr($res, 1, 2))[1];
152- $mes = substr($res, 3);
153- $msg = "データベースの選択に失敗: {$code} - {$mes}";
154- logger(\LogType::MySQL, $msg);
155- throw new \Exception($msg);
156- }
157-
158- $this->dbname = $database;
159-
160- return true;
161- }
162-
163- /**
164- * プリペアドステートメントの準備
165- *
166- * SQLクエリをプリペアドステートメントとして準備し、ステートメントIDを返します。
167- *
168- * @param string $query プレースホルダ付きSQLクエリ(例: "SELECT * FROM users WHERE id = ?")
169- * @return int 成功時はステートメントID
170- * @throws \Exception 準備に失敗した場合
171- */
172- public function prepare(string $query): int {
173- if (!$this->connected || !MYSQL_ENABLED) return false;
174-
175- $this->sendCommand(0x16, $query); // COM_STMT_PREPARE
176- $res = $this->readPacket();
177-
178- if (ord($res[0]) === 0xFF) {
179- $code = unpack('v', substr($res, 1, 2))[1];
180- $mes = substr($res, 3);
181- $msg = "準備に失敗: {$code} - {$mes}";
182- logger(\LogType::MySQL, $msg);
183- throw new \Exception($msg);
184- }
185-
186- $pos = 0;
187- $statementId = unpack('V', substr($res, $pos + 1, 4))[1]; // ステートメントID
188- $pos += 5;
189- $numCols = unpack('v', substr($res, $pos, 2))[1]; // 列数
190- $pos += 2;
191- $numParam = unpack('v', substr($res, $pos, 2))[1]; // パラメートル数
192- $pos += 4;
193-
194- $this->prepared[$statementId] = [
195- 'num_params' => $numParam,
196- 'num_columns' => $numCols,
197- 'params' => [],
198- 'columns' => [],
199- ];
200-
201- if ($numParam > 0) {
202- for ($i = 0; $i < $numParam; $i++) {
203- $paramPacket = $this->readPacket();
204- $this->prepared[$statementId]['params'][] =
205- $this->parseFieldPacket($paramPacket);
206- }
207-
208- $this->readPacket();
209- }
210-
211- if ($numCols > 0) {
212- for ($i = 0; $i < $numCols; $i++) {
213- $columnPacket = $this->readPacket();
214- $this->prepared[$statementId]['columns'][] =
215- $this->parseFieldPacket($columnPacket);
216- }
217-
218- $this->readPacket();
219- }
220-
221- return $statementId;
222- }
223-
224- /**
225- * プリペアドステートメントの実行
226- *
227- * 指定されたステートメントIDとパラメータを使用してクエリを実行します。
228- *
229- * @param int $statementId プリペアドステートメントID
230- * @param array $params パラメータ値の配列
231- * @return array 結果セットまたはOKパケットデータ
232- * @throws \Exception 実行に失敗した場合
233- */
234- public function execute(int $statementId, array $params = []): array {
235- if (!MYSQL_ENABLED) return [];
236-
237- if (!isset($this->prepared[$statementId])) {
238- $msg = "不正なステートメントID: {$statementId}";
239- logger(\LogType::MySQL, $msg);
240- throw new \Exception($msg);
241- }
242-
243- $stmtInfo = $this->prepared[$statementId];
244- if (count($params) != $stmtInfo['num_params']) {
245- $msg = "パラメータ数が一致しません: 期待 {$stmtInfo['num_params']}, 取得 ".count($params);
246- logger(\LogType::MySQL, $msg);
247- throw new \Exception($msg);
248- }
249-
250- $data = chr(0x17); // COM_STMT_EXECUTE
251- $data .= pack('V', $statementId);
252- $data .= chr(0); // 0 = カーソルなし
253- $data .= pack('V', 1); // 繰り返し数(常に1)
254-
255- if ($stmtInfo['num_params'] > 0) {
256- // NULLビットマップ
257- $nullBitmap = str_repeat("\0", ceil($stmtInfo['num_params'] / 8));
258- foreach ($params as $k => $v) {
259- if ($v === NULL) {
260- $nullBitmap[$k >> 3] = chr(ord($nullBitmap[$k >> 3]) | (1 << ($k & 7)));
261- }
262- }
263-
264- $data .= $nullBitmap;
265-
266- $data .= chr(1); // 新パラメートルフラグ(1=はい)
267-
268- $paramTypes = '';
269- $paramValues = '';
270- foreach ($params as $param) {
271- /**
272- * MYSQL_TYPE_DECIMAL 0x00
273- * MYSQL_TYPE_TINY 0x01
274- * MYSQL_TYPE_SHORT 0x02
275- * MYSQL_TYPE_LONG 0x03
276- * MYSQL_TYPE_FLOAT 0x04
277- * MYSQL_TYPE_DOUBLE 0x05
278- * MYSQL_TYPE_NULL 0x06
279- * MYSQL_TYPE_TIMESTAMP 0x07
280- * MYSQL_TYPE_LONGLONG 0x08
281- * MYSQL_TYPE_INT24 0x09
282- * MYSQL_TYPE_DATE 0x0A
283- * MYSQL_TYPE_TIME 0x0B
284- * MYSQL_TYPE_DATETIME 0x0C
285- * MYSQL_TYPE_YEAR 0x0D
286- * MYSQL_TYPE_NEWDATE 0x0E
287- * MYSQL_TYPE_VARCHAR 0x0F
288- * MYSQL_TYPE_BIT 0x10
289- *
290- * MYSQL_TYPE_NEWDECIMAL 0xF6
291- * MYSQL_TYPE_ENUM 0xF7
292- * MYSQL_TYPE_SET 0xF8
293- * MYSQL_TYPE_TINY_BLOB 0xF9
294- * MYSQL_TYPE_MEDIUM_BLOB 0xFA
295- * MYSQL_TYPE_LONG_BLOB 0xFB
296- * MYSQL_TYPE_BLOB 0xFC
297- * MYSQL_TYPE_VAR_STRING 0xFD
298- * MYSQL_TYPE_STRING 0xFE
299- * MYSQL_TYPE_GEOMETRY 0xFF
300- */
301- if ($param === null) {
302- $paramType .= pack('v', 0x06); // MYSQL_TYPE_NULL
303- } else if (is_int($param)) {
304- $intLen = strlen((string)$param);
305- if ($intLen == 10) {
306- $paramTypes .= pack('v', 0x07); // MYSQL_TYPE_TIMESTAMP
307- } else if ($intLen >= -128 && $intLen < 127) {
308- $paramTypes .= pack('v', 0x01); // MYSQL_TYPE_TINY
309- } else if ($intLen >= -32768 && $intLen < 32767) {
310- $paramTypes .= pack('v', 0x02); // MYSQL_TYPE_SHORT
311- } else if ($intLen >= -8388608 && $intLen < 8388607) {
312- $paramTypes .= pack('v', 0x09); // MYSQL_TYPE_INT24
313- } else if ($intLen >= -2147483648 && $intLen < 2147483647) {
314- $paramTypes .= pack('v', 0x03); // MYSQL_TYPE_LONG
315- } else if ($intLen >= -9223372036854775808 && $intLen < 9223372036854775807) {
316- $paramTypes .= pack('v', 0x08); // MYSQL_TYPE_LONGLONG
317- }
318- $paramValues .= pack('V', $param);
319- } else if (is_float($param)) {
320- $decLen = strpos(strrev((string)$param), '.');
321- if ($decLen !== FALSE && $decLen < 25) {
322- $paramTypes .= pack('v', 0x04); // MYSQL_TYPE_FLOAT
323- } else if ($decLen !== FALSE && $decLen >= 25 && $decLen < 60) {
324- $paramTypes .= pack('v', 0x05); // MYSQL_TYPE_DOUBLE
325- }
326- $paramValues .= pack('d', $param);
327- } else {
328- $paramTypes .= pack('v', 0x0F); // MYSQL_TYPE_STRING
329- $len = strlen($param);
330- $paramValues .= $this->encodeLengthEncodedInteger($len).$param;
331- }
332- }
333-
334- $data .= $paramTypes.$paramValues;
335- }
336-
337- $this->sendPacket($data);
338- $res = $this->readPacket();
339-
340- if (ord($res[0]) === 0xFF) {
341- $code = unpack('v', substr($res, 1, 2))[1];
342- $mes = substr($res, 3);
343- $msg = "実行に失敗: {$code} - {$mes}";
344- logger(\LogType::MySQL, $msg);
345- throw new \Exception($msg);
346- }
347-
348- if (ord($res[0]) === 0x00) {
349- return $this->parseOkPacket($res);
350- }
351-
352- return $this->parseResultSet($res);
353- }
354-
355- /**
356- * プリペアドステートメントの解放
357- *
358- * 指定されたステートメントIDを解放し、リソースをクリーンアップします。
359- *
360- * @param int $statementId プリペアドステートメントID
361- * @return bool 成功時はtrue
362- */
363- public function demolish(int $statementId): bool {
364- if (!MYSQL_ENABLED || !isset($this->prepared[$statementId])) return false;
365-
366- $data = chr(0x19).pack('V', $statementId); // COM_STMT_CLOSE
367- $this->sendPacket($data);
368-
369- unset($this->prepared[$statementId]);
370- return true;
371- }
372-
373- /**
374- * SQLクエリの実行
375- *
376- * COM_QUERYを使用してSQLクエリを実行し、結果を返します。
377- *
378- * @param string $query 実行するSQLクエリ
379- * @return array 結果セットまたはOKパケットデータ
380- * @throws \Exception クエリ実行に失敗した場合
381- */
382- public function query(string $query): array {
383- if (!MYSQL_ENABLED) return [];
384-
385- $this->sendCommand(0x03, $query); // COM_QUERY
386- $res = $this->readPacket();
387-
388- if (ord($res[0]) === 0xFF) {
389- $code = unpack('v', substr($res, 1, 2))[1];
390- $mes = substr($res, 3);
391- $msg = "クエリに失敗: {$code} - {$mes}";
392- logger(\LogType::MySQL, $msg);
393- throw new \Exception($msg);
394- }
395-
396- // レスポンスは0x00で始まったら、OKパケットだ
397- if (ord($res[0]) === 0x00) {
398- return $this->parseOkPacket($res);
399- }
400-
401- // レスポンスは0xFBで始まったら、、 LOCAL INFILEリクエストだ
402- // @todo LOCAL INFILEリクエストの処理を実装
403- if (ord($res[0]) === 0xFB) {
404- $msg = "LOCAL INFOリクエストは未対応です";
405- logger(\LogType::MySQL, $msg);
406- throw new \Exception($msg);
407- }
408-
409- return $this->parseResultSet($res);
410- }
411-
412- /**
413- * パケットログをファイルに保存する
414- *
415- * デバッグ用に収集したパケットログを指定されたファイルに保存します。
416- *
417- * @param string $filename 保存先ファイル名
418- * @return bool|int 成功時は書き込んだバイト数、失敗時はfalse
419- */
420- public function savePacketLogToFile(string $filename): bool|int {
421- if (!MYSQL_ENABLED) return 0;
422-
423- $output = '';
424-
425- foreach ($this->packetLog as $index => $packetInfo) {
426- $direction = $packetInfo['direction'];
427- $length = $packetInfo['length'];
428- $seqNum = $packetInfo['seqNum'];
429- $data = $packetInfo['data'];
430- $timestamp = date('Y-m-d H:i:s', (int)$packetInfo['timestamp']);
431-
432- $output .= "=== パケット #{$index} ({$timestamp}) {$direction}
433- $output .= (長さ: {$length}, シーケンス: {$seqNum}) ===\n";
434- $output .= "16進数: ".$this->hexDump($data)."\n";
435- $output .= "ASCII: ".$this->asciiDump($data)."\n";
436- $output .= "==========================================\n\n";
437- }
438-
439- return file_put_contents(ROOT.'/log/'.$filename, $output);
440- }
441-
442- // 機能性メソッド
443-
444- /**
445- * MySQLサーバーで認証する
446- *
447- * クライアント機能フラグと認証情報を送信してサーバー認証を行います。
448- *
449- * @return bool 認証成功時はtrue
450- * @throws \Exception 認証に失敗した場合
451- */
452- private function authenticate(): bool {
453- /**
454- * CLIENT_LONG_PASSWORD 0x00000001
455- * CLIENT_PROTOCOL_41 0x00000200
456- * CLIENT_SECURE_CONNECTION 0x00008000
457- * CLIENT_CONNECT_WITH_DB 0x00000800
458- *
459- * 0x00020D05 = CLIENT_LONG_PASSWORD | CLIENT_PROTOCOL_41 |
460- * CLIENT_SECURE_CONNECTION | CONNECT_WITH_DB
461- */
462- $data = '';
463- $data .= pack('L', 0x00020D05); // クライアント機能フラグ
464- $data .= pack('L', 16777216); // パケットサイズの大きさ
465- $data .= chr(33); // チャーセット(33 = utf8_general_ci)
466- $data .= str_repeat("\0", 23); // 予約バイト
467- $data .= $this->username."\0"; // ユーザー名
468-
469- // パスワード
470- if (empty($this->password)) {
471- $data .= "\0"; // 空
472- } else {
473- $pw = $this->scramblePassword($this->password, $this->serverInfo['scramble']);
474- $data .= chr(strlen($pw)).$pw;
475- }
476-
477- // データベース名
478- if (!empty($this->dbname)) {
479- $data .= $this->dbname."\0";
480- }
481-
482- // 認証パケットを送信する
483- $this->sendPacket($data, 1);
484-
485- // サーバー返事を送る
486- $res = $this->readPacket();
487-
488- if (ord($res[0]) === 0xFF) {
489- $code = unpack('v', substr($res, 1, 2))[1];
490- $mes = substr($res, 3);
491- $this->close();
492- $msg = "認証に失敗: {$code} - {$mes}";
493- logger(\LogType::MySQL, $msg);
494- throw new \Exception($msg);
495- }
496-
497- return true;
498- }
499-
500- /**
501- * サーバーから結果セットを解析する
502- *
503- * クエリ結果のフィールドと行データを解析して返します。
504- *
505- * @param string $firstPacket 最初の結果セットパケット
506- * @return array フィールドと行データの配列
507- * @throws \Exception EOFパケットが期待通りに受信できない場合
508- */
509- private function parseResultSet(string $firstPacket): array {
510- $fieldCnt = ord($firstPacket[0]);
511-
512- $fields = [];
513- for ($i = 0; $i < $fieldCnt; $i++) {
514- $fieldPacket = $this->readPacket();
515- $fields[] = $this->parseFieldPacket($fieldPacket);
516- }
517-
518- $eofPacket = $this->readPacket();
519- if (ord($eofPacket[0]) !== 0xFE) {
520- $msg = "フィールド説明の後にEOFパケットが期待されます";
521- logger(\LogType::MySQL, $msg);
522- throw new \Exception($msg);
523- }
524-
525- $rows = [];
526- while (true) {
527- $rowPacket = $this->readPacket();
528-
529- // 行データの終了を示すEOFパケットを確認
530- if (ord($rowPacket[0]) === 0xFE && strlen($rowPacket) < 9) break;
531- $rows[] = $this->parseRowPacket($rowPacket, $fields);
532- }
533-
534- return [
535- 'fields' => $fields,
536- 'rows' => $rows,
537- ];
538- }
539-
540- /**
541- * フィールドパケットを解析する
542- *
543- * フィールドのメタデータを解析して返します。
544- *
545- * @param string $packet フィールドパケット
546- * @return array フィールドのメタデータ
547- */
548- private function parseFieldPacket(string $packet): array {
549- $pos = 0;
550- $field = [];
551-
552- // カタログのスキップ(def等)
553- $len = $this->getLengthEncodedIntegerValue($packet, $pos);
554- $pos += $this->getLengthEncodedIntegerSize($len);
555- $field['catalog'] = substr($packet, $pos, $len);
556- $pos += 1 + $len;
557-
558- // データベース名
559- $len = $this->getLengthEncodedIntegerValue($packet, $pos);
560- $pos += $this->getLengthEncodedIntegerSize($len);
561- $field['db'] = substr($packet, $pos, $len);
562- $pos += $len;
563-
564- // テーブル名
565- $len = $this->getLengthEncodedIntegerValue($packet, $pos);
566- $pos += $this->getLengthEncodedIntegerSize($len);
567- $field['table'] = substr($packet, $pos, $len);
568- $pos += $len;
569-
570- // 元のテーブル名
571- $len = $this->getLengthEncodedIntegerValue($packet, $pos);
572- $pos += $this->getLengthEncodedIntegerSize($len);
573- $field['org_table'] = substr($packet, $pos, $len);
574- $pos += 1 + $len;
575-
576- // フィールド名
577- $len = $this->getLengthEncodedIntegerValue($packet, $pos);
578- $pos += $this->getLengthEncodedIntegerSize($len);
579- $field['name'] = substr($packet, $pos, $len);
580- $pos += $len;
581-
582- // 元のフィールド名
583- $len = $this->getLengthEncodedIntegerValue($packet, $pos);
584- $pos += $this->getLengthEncodedIntegerSize($len);
585- $field['org_name'] = substr($packet, $pos, $len);
586- $pos += $len;
587-
588- // フィルターバイトをスキップ(通常は0x0C)
589- $pos += 1;
590-
591- // 文字セット
592- $field['charset'] = unpack('v', substr($packet, $pos, 2))[1];
593- $pos += 2;
594-
595- // 列の長さ
596- $field['length'] = unpack('V', substr($packet, $pos, 4))[1];
597- $pos += 4;
598-
599- // フィールド種類
600- $field['type'] = ord($packet[$pos]);
601- $pos += 1;
602-
603- // フラグ
604- $field['flags'] = unpack('v', substr($packet, $pos, 2))[1];
605- $pos += 2;
606-
607- // 小数点以下の桁数
608- $field['decimals'] = ord($packet[$pos]);
609- $pos += 1;
610-
611- // フィルターバイトをスキップ
612- $pos += 2;
613-
614- // デフォルト値(存在する場合、長さエンコード文字列)
615- if ($pos < strlen($packet)) {
616- $len = $this->getLengthEncodedIntegerValue($packet, $pos);
617- $pos += $this->getLengthEncodedIntegerSize($len);
618- $field['default'] = substr($packet, $pos, $len);
619- }
620-
621- return $field;
622- }
623-
624- /**
625- * 行パケットを解析する
626- *
627- * 結果セットの行データを解析して返します。
628- *
629- * @param string $packet 行パケット
630- * @param array $fields フィールドメタデータの配列
631- * @return array 行データの連想配列
632- */
633- private function parseRowPacket(string $packet, array $fields): array {
634- $pos = 0;
635- $row = [];
636-
637- foreach ($fields as $field) {
638- // 0xFB = NULL
639- if (ord($packet[$pos]) === 0xFB) {
640- $row[$field['name']] = null;
641- $pos++;
642- continue;
643- }
644-
645- // 長さ
646- $len = ord($packet[$pos]);
647- $pos++;
648- $row[$field['name']] = substr($packet, $pos, $len);
649- $pos += $len;
650- }
651-
652- return $row;
653- }
654-
655- /**
656- * OKパケットを解析する
657- *
658- * OKパケットの内容を解析して影響を受けた行数や挿入IDなどを返します。
659- *
660- * @param string $packet OKパケット
661- * @return array OKパケットのデータ
662- * @throws \Exception パケットが不完全な場合
663- */
664- private function parseOkPacket(string $packet): array {
665- if (strlen($packet) < 2) {
666- $msg = "OKパケットが短すぎます: ".strlen($packet)."バイト";
667- logger(\LogType::MySQL, $msg);
668- throw new \Exception($msg);
669- }
670-
671- $pos = 1; // ヘッダーバイト(0x00)をスキップする
672-
673- $affectedRows = $this->getLengthEncodedIntegerValue($packet, $pos);
674- $pos += $this->getLengthEncodedIntegerSize($affectedRows);
675-
676- $insertId = $this->getLengthEncodedIntegerValue($packet, $pos);
677- $pos += $this->getLengthEncodedIntegerSize($insertId);
678-
679- if (strlen($packet) < $pos + 2) {
680- $msg = "OKパケットにサーバーステータス用のデータが不足しています";
681- logger(\LogType::MySQL, $msg);
682- throw new \Exception($msg);
683- }
684- $serverStatus = unpack('v', substr($packet, $pos, 2))[1];
685- $pos += 2;
686-
687- if (strlen($packet) < $pos + 2) {
688- $msg = "OKパケットに警告カウント用のデータが不足しています";
689- logger(\LogType::MySQL, $msg);
690- throw new \Exception($msg);
691- }
692- $warningCount = unpack('v', substr($packet, $pos, 2))[1];
693-
694- return [
695- 'affectedRows' => $affectedRows,
696- 'insertId' => $insertId,
697- 'serverStatus' => $serverStatus,
698- 'warningCount' => $warningCount,
699- ];
700- }
701-
702- /**
703- * MySQLサーバーからパケットを読み込む
704- *
705- * ソケットからパケットを読み込み、完全なデータを受信するまで待機します。
706- *
707- * @return string 受信したパケットデータ
708- * @throws \Exception 読み込みに失敗した場合
709- */
710- private function readPacket(): string {
711- $header = ''; // パケットのヘッダー=4バイト
712- $bytesRead = socket_recv($this->socket, $header, 4, MSG_WAITALL);
713- if ($bytesRead !== 4) {
714- $msg = "パケットヘッダーの読み込みに失敗: 期待 4 バイト, 取得 {$bytesRead}";
715- logger(\LogType::MySQL, $msg);
716- throw new \Exception($msg);
717- }
718-
719- // パケットの長さを最初3バイトからパーシングする
720- $len = ord($header[0]) + (ord($header[1]) << 8) + (ord($header[2]) << 16);
721-
722- // パケットの順序番号は第4目のバイト
723- $seqNum = ord($header[3]);
724-
725- // パケットの内容を読み込む
726- $data = '';
727- $remaining = $len;
728- $timeout = 5;
729- socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, [
730- 'sec' => $timeout,
731- 'usec' => 0,
732- ]);
733-
734- while ($remaining > 0) {
735- $buffer = '';
736- $bytesRead = socket_recv($this->socket, $buffer, $remaining, 0);
737-
738- if ($bytesRead === false) {
739- $msg = "パケット内容の読み込みに失敗: エラー "
740- .socket_strerror(socket_last_error($this->socket));
741- logger(\LogType::MySQL, $msg);
742- throw new \Exception($msg);
743- }
744-
745- if ($bytesRead === 0) {
746- usleep(10000);
747- continue;
748- }
749-
750- $data .= $buffer;
751- $remaining -= $bytesRead;
752- }
753-
754- if (ord($data[0]) === 0x00 && strlen($data) < 7) {
755- $extra = '';
756- $extraBytes = socket_recv($this->socket, $extra, 7 - strlen($data), 0);
757- if ($extraBytes !== false && $extraBytes > 0) {
758- $data .= $extra;
759- }
760- }
761-
762- // デバッグ
763- if ($this->debug) {
764- $packetInfo = [
765- 'direction' => 'RECV',
766- 'length' => $len,
767- 'seqNum' => $seqNum,
768- 'data' => $data,
769- 'timestamp' => microtime(true),
770- ];
771-
772- $this->logPacket($packetInfo);
773- }
774-
775- return $data;
776- }
777-
778- /**
779- * MySQLサーバーにパケットを送信する
780- *
781- * 指定されたデータとシーケンス番号でパケットを送信します。
782- *
783- * @param string $data 送信するデータ
784- * @param int $seqNum シーケンス番号(デフォルトは0)
785- * @return bool 成功時はtrue
786- * @throws \Exception 送信に失敗した場合
787- */
788- private function sendPacket(string $data, $seqNum = 0): bool {
789- $len = strlen($data);
790-
791- // パケットヘッダー:長さ=3バイト、順序番号=1バイト
792- $header = chr($len & 0xFF)
793- .chr(($len >> 8) & 0xFF)
794- .chr(($len >> 16) & 0xFF)
795- .chr($seqNum);
796-
797- // デバッグ
798- if ($this->debug) {
799- $packetInfo = [
800- 'direction' => 'SEND',
801- 'length' => $len,
802- 'seqNum' => $seqNum,
803- 'data' => $data,
804- 'timestamp' => microtime(true),
805- ];
806-
807- $this->logPacket($packetInfo);
808- }
809-
810- // ヘッダーの送信
811- $sent = socket_write($this->socket, $header, 4);
812- if ($sent !== 4) {
813- $msg = "パケットヘッダーの送信に失敗";
814- logger(\LogType::MySQL, $msg);
815- throw new \Exception($msg);
816- }
817-
818- // データの送信
819- $sent = socket_write($this->socket, $data, $len);
820- if ($sent !== $len) {
821- $msg = "パケットデータの送信に失敗";
822- logger(\LogType::MySQL, $msg);
823- throw new \Exception($msg);
824- }
825-
826- return true;
827- }
828-
829- /**
830- * MySQLサーバーにコマンドを送信する
831- *
832- * 指定されたコマンドとデータを送信します。
833- *
834- * @param string $command コマンド(例: 0x03 = COM_QUERY)
835- * @param string $data 付加データ(デフォルトは空)
836- * @return bool 成功時はtrue
837- * @throws \Exception 送信に失敗した場合
838- */
839- private function sendCommand(string $command, string $data = ''): bool {
840- $packet = chr($command).$data;
841- return $this->sendPacket($packet);
842- }
843-
844- /**
845- * サーバーの挨拶パケットを解析する
846- *
847- * サーバーからの初期挨拶パケットを解析し、サーバー情報を保存します。
848- *
849- * @param string $packet 挨拶パケット
850- * @return void
851- */
852- private function parseServerGreeting(string $packet): void {
853- $pos = 0;
854-
855- // プロトコールバージョン(1バイト)
856- $this->serverInfo['protocol'] = ord($packet[$pos]);
857- $pos++;
858-
859- // サーバーバージョン
860- $end = strpos($packet, "\0", $pos);
861- $this->serverInfo['version'] = substr($packet, $pos, $end - $pos);
862- $pos = $end + 1;
863-
864- // スレッドID(4バイト)
865- $this->serverInfo['threadId'] = unpack('V', substr($packet, $pos, 4))[1];
866- $pos += 4;
867-
868- // スクランブルバッファの最初の部分(8バイト)
869- $this->serverInfo['scramble'] = substr($packet, $pos, 8);
870- $pos += 8;
871-
872- // フィルターバイトをスキップする
873- $pos++;
874-
875- // サーバー機能(2バイト)
876- $this->serverInfo['capabilities'] = unpack('v', substr($packet, $pos, 2))[1];
877- $pos += 2;
878-
879- // サーバー言語(1バイト)
880- $this->serverInfo['language'] = ord($packet[$pos]);
881- $pos++;
882-
883- // サーバー状況(2バイト)
884- $this->serverInfo['status'] = unpack('v', substr($packet, $pos, 2))[1];
885- $pos += 2;
886-
887- // 13バイトのスキップ
888- $pos += 13;
889-
890- // その他(12バイト)
891- $this->serverInfo['scramble'] .= substr($packet, $pos, 12);
892- }
893-
894- /**
895- * パスワードをスクランブルする
896- *
897- * MySQL認証用のパスワードをスクランブルします。
898- *
899- * @param string $password プレーンテキストのパスワード
900- * @param string $scramble サーバーから提供されたスクランブル文字列
901- * @return string スクランブルされたパスワード(20バイト)
902- */
903- private function scramblePassword(string $password, string $scramble): string {
904- $stage1 = sha1($password, true);
905- $stage2 = sha1($stage1, true);
906- $stage3 = sha1($scramble.$stage2, true);
907-
908- // $stage1 XOR $stage3
909- $res = '';
910- for ($i = 0; $i < 20; $i++) {
911- $res .= chr(ord($stage1[$i]) ^ ord($stage3[$i]));
912- }
913-
914- return $res;
915- }
916-
917- /**
918- * デバッグのためにパケットをログする
919- *
920- * パケット情報をログに追加し、デバッグ出力を表示します。
921- *
922- * @param array $packetInfo パケット情報(方向、長さ、シーケンス番号、データ、タイムスタンプ)
923- * @return void
924- */
925- private function logPacket(array $packetInfo): void {
926- $this->packetLog[] = $packetInfo;
927-
928- $direction = $packetInfo['direction'];
929- $length = $packetInfo['length'];
930- $seqNum = $packetInfo['seqNum'];
931- $data = $packetInfo['data'];
932-
933- echo "=== {$direction} パケット (長さ: {$length}, シーケンス: $seqNum) ===\n";
934- echo $this->hexDumpWithAscii($data)."\n";
935-
936- $this->interpretPacket($data);
937-
938- echo "==========================================\n\n";
939- }
940-
941- /**
942- * バイナリデータを16進数で出力する
943- *
944- * デバッグ用にデータを16進数形式で表示します。
945- *
946- * @param string $data バイナリデータ
947- * @return string 16進数文字列
948- */
949- private function hexDump(string $data): string {
950- $res = '';
951- $len = strlen($data);
952-
953- for ($i = 0; $i < $len; $i++) {
954- $res .= sprintf('%02X ', ord($data[$i]));
955-
956- // 読み易さの為、各16バイトで新行列を入る
957- if (($i + 1) % 16 === 0 && $i !== $len - 1) {
958- $res .= "\n";
959- }
960- }
961-
962- return $res;
963- }
964-
965- /**
966- * バイナリデータをASCIIで出力する
967- *
968- * デバッグ用にデータをASCII形式で表示します。
969- *
970- * @param string $data バイナリデータ
971- * @return string ASCII文字列
972- */
973- private function asciiDump(string $data): string {
974- $res = '';
975- $len = strlen($data);
976-
977- for ($i = 0; $i < $len; $i++) {
978- $char = ord($data[$i]);
979-
980- // 表示出来るASCII文字だけを書き出す
981- if ($char >= 32 && $char <= 126) {
982- $res .= $data[$i];
983- } else {
984- $res .= '.';
985- }
986-
987- // 読み易さの為、各16バイトで新行列を入る
988- if (($i + 1) % 16 === 0 && $i !== $len - 1) {
989- $res .= "\n";
990- }
991- }
992-
993- return $res;
994- }
995-
996- /**
997- * バイナリデータを2進数で出力する
998- *
999- * デバッグ用にデータを2進数形式で表示します。
1000- *
1001- * @param string $data バイナリデータ
1002- * @return string 2進数文字列
1003- */
1004- private function binaryDump(string $data): string {
1005- $res = '';
1006- $len = strlen($data);
1007-
1008- for ($i = 0; $i < $len; $i++) {
1009- $res .= sprintf('%08b ', ord($data[$i]));
1010-
1011- // 読み易さの為、各88バイトで新行列を入る
1012- if (($i + 1) % 8 === 0 && $i !== $len - 1) {
1013- $res .= "\n";
1014- }
1015- }
1016-
1017- return $res;
1018- }
1019-
1020- /**
1021- * パケットを最初のバイトに基づいて解釈する
1022- *
1023- * パケットの種類を特定し、デバッグ情報を出力します。
1024- *
1025- * @param string $data パケットデータ
1026- * @return void
1027- */
1028- private function interpretPacket(string $data): void {
1029- if (empty($data)) {
1030- echo "解釈: 空パケット\n";
1031- return;
1032- }
1033-
1034- $firstByte = ord($data[0]);
1035-
1036- switch ($firstByte) {
1037- case 0x00:
1038- echo "解釈: OKパケット\n";
1039- $this->debugOkPacket($data);
1040- break;
1041- case 0x17:
1042- echo "解釈: COM_STMT_EXECUTEパケット\n";
1043- $this->debugStmtExecutePacket($data);
1044- break;
1045- case 0xFF:
1046- echo "解釈: エラーパケット\n";
1047- $this->debugErrorPacket($data);
1048- break;
1049- case 0xFE:
1050- echo "解釈: EOFパケット\n";
1051- break;
1052- case 0xFB:
1053- echo "解釈: LOCAL INFILEリクエスト\n";
1054- break;
1055- default:
1056- if ($firstByte === 3
1057- && $data[1] === 'd'
1058- && $data[2] === 'e'
1059- && $data[3] === 'f') {
1060- // フィールドパケットかどうかの確認
1061- echo "解釈: フィールド説明パケット\n";
1062- $this->debugFieldPacket($data);
1063- } else if ($firstByte > 0 && $firstByte < 251) {
1064- // 結果セットパケットかどうかの確認
1065- echo "解釈: 結果セットヘッダーパケット(フィールド数: {$firstByte})\n";
1066- } else {
1067- // 以上じゃないと、列データパケットでかもしん
1068- echo "解釈: 列データパケット又はその他のパケット種類\n";
1069- $this->debugLengthEncodedStrings($data);
1070- }
1071- break;
1072- }
1073- }
1074-
1075- /**
1076- * OKパケット構造をデバッグする
1077- *
1078- * OKパケットの内容を解析し、デバッグ情報を出力します。
1079- *
1080- * @param string $data OKパケットデータ
1081- * @return void
1082- */
1083- private function debugOkPacket(string $data): void {
1084- if (strlen($data) < 2) {
1085- echo ' Error: OK packet too short ('.strlen($data)." bytes)\n";
1086- return;
1087- }
1088-
1089- $pos = 1; // ヘッダーバイトをスキップ
1090-
1091- // 影響を受けた行数を取得
1092- $affectedRows = $this->getLengthEncodedIntegerValue($data, $pos);
1093- echo " 影響を受けた行数: {$affectedRows}\n";
1094- $pos += $this->getLengthEncodedIntegerSize($affectedRows);
1095-
1096- // 最後の挿入IDを取得
1097- $insertId = $this->getLengthEncodedIntegerValue($data, $pos);
1098- echo " 最後の挿入ID: {$insertId}\n";
1099- $pos += $this->getLengthEncodedIntegerSize($insertId);
1100-
1101- // サーバーステータス
1102- if (strlen($data) >= $pos + 2) {
1103- $serverStatus = unpack('v', substr($data, $pos, 2))[1];
1104- echo " サーバーステータス: ".sprintf('0x%04X', $serverStatus)."\n";
1105- $pos += 2;
1106- } else {
1107- echo " サーバーステータス: 利用不可\n";
1108- }
1109-
1110- // 警告カウント
1111- if (strlen($data) >= $pos + 2) {
1112- $warningCount = unpack('v', substr($data, $pos, 2))[1];
1113- echo " 警告カウント: {$warningCount}\n";
1114- $pos += 2;
1115- } else {
1116- echo " 警告カウント: 利用不可\n";
1117- }
1118-
1119- // サーバーメッセージ(存在する場合)
1120- if (strlen($data) > $pos) {
1121- $message = substr($data, $pos);
1122- echo " メッセージ: ".$this->safeString($message)."\n";
1123- }
1124- }
1125-
1126- /**
1127- * エラーパケット構造をデバッグする
1128- *
1129- * エラーパケットの内容を解析し、デバッグ情報を出力します。
1130- *
1131- * @param string $data エラーパケットデータ
1132- * @return void
1133- */
1134- private function debugErrorPacket(string $data): void {
1135- $errorCode = unpack('v', substr($data, 1, 2))[1];
1136- echo " エラーコード: {$errorCode}\n";
1137-
1138- // SQLステートマーカーをスキップ(#)
1139- $sqlState = substr($data, 4, 5);
1140- echo " SQLステート: {$sqlState}\n";
1141-
1142- $errorMessage = substr($data, 9);
1143- echo " エラーメッセージ: ".$this->safeString($errorMessage)."\n";
1144- }
1145-
1146- /**
1147- * フィールドパケット構造をデバッグする
1148- *
1149- * フィールドパケットの内容を解析し、デバッグ情報を出力します。
1150- *
1151- * @param string $data フィールドパケットデータ
1152- * @return void
1153- */
1154- private function debugFieldPacket(string $data): void {
1155- $pos = 0;
1156-
1157- // パケットから長さエンコード文字列を抽出
1158- echo " フィールドパケット構造:\n";
1159-
1160- // カタログを抽出
1161- $len = $this->getLengthEncodedIntegerValue($data, $pos);
1162- $pos += $this->getLengthEncodedIntegerSize($len);
1163- $catalog = substr($data, $pos, $len);
1164- echo " カタログ: ".$this->safeString($catalog)." (長さ: $len)\n";
1165- $pos += $len;
1166-
1167- // データベースを抽出
1168- $len = $this->getLengthEncodedIntegerValue($data, $pos);
1169- $pos += $this->getLengthEncodedIntegerSize($len);
1170- $database = substr($data, $pos, $len);
1171- echo " データベース: ".$this->safeString($database)." (長さ: $len)\n";
1172- $pos += $len;
1173-
1174- // テーブルを抽出
1175- $len = $this->getLengthEncodedIntegerValue($data, $pos);
1176- $pos += $this->getLengthEncodedIntegerSize($len);
1177- $table = substr($data, $pos, $len);
1178- echo " テーブル: ".$this->safeString($table)." (長さ: $len)\n";
1179- $pos += $len;
1180-
1181- // 元のテーブルを抽出
1182- $len = $this->getLengthEncodedIntegerValue($data, $pos);
1183- $pos += $this->getLengthEncodedIntegerSize($len);
1184- $orgTable = substr($data, $pos, $len);
1185- echo " 元のテーブル: ".$this->safeString($orgTable)." (長さ: $len)\n";
1186- $pos += $len;
1187-
1188- // 名前を抽出
1189- $len = $this->getLengthEncodedIntegerValue($data, $pos);
1190- $pos += $this->getLengthEncodedIntegerSize($len);
1191- $name = substr($data, $pos, $len);
1192- echo " 名前: ".$this->safeString($name)." (長さ: $len)\n";
1193- $pos += $len;
1194-
1195- // 元の名前を抽出
1196- $len = $this->getLengthEncodedIntegerValue($data, $pos);
1197- $pos += $this->getLengthEncodedIntegerSize($len);
1198- $orgName = substr($data, $pos, $len);
1199- echo " 元の名前: ".$this->safeString($orgName)." (長さ: $len)\n";
1200- $pos += $len;
1201-
1202- // 次の長さエンコード整数を抽出(固定フィールドの長さ、通常は0x0C)
1203- $fixedLength = $this->getLengthEncodedIntegerValue($data, $pos);
1204- echo " 固定フィールドの長さ: {$fixedLength}\n";
1205- $pos += $this->getLengthEncodedIntegerSize($fixedLength);
1206-
1207- // 文字セット
1208- $charSet = unpack('v', substr($data, $pos, 2))[1];
1209- echo " 文字セット: ".sprintf('0x%04X', $charSet)."\n";
1210- $pos += 2;
1211-
1212- // 列の長さ
1213- $columnLength = unpack('V', substr($data, $pos, 4))[1];
1214- echo " 列の長さ: {$columnLength}\n";
1215- $pos += 4;
1216-
1217- // 列の種類
1218- $columnType = ord($data[$pos]);
1219- echo " 列の種類: ".sprintf('0x%02X', $columnType)."\n";
1220- $pos++;
1221-
1222- // フラグ
1223- $flags = unpack('v', substr($data, $pos, 2))[1];
1224- echo " フラグ: ".sprintf('0x%04X', $flags)."\n";
1225- $pos += 2;
1226-
1227- // 小数点以下の桁数
1228- $decimals = ord($data[$pos]);
1229- echo " 小数点以下の桁数: {$decimals}\n";
1230- $pos++;
1231-
1232- // フィルター
1233- echo " フィルター: ".sprintf('0x%04X', unpack('v', substr($data, $pos, 2))[1])."\n";
1234- }
1235-
1236- /**
1237- * パケット内の長さエンコード文字列をデバッグする
1238- *
1239- * パケットからすべての長さエンコード文字列を抽出し、デバッグ情報を出力します。
1240- *
1241- * @param string $data パケットデータ
1242- * @return void
1243- */
1244- private function debugLengthEncodedStrings(string $data): void {
1245- $pos = 0;
1246- $length = strlen($data);
1247- $stringCount = 0;
1248-
1249- echo " 長さエンコード文字列:\n";
1250-
1251- while ($pos < $length) {
1252- // 長さエンコード文字列を特定
1253- if ($pos >= $length) break;
1254-
1255- $firstByte = ord($data[$pos]);
1256-
1257- // MySQLプロトコルに基づく長さエンコーディング
1258- if ($firstByte < 251) {
1259- // 1バイト長
1260- $len = $firstByte;
1261- $pos++;
1262-
1263- if ($pos + $len <= $length) {
1264- $value = substr($data, $pos, $len);
1265- echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
1266- ." (長さ: {$len})\n";
1267- $pos += $len;
1268- } else {
1269- echo " 位置 {$pos} での無効な長さエンコーディング\n";
1270- break;
1271- }
1272- } else if ($firstByte == 251) {
1273- // NULL値
1274- echo " 文字列 ".(++$stringCount).": NULL\n";
1275- $pos++;
1276- } else if ($firstByte == 252) {
1277- // 2バイト長
1278- if ($pos + 3 <= $length) {
1279- $len = unpack('v', substr($data, $pos + 1, 2))[1];
1280- $pos += 3;
1281-
1282- if ($pos + $len <= $length) {
1283- $value = substr($data, $pos, $len);
1284- echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
1285- ." (長さ: {$len})\n";
1286- $pos += $len;
1287- } else {
1288- echo " 位置 {$pos} での無効な長さエンコーディング\n";
1289- break;
1290- }
1291- } else {
1292- echo " 位置 {$pos} での不完全な2バイト長\n";
1293- break;
1294- }
1295- } else if ($firstByte == 253) {
1296- // 3バイト長
1297- if ($pos + 4 <= $length) {
1298- $len = unpack('V', substr($data, $pos + 1, 3)."\0")[1];
1299- $pos += 4;
1300-
1301- if ($pos + $len <= $length) {
1302- $value = substr($data, $pos, $len);
1303- echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
1304- ." (長さ: {$len})\n";
1305- $pos += $len;
1306- } else {
1307- echo " 位置 {$pos} での無効な長さエンコーディング\n";
1308- break;
1309- }
1310- } else {
1311- echo " 位置 {$pos} での不完全な3バイト長\n";
1312- break;
1313- }
1314- } else if ($firstByte == 254) {
1315- // 8バイト長
1316- if ($pos + 9 <= $length) {
1317- // PHPでは8バイト整数を完全に扱えないため、最初の4バイトのみ読み取る
1318- $len = unpack('V', substr($data, $pos + 1, 4))[1];
1319- $pos += 9;
1320-
1321- if ($pos + $len <= $length) {
1322- $value = substr($data, $pos, $len);
1323- echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
1324- ." (長さ: {$len})\n";
1325- $pos += $len;
1326- } else {
1327- echo " 位置 {$pos} での無効な長さエンコーディング\n";
1328- break;
1329- }
1330- } else {
1331- echo " 位置 {$pos} での不完全な8バイト長\n";
1332- break;
1333- }
1334- } else {
1335- // 長さエンコード文字列でない場合、次のバイトへ
1336- $pos++;
1337- }
1338- }
1339-
1340- if ($stringCount === 0) {
1341- echo " No length-encoded strings found\n";
1342- }
1343- }
1344-
1345- /**
1346- * 文字列を安全に出力用に変換する
1347- *
1348- * 表示可能な文字のみを含み、非表示文字は16進数で表現します。
1349- *
1350- * @param string $str 変換する文字列
1351- * @return string 安全な文字列
1352- */
1353- private function safeString(string $str): string {
1354- $result = '';
1355- $length = strlen($str);
1356-
1357- for ($i = 0; $i < $length; $i++) {
1358- $char = ord($str[$i]);
1359- if ($char >= 32 && $char <= 126) {
1360- $result .= $str[$i];
1361- } else {
1362- $result .= '\\x'.sprintf('%02X', $char);
1363- }
1364- }
1365-
1366- return $result;
1367- }
1368-
1369- /**
1370- * 指定位置の長さエンコード整数値を取得する
1371- *
1372- * パケット内の長さエンコード整数を読み取ります。
1373- *
1374- * @param string $data パケットデータ
1375- * @param int $pos 開始位置
1376- * @return mixed 整数値または0(不明な場合)
1377- */
1378- private function getLengthEncodedIntegerValue(string $data, int $pos): mixed {
1379- $firstByte = ord($data[$pos]);
1380-
1381- if ($firstByte < 251) {
1382- return $firstByte;
1383- } else if ($firstByte == 252) {
1384- return unpack('v', substr($data, $pos + 1, 2))[1];
1385- } else if ($firstByte == 253) {
1386- return unpack('V', substr($data, $pos + 1, 3)."\0")[1];
1387- } else if ($firstByte == 254) {
1388- // 簡略化のため、8バイト整数の最初の4バイトのみ読み取る
1389- return unpack('V', substr($data, $pos + 1, 4))[1];
1390- }
1391-
1392- return 0;
1393- }
1394-
1395- /**
1396- * 長さエンコード整数のサイズを取得する
1397- *
1398- * 値に基づいて長さエンコードに必要なバイト数を返します。
1399- *
1400- * @param int $value 整数値
1401- * @return int バイト数
1402- */
1403- private function getLengthEncodedIntegerSize(int $value): int {
1404- if ($value < 251) {
1405- return 1;
1406- } else if ($value < 65536) {
1407- return 3; // 0xFCマーカー1バイト + 値2バイト
1408- } else if ($value < 16777216) {
1409- return 4; // 0xFDマーカー1バイト + 値3バイト
1410- } else {
1411- return 9; // 0xFEマーカー1バイト + 値8バイト
1412- }
1413- }
1414-
1415- /**
1416- * 整数を長さエンコード形式に変換する
1417- *
1418- * MySQLプロトコルに基づいて整数を長さエンコードします。
1419- *
1420- * @param int $value 変換する整数
1421- * @return string 長さエンコードされた文字列
1422- */
1423- private function encodeLengthEncodedInteger(int $value): string {
1424- if ($value < 251) {
1425- return chr($value);
1426- } else if ($value < 65536) {
1427- return chr(0xFC).pack('v', $value);
1428- } else if ($value < 16777216) {
1429- return chr(0xFD).pack('V', $value & 0xFFFFFF);
1430- } else {
1431- return chr(0xFE).pack('P', $value);
1432- }
1433- }
1434-
1435- /**
1436- * STMTパケットをデバッグする
1437- *
1438- * COM_STMT_EXECUTEパケットの内容を解析し、デバッグ情報を出力します。
1439- *
1440- * @param string $data パケットデータ
1441- * @return void
1442- */
1443- private function debugStmtExecutePacket(string $data): void {
1444- $pos = 1; // コマンドバイトをスキップ
1445- $statementId = unpack('V', substr($data, $pos, 4))[1];
1446- echo " ステートメントID: {$statementId}\n";
1447- $pos += 5; // ステートメントIDとカーソルフラグをスキップ
1448- $iterationCount = unpack('V', substr($data, $pos, 4))[1];
1449- echo " 繰り返し数: {$iterationCount}\n";
1450- $pos += 4;
1451-
1452- $numParams = $this->prepared[$statementId]['num_params'] ?? 0;
1453- if ($numParams > 0) {
1454- $nullBitmapLen = ceil($numParams / 8);
1455- $nullBitmap = substr($data, $pos, $nullBitmapLen);
1456- echo " NULLビットマップ: ".$this->hexDump($nullBitmap)."\n";
1457- $pos += $nullBitmapLen;
1458-
1459- $newParamsFlag = ord($data[$pos]);
1460- echo " 新パラメータフラグ: {$newParamsFlag}\n";
1461- $pos++;
1462-
1463- if ($newParamsFlag) {
1464- echo " パラメータ種類:\n";
1465-
1466- for ($i = 0; $i < $numParams; $i++) {
1467- $type = unpack('v', substr($data, $pos, 2))[1];
1468- $typeName = $this->getMysqlTypeName($type);
1469- echo " パラメータ {$i}: ".sprintf("0x%02X", $type)." ({$typeName})\n";
1470- $pos += 2;
1471- }
1472-
1473- echo " パラメータ値: (生バイナリが続く)\n";
1474- }
1475- }
1476- }
1477-
1478- /**
1479- * 16進値をMySQLタイプ名に変換する
1480- *
1481- * MySQLのデータ型コードを対応する型名に変換します。
1482- *
1483- * @param int $type 16進数の型コード
1484- * @return string 型名(不明な場合は'UNKNOWN')
1485- */
1486- private function getMysqlTypeName(int $type): string {
1487- $types = [
1488- 0x00 => 'MYSQL_TYPE_DECIMAL',
1489- 0x01 => 'MYSQL_TYPE_TINY',
1490- 0x02 => 'MYSQL_TYPE_SHORT',
1491- 0x03 => 'MYSQL_TYPE_LONG',
1492- 0x04 => 'MYSQL_TYPE_FLOAT',
1493- 0x05 => 'MYSQL_TYPE_DOUBLE',
1494- 0x06 => 'MYSQL_TYPE_NULL',
1495- 0x07 => 'MYSQL_TYPE_TIMESTAMP',
1496- 0x08 => 'MYSQL_TYPE_LONGLONG',
1497- 0x09 => 'MYSQL_TYPE_INT24',
1498- 0x0A => 'MYSQL_TYPE_DATE',
1499- 0x0B => 'MYSQL_TYPE_TIME',
1500- 0x0C => 'MYSQL_TYPE_DATETIME',
1501- 0x0D => 'MYSQL_TYPE_YEAR',
1502- 0x0E => 'MYSQL_TYPE_NEWDATE',
1503- 0x0F => 'MYSQL_TYPE_VARCHAR',
1504- 0x10 => 'MYSQL_TYPE_BIT',
1505- 0xF6 => 'MYSQL_TYPE_NEWDECIMAL',
1506- 0xF7 => 'MYSQL_TYPE_ENUM',
1507- 0xF8 => 'MYSQL_TYPE_SET',
1508- 0xF9 => 'MYSQL_TYPE_TINY_BLOB',
1509- 0xFA => 'MYSQL_TYPE_MEDIUM_BLOB',
1510- 0xFB => 'MYSQL_TYPE_LONG_BLOB',
1511- 0xFC => 'MYSQL_TYPE_BLOB',
1512- 0xFD => 'MYSQL_TYPE_VAR_STRING',
1513- 0xFE => 'MYSQL_TYPE_STRING',
1514- 0xFF => 'MYSQL_TYPE_GEOMETRY',
1515- ];
1516-
1517- return $types[$type] ?? '不明';
1518- }
1519-
1520- /**
1521- * 16進数とASCII値を横に並べて出力する
1522- *
1523- * 16進エディタのように、16進数とASCII値を並べて表示します。
1524- *
1525- * @param string $data バイナリデータ
1526- * @return string フォーマットされた文字列
1527- */
1528- private function hexDumpWithAscii(string $data): string {
1529- $output = '';
1530- $len = strlen($data);
1531- $offset = 0;
1532-
1533- while ($offset < $len) {
1534- $hex = '';
1535- $ascii = '';
1536- $bytesInLine = min(16, $len - $offset); // 1行あたり16バイト
1537-
1538- // 16進数部分
1539- for ($i = 0; $i < 16; $i++) {
1540- if ($i < $bytesInLine) {
1541- $hex .= sprintf('%02X ', ord($data[$offset + $i]));
1542- } else {
1543- $hex .= ' '; // 揃えのためにスペースを埋める
1544- }
1545- }
1546-
1547- // ASCII部分
1548- for ($i = 0; $i < $bytesInLine; $i++) {
1549- $char = ord($data[$offset + $i]);
1550- $ascii .= ($char >= 32 && $char <= 126) ? chr($char) : '.';
1551- }
1552-
1553- $output .= sprintf("%08X %s |%s|\n", $offset, $hex, $ascii);
1554- $offset += 16;
1555- }
1556-
1557- return $output;
1558- }
1+<?php
2+namespace Site\Lib;
3+
4+/**
5+ * PHP純正のMySQLドライバ。php-mysql拡張モジュールが不要。
6+ * TODO: execute()で他のMySQLタイプを処理 (MYSQL_TYPE_DATA, MYSQL_TYPE_BLOB)
7+ * TODO: トランザクション管理
8+ * TODO: 各種接続オプション
9+ * TODO: プロトコルの完全なカバレッジ
10+ */
11+class Mysql {
12+ private $socket = null;
13+ private bool $connected = false;
14+ private bool $debug = false;
15+ private array $packetLog = [];
16+ private array $serverInfo = [];
17+ public string $username;
18+ private string $password;
19+ public string $dbname;
20+ public string $host;
21+ private int $port;
22+
23+ private array $prepared = [];
24+
25+ /**
26+ * コンストラクタ
27+ *
28+ * MySQL接続に必要な設定をDBINFOから初期化します。
29+ */
30+ public function __construct() {
31+ if (!MYSQL_ENABLED) return;
32+
33+ $this->host = DBINFO['host'];
34+ $this->username = DBINFO['username'];
35+ $this->password = DBINFO['password'];
36+ $this->dbname = DBINFO['dbname'];
37+ $this->port = DBINFO['port'];
38+ $this->debug = DBINFO['debug'];
39+ }
40+
41+ /**
42+ * デストラクタ
43+ *
44+ * オブジェクト破棄時に接続を閉じます。
45+ */
46+ public function __destruct() {
47+ $this->close();
48+ }
49+
50+ /**
51+ * デバッギングの有無
52+ *
53+ * @param bool $debug デバッグモードを有効にするかどうか
54+ * @return Mysql 自身を返す(メソッドチェーン用)
55+ */
56+ public function setDebug(bool $debug): Mysql {
57+ $this->debug = (bool)$debug;
58+ return $this;
59+ }
60+
61+ /**
62+ * パケットログの取得
63+ *
64+ * @return array 送信および受信したパケットのログ
65+ */
66+ public function getPacketLog(): array {
67+ return $this->packetLog;
68+ }
69+
70+ /**
71+ * MySQLサーバーに接続
72+ *
73+ * ソケットを作成し、サーバーに接続後、認証とデータベース選択を行います。
74+ *
75+ * @return bool 接続成功時はtrue
76+ * @throws \Exception 接続または認証に失敗した場合
77+ */
78+ public function connect(): bool {
79+ if (!MYSQL_ENABLED) return false;
80+
81+ $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
82+ if ($this->socket === false) {
83+ $msg = 'ソケットの作成に失敗: '.socket_strerror(socket_last_error());
84+ logger(\LogType::MySQL, $msg);
85+ throw new \Exception($msg);
86+ }
87+
88+ $res = socket_connect($this->socket, $this->host, $this->port);
89+ if ($res === false) {
90+ $msg = 'ソケットに接続に失敗: '
91+ .socket_strerror(socket_last_error($this->socket));
92+ logger(\LogType::MySQL, $msg);
93+ throw new \Exception($msg);
94+ }
95+
96+ $greeting = $this->readPacket();
97+ $this->parseServerGreeting($greeting);
98+ $this->authenticate();
99+ $response = $this->readPacket();
100+
101+ if (ord($response[0]) !== 0x00) {
102+ $code = unpack('v', substr($response, 1, 2))[1];
103+ $mes = substr($response, 3);
104+ $msg = "認証応答に失敗: {$code} - {$mes}";
105+ logger(\LogType::MySQL, $msg);
106+ throw new \Exception($msg);
107+ }
108+
109+ if (!empty($this->dbname)) {
110+ $this->selectDatabase($this->dbname);
111+ }
112+
113+ $this->connected = true;
114+ return true;
115+ }
116+
117+ /**
118+ * 接続を閉じる
119+ *
120+ * COM_QUITコマンドを送信し、ソケットを閉じます。
121+ *
122+ * @return void
123+ */
124+ public function close(): void {
125+ if (!MYSQL_ENABLED) return;
126+
127+ if ($this->socket) {
128+ $this->sendCommand(0x01); // COM_QUIT
129+ socket_close($this->socket);
130+ $this->socket = null;
131+ $this->connected = false;
132+ }
133+ }
134+
135+ /**
136+ * 利用するデータベースを選択する
137+ *
138+ * COM_INIT_DBコマンドを使用してデータベースを選択します。
139+ *
140+ * @param string $database データベース名
141+ * @return bool 成功時はtrue
142+ * @throws \Exception データベース選択に失敗した場合
143+ */
144+ public function selectDatabase(string $database): bool {
145+ if (!MYSQL_ENABLED) return false;
146+
147+ $this->sendCommand(0x02, $database); // COM_INIT_DB
148+ $res = $this->readPacket();
149+
150+ if (ord($res[0]) === 0xFF) {
151+ $code = unpack('v', substr($res, 1, 2))[1];
152+ $mes = substr($res, 3);
153+ $msg = "データベースの選択に失敗: {$code} - {$mes}";
154+ logger(\LogType::MySQL, $msg);
155+ throw new \Exception($msg);
156+ }
157+
158+ $this->dbname = $database;
159+
160+ return true;
161+ }
162+
163+ /**
164+ * プリペアドステートメントの準備
165+ *
166+ * SQLクエリをプリペアドステートメントとして準備し、ステートメントIDを返します。
167+ *
168+ * @param string $query プレースホルダ付きSQLクエリ(例: "SELECT * FROM users WHERE id = ?")
169+ * @return int 成功時はステートメントID
170+ * @throws \Exception 準備に失敗した場合
171+ */
172+ public function prepare(string $query): int {
173+ if (!$this->connected || !MYSQL_ENABLED) return false;
174+
175+ $this->sendCommand(0x16, $query); // COM_STMT_PREPARE
176+ $res = $this->readPacket();
177+
178+ if (ord($res[0]) === 0xFF) {
179+ $code = unpack('v', substr($res, 1, 2))[1];
180+ $mes = substr($res, 3);
181+ $msg = "準備に失敗: {$code} - {$mes}";
182+ logger(\LogType::MySQL, $msg);
183+ throw new \Exception($msg);
184+ }
185+
186+ $pos = 0;
187+ $statementId = unpack('V', substr($res, $pos + 1, 4))[1]; // ステートメントID
188+ $pos += 5;
189+ $numCols = unpack('v', substr($res, $pos, 2))[1]; // 列数
190+ $pos += 2;
191+ $numParam = unpack('v', substr($res, $pos, 2))[1]; // パラメートル数
192+ $pos += 4;
193+
194+ $this->prepared[$statementId] = [
195+ 'num_params' => $numParam,
196+ 'num_columns' => $numCols,
197+ 'params' => [],
198+ 'columns' => [],
199+ ];
200+
201+ if ($numParam > 0) {
202+ for ($i = 0; $i < $numParam; $i++) {
203+ $paramPacket = $this->readPacket();
204+ $this->prepared[$statementId]['params'][] =
205+ $this->parseFieldPacket($paramPacket);
206+ }
207+
208+ $this->readPacket();
209+ }
210+
211+ if ($numCols > 0) {
212+ for ($i = 0; $i < $numCols; $i++) {
213+ $columnPacket = $this->readPacket();
214+ $this->prepared[$statementId]['columns'][] =
215+ $this->parseFieldPacket($columnPacket);
216+ }
217+
218+ $this->readPacket();
219+ }
220+
221+ return $statementId;
222+ }
223+
224+ /**
225+ * プリペアドステートメントの実行
226+ *
227+ * 指定されたステートメントIDとパラメータを使用してクエリを実行します。
228+ *
229+ * @param int $statementId プリペアドステートメントID
230+ * @param array $params パラメータ値の配列
231+ * @return array 結果セットまたはOKパケットデータ
232+ * @throws \Exception 実行に失敗した場合
233+ */
234+ public function execute(int $statementId, array $params = []): array {
235+ if (!MYSQL_ENABLED) return [];
236+
237+ if (!isset($this->prepared[$statementId])) {
238+ $msg = "不正なステートメントID: {$statementId}";
239+ logger(\LogType::MySQL, $msg);
240+ throw new \Exception($msg);
241+ }
242+
243+ $stmtInfo = $this->prepared[$statementId];
244+ if (count($params) != $stmtInfo['num_params']) {
245+ $msg = "パラメータ数が一致しません: 期待 {$stmtInfo['num_params']}, 取得 ".count($params);
246+ logger(\LogType::MySQL, $msg);
247+ throw new \Exception($msg);
248+ }
249+
250+ $data = chr(0x17); // COM_STMT_EXECUTE
251+ $data .= pack('V', $statementId);
252+ $data .= chr(0); // 0 = カーソルなし
253+ $data .= pack('V', 1); // 繰り返し数(常に1)
254+
255+ if ($stmtInfo['num_params'] > 0) {
256+ // NULLビットマップ
257+ $nullBitmap = str_repeat("\0", ceil($stmtInfo['num_params'] / 8));
258+ foreach ($params as $k => $v) {
259+ if ($v === NULL) {
260+ $nullBitmap[$k >> 3] = chr(ord($nullBitmap[$k >> 3]) | (1 << ($k & 7)));
261+ }
262+ }
263+
264+ $data .= $nullBitmap;
265+
266+ $data .= chr(1); // 新パラメートルフラグ(1=はい)
267+
268+ $paramTypes = '';
269+ $paramValues = '';
270+ foreach ($params as $param) {
271+ /**
272+ * MYSQL_TYPE_DECIMAL 0x00
273+ * MYSQL_TYPE_TINY 0x01
274+ * MYSQL_TYPE_SHORT 0x02
275+ * MYSQL_TYPE_LONG 0x03
276+ * MYSQL_TYPE_FLOAT 0x04
277+ * MYSQL_TYPE_DOUBLE 0x05
278+ * MYSQL_TYPE_NULL 0x06
279+ * MYSQL_TYPE_TIMESTAMP 0x07
280+ * MYSQL_TYPE_LONGLONG 0x08
281+ * MYSQL_TYPE_INT24 0x09
282+ * MYSQL_TYPE_DATE 0x0A
283+ * MYSQL_TYPE_TIME 0x0B
284+ * MYSQL_TYPE_DATETIME 0x0C
285+ * MYSQL_TYPE_YEAR 0x0D
286+ * MYSQL_TYPE_NEWDATE 0x0E
287+ * MYSQL_TYPE_VARCHAR 0x0F
288+ * MYSQL_TYPE_BIT 0x10
289+ *
290+ * MYSQL_TYPE_NEWDECIMAL 0xF6
291+ * MYSQL_TYPE_ENUM 0xF7
292+ * MYSQL_TYPE_SET 0xF8
293+ * MYSQL_TYPE_TINY_BLOB 0xF9
294+ * MYSQL_TYPE_MEDIUM_BLOB 0xFA
295+ * MYSQL_TYPE_LONG_BLOB 0xFB
296+ * MYSQL_TYPE_BLOB 0xFC
297+ * MYSQL_TYPE_VAR_STRING 0xFD
298+ * MYSQL_TYPE_STRING 0xFE
299+ * MYSQL_TYPE_GEOMETRY 0xFF
300+ */
301+ if ($param === null) {
302+ $paramType .= pack('v', 0x06); // MYSQL_TYPE_NULL
303+ } else if (is_int($param)) {
304+ $intLen = strlen((string)$param);
305+ if ($intLen == 10) {
306+ $paramTypes .= pack('v', 0x07); // MYSQL_TYPE_TIMESTAMP
307+ } else if ($intLen >= -128 && $intLen < 127) {
308+ $paramTypes .= pack('v', 0x01); // MYSQL_TYPE_TINY
309+ } else if ($intLen >= -32768 && $intLen < 32767) {
310+ $paramTypes .= pack('v', 0x02); // MYSQL_TYPE_SHORT
311+ } else if ($intLen >= -8388608 && $intLen < 8388607) {
312+ $paramTypes .= pack('v', 0x09); // MYSQL_TYPE_INT24
313+ } else if ($intLen >= -2147483648 && $intLen < 2147483647) {
314+ $paramTypes .= pack('v', 0x03); // MYSQL_TYPE_LONG
315+ } else if ($intLen >= -9223372036854775808 && $intLen < 9223372036854775807) {
316+ $paramTypes .= pack('v', 0x08); // MYSQL_TYPE_LONGLONG
317+ }
318+ $paramValues .= pack('V', $param);
319+ } else if (is_float($param)) {
320+ $decLen = strpos(strrev((string)$param), '.');
321+ if ($decLen !== FALSE && $decLen < 25) {
322+ $paramTypes .= pack('v', 0x04); // MYSQL_TYPE_FLOAT
323+ } else if ($decLen !== FALSE && $decLen >= 25 && $decLen < 60) {
324+ $paramTypes .= pack('v', 0x05); // MYSQL_TYPE_DOUBLE
325+ }
326+ $paramValues .= pack('d', $param);
327+ } else {
328+ $paramTypes .= pack('v', 0x0F); // MYSQL_TYPE_STRING
329+ $len = strlen($param);
330+ $paramValues .= $this->encodeLengthEncodedInteger($len).$param;
331+ }
332+ }
333+
334+ $data .= $paramTypes.$paramValues;
335+ }
336+
337+ $this->sendPacket($data);
338+ $res = $this->readPacket();
339+
340+ if (ord($res[0]) === 0xFF) {
341+ $code = unpack('v', substr($res, 1, 2))[1];
342+ $mes = substr($res, 3);
343+ $msg = "実行に失敗: {$code} - {$mes}";
344+ logger(\LogType::MySQL, $msg);
345+ throw new \Exception($msg);
346+ }
347+
348+ if (ord($res[0]) === 0x00) {
349+ return $this->parseOkPacket($res);
350+ }
351+
352+ return $this->parseResultSet($res);
353+ }
354+
355+ /**
356+ * プリペアドステートメントの解放
357+ *
358+ * 指定されたステートメントIDを解放し、リソースをクリーンアップします。
359+ *
360+ * @param int $statementId プリペアドステートメントID
361+ * @return bool 成功時はtrue
362+ */
363+ public function demolish(int $statementId): bool {
364+ if (!MYSQL_ENABLED || !isset($this->prepared[$statementId])) return false;
365+
366+ $data = chr(0x19).pack('V', $statementId); // COM_STMT_CLOSE
367+ $this->sendPacket($data);
368+
369+ unset($this->prepared[$statementId]);
370+ return true;
371+ }
372+
373+ /**
374+ * SQLクエリの実行
375+ *
376+ * COM_QUERYを使用してSQLクエリを実行し、結果を返します。
377+ *
378+ * @param string $query 実行するSQLクエリ
379+ * @return array 結果セットまたはOKパケットデータ
380+ * @throws \Exception クエリ実行に失敗した場合
381+ */
382+ public function query(string $query): array {
383+ if (!MYSQL_ENABLED) return [];
384+
385+ $this->sendCommand(0x03, $query); // COM_QUERY
386+ $res = $this->readPacket();
387+
388+ if (ord($res[0]) === 0xFF) {
389+ $code = unpack('v', substr($res, 1, 2))[1];
390+ $mes = substr($res, 3);
391+ $msg = "クエリに失敗: {$code} - {$mes}";
392+ logger(\LogType::MySQL, $msg);
393+ throw new \Exception($msg);
394+ }
395+
396+ // レスポンスは0x00で始まったら、OKパケットだ
397+ if (ord($res[0]) === 0x00) {
398+ return $this->parseOkPacket($res);
399+ }
400+
401+ // レスポンスは0xFBで始まったら、、 LOCAL INFILEリクエストだ
402+ // @todo LOCAL INFILEリクエストの処理を実装
403+ if (ord($res[0]) === 0xFB) {
404+ $msg = "LOCAL INFOリクエストは未対応です";
405+ logger(\LogType::MySQL, $msg);
406+ throw new \Exception($msg);
407+ }
408+
409+ return $this->parseResultSet($res);
410+ }
411+
412+ /**
413+ * パケットログをファイルに保存する
414+ *
415+ * デバッグ用に収集したパケットログを指定されたファイルに保存します。
416+ *
417+ * @param string $filename 保存先ファイル名
418+ * @return bool|int 成功時は書き込んだバイト数、失敗時はfalse
419+ */
420+ public function savePacketLogToFile(string $filename): bool|int {
421+ if (!MYSQL_ENABLED) return 0;
422+
423+ $output = '';
424+
425+ foreach ($this->packetLog as $index => $packetInfo) {
426+ $direction = $packetInfo['direction'];
427+ $length = $packetInfo['length'];
428+ $seqNum = $packetInfo['seqNum'];
429+ $data = $packetInfo['data'];
430+ $timestamp = date('Y-m-d H:i:s', (int)$packetInfo['timestamp']);
431+
432+ $output .= "=== パケット #{$index} ({$timestamp}) {$direction}
433+ $output .= (長さ: {$length}, シーケンス: {$seqNum}) ===\n";
434+ $output .= "16進数: ".$this->hexDump($data)."\n";
435+ $output .= "ASCII: ".$this->asciiDump($data)."\n";
436+ $output .= "==========================================\n\n";
437+ }
438+
439+ return file_put_contents(ROOT.'/log/'.$filename, $output);
440+ }
441+
442+ // 機能性メソッド
443+
444+ /**
445+ * MySQLサーバーで認証する
446+ *
447+ * クライアント機能フラグと認証情報を送信してサーバー認証を行います。
448+ *
449+ * @return bool 認証成功時はtrue
450+ * @throws \Exception 認証に失敗した場合
451+ */
452+ private function authenticate(): bool {
453+ /**
454+ * CLIENT_LONG_PASSWORD 0x00000001
455+ * CLIENT_PROTOCOL_41 0x00000200
456+ * CLIENT_SECURE_CONNECTION 0x00008000
457+ * CLIENT_CONNECT_WITH_DB 0x00000800
458+ *
459+ * 0x00020D05 = CLIENT_LONG_PASSWORD | CLIENT_PROTOCOL_41 |
460+ * CLIENT_SECURE_CONNECTION | CONNECT_WITH_DB
461+ */
462+ $data = '';
463+ $data .= pack('L', 0x00020D05); // クライアント機能フラグ
464+ $data .= pack('L', 16777216); // パケットサイズの大きさ
465+ $data .= chr(33); // チャーセット(33 = utf8_general_ci)
466+ $data .= str_repeat("\0", 23); // 予約バイト
467+ $data .= $this->username."\0"; // ユーザー名
468+
469+ // パスワード
470+ if (empty($this->password)) {
471+ $data .= "\0"; // 空
472+ } else {
473+ $pw = $this->scramblePassword($this->password, $this->serverInfo['scramble']);
474+ $data .= chr(strlen($pw)).$pw;
475+ }
476+
477+ // データベース名
478+ if (!empty($this->dbname)) {
479+ $data .= $this->dbname."\0";
480+ }
481+
482+ // 認証パケットを送信する
483+ $this->sendPacket($data, 1);
484+
485+ // サーバー返事を送る
486+ $res = $this->readPacket();
487+
488+ if (ord($res[0]) === 0xFF) {
489+ $code = unpack('v', substr($res, 1, 2))[1];
490+ $mes = substr($res, 3);
491+ $this->close();
492+ $msg = "認証に失敗: {$code} - {$mes}";
493+ logger(\LogType::MySQL, $msg);
494+ throw new \Exception($msg);
495+ }
496+
497+ return true;
498+ }
499+
500+ /**
501+ * サーバーから結果セットを解析する
502+ *
503+ * クエリ結果のフィールドと行データを解析して返します。
504+ *
505+ * @param string $firstPacket 最初の結果セットパケット
506+ * @return array フィールドと行データの配列
507+ * @throws \Exception EOFパケットが期待通りに受信できない場合
508+ */
509+ private function parseResultSet(string $firstPacket): array {
510+ $fieldCnt = ord($firstPacket[0]);
511+
512+ $fields = [];
513+ for ($i = 0; $i < $fieldCnt; $i++) {
514+ $fieldPacket = $this->readPacket();
515+ $fields[] = $this->parseFieldPacket($fieldPacket);
516+ }
517+
518+ $eofPacket = $this->readPacket();
519+ if (ord($eofPacket[0]) !== 0xFE) {
520+ $msg = "フィールド説明の後にEOFパケットが期待されます";
521+ logger(\LogType::MySQL, $msg);
522+ throw new \Exception($msg);
523+ }
524+
525+ $rows = [];
526+ while (true) {
527+ $rowPacket = $this->readPacket();
528+
529+ // 行データの終了を示すEOFパケットを確認
530+ if (ord($rowPacket[0]) === 0xFE && strlen($rowPacket) < 9) break;
531+ $rows[] = $this->parseRowPacket($rowPacket, $fields);
532+ }
533+
534+ return [
535+ 'fields' => $fields,
536+ 'rows' => $rows,
537+ ];
538+ }
539+
540+ /**
541+ * フィールドパケットを解析する
542+ *
543+ * フィールドのメタデータを解析して返します。
544+ *
545+ * @param string $packet フィールドパケット
546+ * @return array フィールドのメタデータ
547+ */
548+ private function parseFieldPacket(string $packet): array {
549+ $pos = 0;
550+ $field = [];
551+
552+ // カタログのスキップ(def等)
553+ $len = $this->getLengthEncodedIntegerValue($packet, $pos);
554+ $pos += $this->getLengthEncodedIntegerSize($len);
555+ $field['catalog'] = substr($packet, $pos, $len);
556+ $pos += 1 + $len;
557+
558+ // データベース名
559+ $len = $this->getLengthEncodedIntegerValue($packet, $pos);
560+ $pos += $this->getLengthEncodedIntegerSize($len);
561+ $field['db'] = substr($packet, $pos, $len);
562+ $pos += $len;
563+
564+ // テーブル名
565+ $len = $this->getLengthEncodedIntegerValue($packet, $pos);
566+ $pos += $this->getLengthEncodedIntegerSize($len);
567+ $field['table'] = substr($packet, $pos, $len);
568+ $pos += $len;
569+
570+ // 元のテーブル名
571+ $len = $this->getLengthEncodedIntegerValue($packet, $pos);
572+ $pos += $this->getLengthEncodedIntegerSize($len);
573+ $field['org_table'] = substr($packet, $pos, $len);
574+ $pos += 1 + $len;
575+
576+ // フィールド名
577+ $len = $this->getLengthEncodedIntegerValue($packet, $pos);
578+ $pos += $this->getLengthEncodedIntegerSize($len);
579+ $field['name'] = substr($packet, $pos, $len);
580+ $pos += $len;
581+
582+ // 元のフィールド名
583+ $len = $this->getLengthEncodedIntegerValue($packet, $pos);
584+ $pos += $this->getLengthEncodedIntegerSize($len);
585+ $field['org_name'] = substr($packet, $pos, $len);
586+ $pos += $len;
587+
588+ // フィルターバイトをスキップ(通常は0x0C)
589+ $pos += 1;
590+
591+ // 文字セット
592+ $field['charset'] = unpack('v', substr($packet, $pos, 2))[1];
593+ $pos += 2;
594+
595+ // 列の長さ
596+ $field['length'] = unpack('V', substr($packet, $pos, 4))[1];
597+ $pos += 4;
598+
599+ // フィールド種類
600+ $field['type'] = ord($packet[$pos]);
601+ $pos += 1;
602+
603+ // フラグ
604+ $field['flags'] = unpack('v', substr($packet, $pos, 2))[1];
605+ $pos += 2;
606+
607+ // 小数点以下の桁数
608+ $field['decimals'] = ord($packet[$pos]);
609+ $pos += 1;
610+
611+ // フィルターバイトをスキップ
612+ $pos += 2;
613+
614+ // デフォルト値(存在する場合、長さエンコード文字列)
615+ if ($pos < strlen($packet)) {
616+ $len = $this->getLengthEncodedIntegerValue($packet, $pos);
617+ $pos += $this->getLengthEncodedIntegerSize($len);
618+ $field['default'] = substr($packet, $pos, $len);
619+ }
620+
621+ return $field;
622+ }
623+
624+ /**
625+ * 行パケットを解析する
626+ *
627+ * 結果セットの行データを解析して返します。
628+ *
629+ * @param string $packet 行パケット
630+ * @param array $fields フィールドメタデータの配列
631+ * @return array 行データの連想配列
632+ */
633+ private function parseRowPacket(string $packet, array $fields): array {
634+ $pos = 0;
635+ $row = [];
636+
637+ foreach ($fields as $field) {
638+ // 0xFB = NULL
639+ if (ord($packet[$pos]) === 0xFB) {
640+ $row[$field['name']] = null;
641+ $pos++;
642+ continue;
643+ }
644+
645+ // 長さ
646+ $len = ord($packet[$pos]);
647+ $pos++;
648+ $row[$field['name']] = substr($packet, $pos, $len);
649+ $pos += $len;
650+ }
651+
652+ return $row;
653+ }
654+
655+ /**
656+ * OKパケットを解析する
657+ *
658+ * OKパケットの内容を解析して影響を受けた行数や挿入IDなどを返します。
659+ *
660+ * @param string $packet OKパケット
661+ * @return array OKパケットのデータ
662+ * @throws \Exception パケットが不完全な場合
663+ */
664+ private function parseOkPacket(string $packet): array {
665+ if (strlen($packet) < 2) {
666+ $msg = "OKパケットが短すぎます: ".strlen($packet)."バイト";
667+ logger(\LogType::MySQL, $msg);
668+ throw new \Exception($msg);
669+ }
670+
671+ $pos = 1; // ヘッダーバイト(0x00)をスキップする
672+
673+ $affectedRows = $this->getLengthEncodedIntegerValue($packet, $pos);
674+ $pos += $this->getLengthEncodedIntegerSize($affectedRows);
675+
676+ $insertId = $this->getLengthEncodedIntegerValue($packet, $pos);
677+ $pos += $this->getLengthEncodedIntegerSize($insertId);
678+
679+ if (strlen($packet) < $pos + 2) {
680+ $msg = "OKパケットにサーバーステータス用のデータが不足しています";
681+ logger(\LogType::MySQL, $msg);
682+ throw new \Exception($msg);
683+ }
684+ $serverStatus = unpack('v', substr($packet, $pos, 2))[1];
685+ $pos += 2;
686+
687+ if (strlen($packet) < $pos + 2) {
688+ $msg = "OKパケットに警告カウント用のデータが不足しています";
689+ logger(\LogType::MySQL, $msg);
690+ throw new \Exception($msg);
691+ }
692+ $warningCount = unpack('v', substr($packet, $pos, 2))[1];
693+
694+ return [
695+ 'affectedRows' => $affectedRows,
696+ 'insertId' => $insertId,
697+ 'serverStatus' => $serverStatus,
698+ 'warningCount' => $warningCount,
699+ ];
700+ }
701+
702+ /**
703+ * MySQLサーバーからパケットを読み込む
704+ *
705+ * ソケットからパケットを読み込み、完全なデータを受信するまで待機します。
706+ *
707+ * @return string 受信したパケットデータ
708+ * @throws \Exception 読み込みに失敗した場合
709+ */
710+ private function readPacket(): string {
711+ $header = ''; // パケットのヘッダー=4バイト
712+ $bytesRead = socket_recv($this->socket, $header, 4, MSG_WAITALL);
713+ if ($bytesRead !== 4) {
714+ $msg = "パケットヘッダーの読み込みに失敗: 期待 4 バイト, 取得 {$bytesRead}";
715+ logger(\LogType::MySQL, $msg);
716+ throw new \Exception($msg);
717+ }
718+
719+ // パケットの長さを最初3バイトからパーシングする
720+ $len = ord($header[0]) + (ord($header[1]) << 8) + (ord($header[2]) << 16);
721+
722+ // パケットの順序番号は第4目のバイト
723+ $seqNum = ord($header[3]);
724+
725+ // パケットの内容を読み込む
726+ $data = '';
727+ $remaining = $len;
728+ $timeout = 5;
729+ socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, [
730+ 'sec' => $timeout,
731+ 'usec' => 0,
732+ ]);
733+
734+ while ($remaining > 0) {
735+ $buffer = '';
736+ $bytesRead = socket_recv($this->socket, $buffer, $remaining, 0);
737+
738+ if ($bytesRead === false) {
739+ $msg = "パケット内容の読み込みに失敗: エラー "
740+ .socket_strerror(socket_last_error($this->socket));
741+ logger(\LogType::MySQL, $msg);
742+ throw new \Exception($msg);
743+ }
744+
745+ if ($bytesRead === 0) {
746+ usleep(10000);
747+ continue;
748+ }
749+
750+ $data .= $buffer;
751+ $remaining -= $bytesRead;
752+ }
753+
754+ if (ord($data[0]) === 0x00 && strlen($data) < 7) {
755+ $extra = '';
756+ $extraBytes = socket_recv($this->socket, $extra, 7 - strlen($data), 0);
757+ if ($extraBytes !== false && $extraBytes > 0) {
758+ $data .= $extra;
759+ }
760+ }
761+
762+ // デバッグ
763+ if ($this->debug) {
764+ $packetInfo = [
765+ 'direction' => 'RECV',
766+ 'length' => $len,
767+ 'seqNum' => $seqNum,
768+ 'data' => $data,
769+ 'timestamp' => microtime(true),
770+ ];
771+
772+ $this->logPacket($packetInfo);
773+ }
774+
775+ return $data;
776+ }
777+
778+ /**
779+ * MySQLサーバーにパケットを送信する
780+ *
781+ * 指定されたデータとシーケンス番号でパケットを送信します。
782+ *
783+ * @param string $data 送信するデータ
784+ * @param int $seqNum シーケンス番号(デフォルトは0)
785+ * @return bool 成功時はtrue
786+ * @throws \Exception 送信に失敗した場合
787+ */
788+ private function sendPacket(string $data, $seqNum = 0): bool {
789+ $len = strlen($data);
790+
791+ // パケットヘッダー:長さ=3バイト、順序番号=1バイト
792+ $header = chr($len & 0xFF)
793+ .chr(($len >> 8) & 0xFF)
794+ .chr(($len >> 16) & 0xFF)
795+ .chr($seqNum);
796+
797+ // デバッグ
798+ if ($this->debug) {
799+ $packetInfo = [
800+ 'direction' => 'SEND',
801+ 'length' => $len,
802+ 'seqNum' => $seqNum,
803+ 'data' => $data,
804+ 'timestamp' => microtime(true),
805+ ];
806+
807+ $this->logPacket($packetInfo);
808+ }
809+
810+ // ヘッダーの送信
811+ $sent = socket_write($this->socket, $header, 4);
812+ if ($sent !== 4) {
813+ $msg = "パケットヘッダーの送信に失敗";
814+ logger(\LogType::MySQL, $msg);
815+ throw new \Exception($msg);
816+ }
817+
818+ // データの送信
819+ $sent = socket_write($this->socket, $data, $len);
820+ if ($sent !== $len) {
821+ $msg = "パケットデータの送信に失敗";
822+ logger(\LogType::MySQL, $msg);
823+ throw new \Exception($msg);
824+ }
825+
826+ return true;
827+ }
828+
829+ /**
830+ * MySQLサーバーにコマンドを送信する
831+ *
832+ * 指定されたコマンドとデータを送信します。
833+ *
834+ * @param string $command コマンド(例: 0x03 = COM_QUERY)
835+ * @param string $data 付加データ(デフォルトは空)
836+ * @return bool 成功時はtrue
837+ * @throws \Exception 送信に失敗した場合
838+ */
839+ private function sendCommand(string $command, string $data = ''): bool {
840+ $packet = chr($command).$data;
841+ return $this->sendPacket($packet);
842+ }
843+
844+ /**
845+ * サーバーの挨拶パケットを解析する
846+ *
847+ * サーバーからの初期挨拶パケットを解析し、サーバー情報を保存します。
848+ *
849+ * @param string $packet 挨拶パケット
850+ * @return void
851+ */
852+ private function parseServerGreeting(string $packet): void {
853+ $pos = 0;
854+
855+ // プロトコールバージョン(1バイト)
856+ $this->serverInfo['protocol'] = ord($packet[$pos]);
857+ $pos++;
858+
859+ // サーバーバージョン
860+ $end = strpos($packet, "\0", $pos);
861+ $this->serverInfo['version'] = substr($packet, $pos, $end - $pos);
862+ $pos = $end + 1;
863+
864+ // スレッドID(4バイト)
865+ $this->serverInfo['threadId'] = unpack('V', substr($packet, $pos, 4))[1];
866+ $pos += 4;
867+
868+ // スクランブルバッファの最初の部分(8バイト)
869+ $this->serverInfo['scramble'] = substr($packet, $pos, 8);
870+ $pos += 8;
871+
872+ // フィルターバイトをスキップする
873+ $pos++;
874+
875+ // サーバー機能(2バイト)
876+ $this->serverInfo['capabilities'] = unpack('v', substr($packet, $pos, 2))[1];
877+ $pos += 2;
878+
879+ // サーバー言語(1バイト)
880+ $this->serverInfo['language'] = ord($packet[$pos]);
881+ $pos++;
882+
883+ // サーバー状況(2バイト)
884+ $this->serverInfo['status'] = unpack('v', substr($packet, $pos, 2))[1];
885+ $pos += 2;
886+
887+ // 13バイトのスキップ
888+ $pos += 13;
889+
890+ // その他(12バイト)
891+ $this->serverInfo['scramble'] .= substr($packet, $pos, 12);
892+ }
893+
894+ /**
895+ * パスワードをスクランブルする
896+ *
897+ * MySQL認証用のパスワードをスクランブルします。
898+ *
899+ * @param string $password プレーンテキストのパスワード
900+ * @param string $scramble サーバーから提供されたスクランブル文字列
901+ * @return string スクランブルされたパスワード(20バイト)
902+ */
903+ private function scramblePassword(string $password, string $scramble): string {
904+ $stage1 = sha1($password, true);
905+ $stage2 = sha1($stage1, true);
906+ $stage3 = sha1($scramble.$stage2, true);
907+
908+ // $stage1 XOR $stage3
909+ $res = '';
910+ for ($i = 0; $i < 20; $i++) {
911+ $res .= chr(ord($stage1[$i]) ^ ord($stage3[$i]));
912+ }
913+
914+ return $res;
915+ }
916+
917+ /**
918+ * デバッグのためにパケットをログする
919+ *
920+ * パケット情報をログに追加し、デバッグ出力を表示します。
921+ *
922+ * @param array $packetInfo パケット情報(方向、長さ、シーケンス番号、データ、タイムスタンプ)
923+ * @return void
924+ */
925+ private function logPacket(array $packetInfo): void {
926+ $this->packetLog[] = $packetInfo;
927+
928+ $direction = $packetInfo['direction'];
929+ $length = $packetInfo['length'];
930+ $seqNum = $packetInfo['seqNum'];
931+ $data = $packetInfo['data'];
932+
933+ echo "=== {$direction} パケット (長さ: {$length}, シーケンス: $seqNum) ===\n";
934+ echo $this->hexDumpWithAscii($data)."\n";
935+
936+ $this->interpretPacket($data);
937+
938+ echo "==========================================\n\n";
939+ }
940+
941+ /**
942+ * バイナリデータを16進数で出力する
943+ *
944+ * デバッグ用にデータを16進数形式で表示します。
945+ *
946+ * @param string $data バイナリデータ
947+ * @return string 16進数文字列
948+ */
949+ private function hexDump(string $data): string {
950+ $res = '';
951+ $len = strlen($data);
952+
953+ for ($i = 0; $i < $len; $i++) {
954+ $res .= sprintf('%02X ', ord($data[$i]));
955+
956+ // 読み易さの為、各16バイトで新行列を入る
957+ if (($i + 1) % 16 === 0 && $i !== $len - 1) {
958+ $res .= "\n";
959+ }
960+ }
961+
962+ return $res;
963+ }
964+
965+ /**
966+ * バイナリデータをASCIIで出力する
967+ *
968+ * デバッグ用にデータをASCII形式で表示します。
969+ *
970+ * @param string $data バイナリデータ
971+ * @return string ASCII文字列
972+ */
973+ private function asciiDump(string $data): string {
974+ $res = '';
975+ $len = strlen($data);
976+
977+ for ($i = 0; $i < $len; $i++) {
978+ $char = ord($data[$i]);
979+
980+ // 表示出来るASCII文字だけを書き出す
981+ if ($char >= 32 && $char <= 126) {
982+ $res .= $data[$i];
983+ } else {
984+ $res .= '.';
985+ }
986+
987+ // 読み易さの為、各16バイトで新行列を入る
988+ if (($i + 1) % 16 === 0 && $i !== $len - 1) {
989+ $res .= "\n";
990+ }
991+ }
992+
993+ return $res;
994+ }
995+
996+ /**
997+ * バイナリデータを2進数で出力する
998+ *
999+ * デバッグ用にデータを2進数形式で表示します。
1000+ *
1001+ * @param string $data バイナリデータ
1002+ * @return string 2進数文字列
1003+ */
1004+ private function binaryDump(string $data): string {
1005+ $res = '';
1006+ $len = strlen($data);
1007+
1008+ for ($i = 0; $i < $len; $i++) {
1009+ $res .= sprintf('%08b ', ord($data[$i]));
1010+
1011+ // 読み易さの為、各88バイトで新行列を入る
1012+ if (($i + 1) % 8 === 0 && $i !== $len - 1) {
1013+ $res .= "\n";
1014+ }
1015+ }
1016+
1017+ return $res;
1018+ }
1019+
1020+ /**
1021+ * パケットを最初のバイトに基づいて解釈する
1022+ *
1023+ * パケットの種類を特定し、デバッグ情報を出力します。
1024+ *
1025+ * @param string $data パケットデータ
1026+ * @return void
1027+ */
1028+ private function interpretPacket(string $data): void {
1029+ if (empty($data)) {
1030+ echo "解釈: 空パケット\n";
1031+ return;
1032+ }
1033+
1034+ $firstByte = ord($data[0]);
1035+
1036+ switch ($firstByte) {
1037+ case 0x00:
1038+ echo "解釈: OKパケット\n";
1039+ $this->debugOkPacket($data);
1040+ break;
1041+ case 0x17:
1042+ echo "解釈: COM_STMT_EXECUTEパケット\n";
1043+ $this->debugStmtExecutePacket($data);
1044+ break;
1045+ case 0xFF:
1046+ echo "解釈: エラーパケット\n";
1047+ $this->debugErrorPacket($data);
1048+ break;
1049+ case 0xFE:
1050+ echo "解釈: EOFパケット\n";
1051+ break;
1052+ case 0xFB:
1053+ echo "解釈: LOCAL INFILEリクエスト\n";
1054+ break;
1055+ default:
1056+ if ($firstByte === 3
1057+ && $data[1] === 'd'
1058+ && $data[2] === 'e'
1059+ && $data[3] === 'f') {
1060+ // フィールドパケットかどうかの確認
1061+ echo "解釈: フィールド説明パケット\n";
1062+ $this->debugFieldPacket($data);
1063+ } else if ($firstByte > 0 && $firstByte < 251) {
1064+ // 結果セットパケットかどうかの確認
1065+ echo "解釈: 結果セットヘッダーパケット(フィールド数: {$firstByte})\n";
1066+ } else {
1067+ // 以上じゃないと、列データパケットでかもしん
1068+ echo "解釈: 列データパケット又はその他のパケット種類\n";
1069+ $this->debugLengthEncodedStrings($data);
1070+ }
1071+ break;
1072+ }
1073+ }
1074+
1075+ /**
1076+ * OKパケット構造をデバッグする
1077+ *
1078+ * OKパケットの内容を解析し、デバッグ情報を出力します。
1079+ *
1080+ * @param string $data OKパケットデータ
1081+ * @return void
1082+ */
1083+ private function debugOkPacket(string $data): void {
1084+ if (strlen($data) < 2) {
1085+ echo ' Error: OK packet too short ('.strlen($data)." bytes)\n";
1086+ return;
1087+ }
1088+
1089+ $pos = 1; // ヘッダーバイトをスキップ
1090+
1091+ // 影響を受けた行数を取得
1092+ $affectedRows = $this->getLengthEncodedIntegerValue($data, $pos);
1093+ echo " 影響を受けた行数: {$affectedRows}\n";
1094+ $pos += $this->getLengthEncodedIntegerSize($affectedRows);
1095+
1096+ // 最後の挿入IDを取得
1097+ $insertId = $this->getLengthEncodedIntegerValue($data, $pos);
1098+ echo " 最後の挿入ID: {$insertId}\n";
1099+ $pos += $this->getLengthEncodedIntegerSize($insertId);
1100+
1101+ // サーバーステータス
1102+ if (strlen($data) >= $pos + 2) {
1103+ $serverStatus = unpack('v', substr($data, $pos, 2))[1];
1104+ echo " サーバーステータス: ".sprintf('0x%04X', $serverStatus)."\n";
1105+ $pos += 2;
1106+ } else {
1107+ echo " サーバーステータス: 利用不可\n";
1108+ }
1109+
1110+ // 警告カウント
1111+ if (strlen($data) >= $pos + 2) {
1112+ $warningCount = unpack('v', substr($data, $pos, 2))[1];
1113+ echo " 警告カウント: {$warningCount}\n";
1114+ $pos += 2;
1115+ } else {
1116+ echo " 警告カウント: 利用不可\n";
1117+ }
1118+
1119+ // サーバーメッセージ(存在する場合)
1120+ if (strlen($data) > $pos) {
1121+ $message = substr($data, $pos);
1122+ echo " メッセージ: ".$this->safeString($message)."\n";
1123+ }
1124+ }
1125+
1126+ /**
1127+ * エラーパケット構造をデバッグする
1128+ *
1129+ * エラーパケットの内容を解析し、デバッグ情報を出力します。
1130+ *
1131+ * @param string $data エラーパケットデータ
1132+ * @return void
1133+ */
1134+ private function debugErrorPacket(string $data): void {
1135+ $errorCode = unpack('v', substr($data, 1, 2))[1];
1136+ echo " エラーコード: {$errorCode}\n";
1137+
1138+ // SQLステートマーカーをスキップ(#)
1139+ $sqlState = substr($data, 4, 5);
1140+ echo " SQLステート: {$sqlState}\n";
1141+
1142+ $errorMessage = substr($data, 9);
1143+ echo " エラーメッセージ: ".$this->safeString($errorMessage)."\n";
1144+ }
1145+
1146+ /**
1147+ * フィールドパケット構造をデバッグする
1148+ *
1149+ * フィールドパケットの内容を解析し、デバッグ情報を出力します。
1150+ *
1151+ * @param string $data フィールドパケットデータ
1152+ * @return void
1153+ */
1154+ private function debugFieldPacket(string $data): void {
1155+ $pos = 0;
1156+
1157+ // パケットから長さエンコード文字列を抽出
1158+ echo " フィールドパケット構造:\n";
1159+
1160+ // カタログを抽出
1161+ $len = $this->getLengthEncodedIntegerValue($data, $pos);
1162+ $pos += $this->getLengthEncodedIntegerSize($len);
1163+ $catalog = substr($data, $pos, $len);
1164+ echo " カタログ: ".$this->safeString($catalog)." (長さ: $len)\n";
1165+ $pos += $len;
1166+
1167+ // データベースを抽出
1168+ $len = $this->getLengthEncodedIntegerValue($data, $pos);
1169+ $pos += $this->getLengthEncodedIntegerSize($len);
1170+ $database = substr($data, $pos, $len);
1171+ echo " データベース: ".$this->safeString($database)." (長さ: $len)\n";
1172+ $pos += $len;
1173+
1174+ // テーブルを抽出
1175+ $len = $this->getLengthEncodedIntegerValue($data, $pos);
1176+ $pos += $this->getLengthEncodedIntegerSize($len);
1177+ $table = substr($data, $pos, $len);
1178+ echo " テーブル: ".$this->safeString($table)." (長さ: $len)\n";
1179+ $pos += $len;
1180+
1181+ // 元のテーブルを抽出
1182+ $len = $this->getLengthEncodedIntegerValue($data, $pos);
1183+ $pos += $this->getLengthEncodedIntegerSize($len);
1184+ $orgTable = substr($data, $pos, $len);
1185+ echo " 元のテーブル: ".$this->safeString($orgTable)." (長さ: $len)\n";
1186+ $pos += $len;
1187+
1188+ // 名前を抽出
1189+ $len = $this->getLengthEncodedIntegerValue($data, $pos);
1190+ $pos += $this->getLengthEncodedIntegerSize($len);
1191+ $name = substr($data, $pos, $len);
1192+ echo " 名前: ".$this->safeString($name)." (長さ: $len)\n";
1193+ $pos += $len;
1194+
1195+ // 元の名前を抽出
1196+ $len = $this->getLengthEncodedIntegerValue($data, $pos);
1197+ $pos += $this->getLengthEncodedIntegerSize($len);
1198+ $orgName = substr($data, $pos, $len);
1199+ echo " 元の名前: ".$this->safeString($orgName)." (長さ: $len)\n";
1200+ $pos += $len;
1201+
1202+ // 次の長さエンコード整数を抽出(固定フィールドの長さ、通常は0x0C)
1203+ $fixedLength = $this->getLengthEncodedIntegerValue($data, $pos);
1204+ echo " 固定フィールドの長さ: {$fixedLength}\n";
1205+ $pos += $this->getLengthEncodedIntegerSize($fixedLength);
1206+
1207+ // 文字セット
1208+ $charSet = unpack('v', substr($data, $pos, 2))[1];
1209+ echo " 文字セット: ".sprintf('0x%04X', $charSet)."\n";
1210+ $pos += 2;
1211+
1212+ // 列の長さ
1213+ $columnLength = unpack('V', substr($data, $pos, 4))[1];
1214+ echo " 列の長さ: {$columnLength}\n";
1215+ $pos += 4;
1216+
1217+ // 列の種類
1218+ $columnType = ord($data[$pos]);
1219+ echo " 列の種類: ".sprintf('0x%02X', $columnType)."\n";
1220+ $pos++;
1221+
1222+ // フラグ
1223+ $flags = unpack('v', substr($data, $pos, 2))[1];
1224+ echo " フラグ: ".sprintf('0x%04X', $flags)."\n";
1225+ $pos += 2;
1226+
1227+ // 小数点以下の桁数
1228+ $decimals = ord($data[$pos]);
1229+ echo " 小数点以下の桁数: {$decimals}\n";
1230+ $pos++;
1231+
1232+ // フィルター
1233+ echo " フィルター: ".sprintf('0x%04X', unpack('v', substr($data, $pos, 2))[1])."\n";
1234+ }
1235+
1236+ /**
1237+ * パケット内の長さエンコード文字列をデバッグする
1238+ *
1239+ * パケットからすべての長さエンコード文字列を抽出し、デバッグ情報を出力します。
1240+ *
1241+ * @param string $data パケットデータ
1242+ * @return void
1243+ */
1244+ private function debugLengthEncodedStrings(string $data): void {
1245+ $pos = 0;
1246+ $length = strlen($data);
1247+ $stringCount = 0;
1248+
1249+ echo " 長さエンコード文字列:\n";
1250+
1251+ while ($pos < $length) {
1252+ // 長さエンコード文字列を特定
1253+ if ($pos >= $length) break;
1254+
1255+ $firstByte = ord($data[$pos]);
1256+
1257+ // MySQLプロトコルに基づく長さエンコーディング
1258+ if ($firstByte < 251) {
1259+ // 1バイト長
1260+ $len = $firstByte;
1261+ $pos++;
1262+
1263+ if ($pos + $len <= $length) {
1264+ $value = substr($data, $pos, $len);
1265+ echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
1266+ ." (長さ: {$len})\n";
1267+ $pos += $len;
1268+ } else {
1269+ echo " 位置 {$pos} での無効な長さエンコーディング\n";
1270+ break;
1271+ }
1272+ } else if ($firstByte == 251) {
1273+ // NULL値
1274+ echo " 文字列 ".(++$stringCount).": NULL\n";
1275+ $pos++;
1276+ } else if ($firstByte == 252) {
1277+ // 2バイト長
1278+ if ($pos + 3 <= $length) {
1279+ $len = unpack('v', substr($data, $pos + 1, 2))[1];
1280+ $pos += 3;
1281+
1282+ if ($pos + $len <= $length) {
1283+ $value = substr($data, $pos, $len);
1284+ echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
1285+ ." (長さ: {$len})\n";
1286+ $pos += $len;
1287+ } else {
1288+ echo " 位置 {$pos} での無効な長さエンコーディング\n";
1289+ break;
1290+ }
1291+ } else {
1292+ echo " 位置 {$pos} での不完全な2バイト長\n";
1293+ break;
1294+ }
1295+ } else if ($firstByte == 253) {
1296+ // 3バイト長
1297+ if ($pos + 4 <= $length) {
1298+ $len = unpack('V', substr($data, $pos + 1, 3)."\0")[1];
1299+ $pos += 4;
1300+
1301+ if ($pos + $len <= $length) {
1302+ $value = substr($data, $pos, $len);
1303+ echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
1304+ ." (長さ: {$len})\n";
1305+ $pos += $len;
1306+ } else {
1307+ echo " 位置 {$pos} での無効な長さエンコーディング\n";
1308+ break;
1309+ }
1310+ } else {
1311+ echo " 位置 {$pos} での不完全な3バイト長\n";
1312+ break;
1313+ }
1314+ } else if ($firstByte == 254) {
1315+ // 8バイト長
1316+ if ($pos + 9 <= $length) {
1317+ // PHPでは8バイト整数を完全に扱えないため、最初の4バイトのみ読み取る
1318+ $len = unpack('V', substr($data, $pos + 1, 4))[1];
1319+ $pos += 9;
1320+
1321+ if ($pos + $len <= $length) {
1322+ $value = substr($data, $pos, $len);
1323+ echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
1324+ ." (長さ: {$len})\n";
1325+ $pos += $len;
1326+ } else {
1327+ echo " 位置 {$pos} での無効な長さエンコーディング\n";
1328+ break;
1329+ }
1330+ } else {
1331+ echo " 位置 {$pos} での不完全な8バイト長\n";
1332+ break;
1333+ }
1334+ } else {
1335+ // 長さエンコード文字列でない場合、次のバイトへ
1336+ $pos++;
1337+ }
1338+ }
1339+
1340+ if ($stringCount === 0) {
1341+ echo " No length-encoded strings found\n";
1342+ }
1343+ }
1344+
1345+ /**
1346+ * 文字列を安全に出力用に変換する
1347+ *
1348+ * 表示可能な文字のみを含み、非表示文字は16進数で表現します。
1349+ *
1350+ * @param string $str 変換する文字列
1351+ * @return string 安全な文字列
1352+ */
1353+ private function safeString(string $str): string {
1354+ $result = '';
1355+ $length = strlen($str);
1356+
1357+ for ($i = 0; $i < $length; $i++) {
1358+ $char = ord($str[$i]);
1359+ if ($char >= 32 && $char <= 126) {
1360+ $result .= $str[$i];
1361+ } else {
1362+ $result .= '\\x'.sprintf('%02X', $char);
1363+ }
1364+ }
1365+
1366+ return $result;
1367+ }
1368+
1369+ /**
1370+ * 指定位置の長さエンコード整数値を取得する
1371+ *
1372+ * パケット内の長さエンコード整数を読み取ります。
1373+ *
1374+ * @param string $data パケットデータ
1375+ * @param int $pos 開始位置
1376+ * @return mixed 整数値または0(不明な場合)
1377+ */
1378+ private function getLengthEncodedIntegerValue(string $data, int $pos): mixed {
1379+ $firstByte = ord($data[$pos]);
1380+
1381+ if ($firstByte < 251) {
1382+ return $firstByte;
1383+ } else if ($firstByte == 252) {
1384+ return unpack('v', substr($data, $pos + 1, 2))[1];
1385+ } else if ($firstByte == 253) {
1386+ return unpack('V', substr($data, $pos + 1, 3)."\0")[1];
1387+ } else if ($firstByte == 254) {
1388+ // 簡略化のため、8バイト整数の最初の4バイトのみ読み取る
1389+ return unpack('V', substr($data, $pos + 1, 4))[1];
1390+ }
1391+
1392+ return 0;
1393+ }
1394+
1395+ /**
1396+ * 長さエンコード整数のサイズを取得する
1397+ *
1398+ * 値に基づいて長さエンコードに必要なバイト数を返します。
1399+ *
1400+ * @param int $value 整数値
1401+ * @return int バイト数
1402+ */
1403+ private function getLengthEncodedIntegerSize(int $value): int {
1404+ if ($value < 251) {
1405+ return 1;
1406+ } else if ($value < 65536) {
1407+ return 3; // 0xFCマーカー1バイト + 値2バイト
1408+ } else if ($value < 16777216) {
1409+ return 4; // 0xFDマーカー1バイト + 値3バイト
1410+ } else {
1411+ return 9; // 0xFEマーカー1バイト + 値8バイト
1412+ }
1413+ }
1414+
1415+ /**
1416+ * 整数を長さエンコード形式に変換する
1417+ *
1418+ * MySQLプロトコルに基づいて整数を長さエンコードします。
1419+ *
1420+ * @param int $value 変換する整数
1421+ * @return string 長さエンコードされた文字列
1422+ */
1423+ private function encodeLengthEncodedInteger(int $value): string {
1424+ if ($value < 251) {
1425+ return chr($value);
1426+ } else if ($value < 65536) {
1427+ return chr(0xFC).pack('v', $value);
1428+ } else if ($value < 16777216) {
1429+ return chr(0xFD).pack('V', $value & 0xFFFFFF);
1430+ } else {
1431+ return chr(0xFE).pack('P', $value);
1432+ }
1433+ }
1434+
1435+ /**
1436+ * STMTパケットをデバッグする
1437+ *
1438+ * COM_STMT_EXECUTEパケットの内容を解析し、デバッグ情報を出力します。
1439+ *
1440+ * @param string $data パケットデータ
1441+ * @return void
1442+ */
1443+ private function debugStmtExecutePacket(string $data): void {
1444+ $pos = 1; // コマンドバイトをスキップ
1445+ $statementId = unpack('V', substr($data, $pos, 4))[1];
1446+ echo " ステートメントID: {$statementId}\n";
1447+ $pos += 5; // ステートメントIDとカーソルフラグをスキップ
1448+ $iterationCount = unpack('V', substr($data, $pos, 4))[1];
1449+ echo " 繰り返し数: {$iterationCount}\n";
1450+ $pos += 4;
1451+
1452+ $numParams = $this->prepared[$statementId]['num_params'] ?? 0;
1453+ if ($numParams > 0) {
1454+ $nullBitmapLen = ceil($numParams / 8);
1455+ $nullBitmap = substr($data, $pos, $nullBitmapLen);
1456+ echo " NULLビットマップ: ".$this->hexDump($nullBitmap)."\n";
1457+ $pos += $nullBitmapLen;
1458+
1459+ $newParamsFlag = ord($data[$pos]);
1460+ echo " 新パラメータフラグ: {$newParamsFlag}\n";
1461+ $pos++;
1462+
1463+ if ($newParamsFlag) {
1464+ echo " パラメータ種類:\n";
1465+
1466+ for ($i = 0; $i < $numParams; $i++) {
1467+ $type = unpack('v', substr($data, $pos, 2))[1];
1468+ $typeName = $this->getMysqlTypeName($type);
1469+ echo " パラメータ {$i}: ".sprintf("0x%02X", $type)." ({$typeName})\n";
1470+ $pos += 2;
1471+ }
1472+
1473+ echo " パラメータ値: (生バイナリが続く)\n";
1474+ }
1475+ }
1476+ }
1477+
1478+ /**
1479+ * 16進値をMySQLタイプ名に変換する
1480+ *
1481+ * MySQLのデータ型コードを対応する型名に変換します。
1482+ *
1483+ * @param int $type 16進数の型コード
1484+ * @return string 型名(不明な場合は'UNKNOWN')
1485+ */
1486+ private function getMysqlTypeName(int $type): string {
1487+ $types = [
1488+ 0x00 => 'MYSQL_TYPE_DECIMAL',
1489+ 0x01 => 'MYSQL_TYPE_TINY',
1490+ 0x02 => 'MYSQL_TYPE_SHORT',
1491+ 0x03 => 'MYSQL_TYPE_LONG',
1492+ 0x04 => 'MYSQL_TYPE_FLOAT',
1493+ 0x05 => 'MYSQL_TYPE_DOUBLE',
1494+ 0x06 => 'MYSQL_TYPE_NULL',
1495+ 0x07 => 'MYSQL_TYPE_TIMESTAMP',
1496+ 0x08 => 'MYSQL_TYPE_LONGLONG',
1497+ 0x09 => 'MYSQL_TYPE_INT24',
1498+ 0x0A => 'MYSQL_TYPE_DATE',
1499+ 0x0B => 'MYSQL_TYPE_TIME',
1500+ 0x0C => 'MYSQL_TYPE_DATETIME',
1501+ 0x0D => 'MYSQL_TYPE_YEAR',
1502+ 0x0E => 'MYSQL_TYPE_NEWDATE',
1503+ 0x0F => 'MYSQL_TYPE_VARCHAR',
1504+ 0x10 => 'MYSQL_TYPE_BIT',
1505+ 0xF6 => 'MYSQL_TYPE_NEWDECIMAL',
1506+ 0xF7 => 'MYSQL_TYPE_ENUM',
1507+ 0xF8 => 'MYSQL_TYPE_SET',
1508+ 0xF9 => 'MYSQL_TYPE_TINY_BLOB',
1509+ 0xFA => 'MYSQL_TYPE_MEDIUM_BLOB',
1510+ 0xFB => 'MYSQL_TYPE_LONG_BLOB',
1511+ 0xFC => 'MYSQL_TYPE_BLOB',
1512+ 0xFD => 'MYSQL_TYPE_VAR_STRING',
1513+ 0xFE => 'MYSQL_TYPE_STRING',
1514+ 0xFF => 'MYSQL_TYPE_GEOMETRY',
1515+ ];
1516+
1517+ return $types[$type] ?? '不明';
1518+ }
1519+
1520+ /**
1521+ * 16進数とASCII値を横に並べて出力する
1522+ *
1523+ * 16進エディタのように、16進数とASCII値を並べて表示します。
1524+ *
1525+ * @param string $data バイナリデータ
1526+ * @return string フォーマットされた文字列
1527+ */
1528+ private function hexDumpWithAscii(string $data): string {
1529+ $output = '';
1530+ $len = strlen($data);
1531+ $offset = 0;
1532+
1533+ while ($offset < $len) {
1534+ $hex = '';
1535+ $ascii = '';
1536+ $bytesInLine = min(16, $len - $offset); // 1行あたり16バイト
1537+
1538+ // 16進数部分
1539+ for ($i = 0; $i < 16; $i++) {
1540+ if ($i < $bytesInLine) {
1541+ $hex .= sprintf('%02X ', ord($data[$offset + $i]));
1542+ } else {
1543+ $hex .= ' '; // 揃えのためにスペースを埋める
1544+ }
1545+ }
1546+
1547+ // ASCII部分
1548+ for ($i = 0; $i < $bytesInLine; $i++) {
1549+ $char = ord($data[$offset + $i]);
1550+ $ascii .= ($char >= 32 && $char <= 126) ? chr($char) : '.';
1551+ }
1552+
1553+ $output .= sprintf("%08X %s |%s|\n", $offset, $hex, $ascii);
1554+ $offset += 16;
1555+ }
1556+
1557+ return $output;
1558+ }
15591559 }
@@ -1,64 +1,64 @@
1-<?php
2-namespace Site\Test;
3-
4-require_once __DIR__.'/../../../autoload.php';
5-
6-use Site\Lib\Tester;
7-use Site\Lib\Mysql;
8-
9-$test = new Tester([
10- 'colorOutput' => true,
11- 'verboseOutput' => true
12-]);
13-
14-$test->describe('パケットのデバッグ', function($test): void {
15- try {
16- $db = new Mysql();
17-
18- $db->setDebug(true);
19- $db->connect();
20-
21- $result = $db->query('SELECT * FROM user WHERE id = 1');
22-
23- foreach ($result['rows'] as $row) {
24- echo "ユーザー名: ".$row['nickname']."\n";
25- }
26-
27- $db->savePacketLogToFile('mysql_log.txt');
28- $db->close();
29- } catch (\Exception $e) {
30- echo 'エラー: '.$e->getMessage()."\n";
31- }
32-});
33-
34-$test->describe('プリペアドステートメント', function($test): void {
35- try {
36- $db = new Mysql();
37- $db->connect();
38-
39- // データの入り
40- $stmt = $db->prepare('INSERT INTO users (name, age) VALUES (?, ?)');
41- $test->assertTrue($stmt);
42-
43- $db->execute($stmt, ['山田太郎', 25]);
44- // TODO: assert
45-
46- $close = $db->demolish($stmt);
47- $this->assertTrue($close);
48-
49- // データの受け取り
50- $stmt = $db->prepare('SELECT * FROM users WHERE age > ?');
51- $test->assertTrue($stmt);
52-
53- $res = $db->execute($stmt, [20]);
54- // TODO: assert
55- print_r($res);
56-
57- $close = $db->demolish($stmt);
58- $this->assertTrue($close);
59-
60- $db->close();
61- } catch (\Exception $e) {
62- echo 'エラー: '.$e->getMessage()."\n";
63- }
1+<?php
2+namespace Site\Test;
3+
4+require_once __DIR__.'/../../../autoload.php';
5+
6+use Site\Lib\Tester;
7+use Site\Lib\Mysql;
8+
9+$test = new Tester([
10+ 'colorOutput' => true,
11+ 'verboseOutput' => true
12+]);
13+
14+$test->describe('パケットのデバッグ', function($test): void {
15+ try {
16+ $db = new Mysql();
17+
18+ $db->setDebug(true);
19+ $db->connect();
20+
21+ $result = $db->query('SELECT * FROM user WHERE id = 1');
22+
23+ foreach ($result['rows'] as $row) {
24+ echo "ユーザー名: ".$row['nickname']."\n";
25+ }
26+
27+ $db->savePacketLogToFile('mysql_log.txt');
28+ $db->close();
29+ } catch (\Exception $e) {
30+ echo 'エラー: '.$e->getMessage()."\n";
31+ }
32+});
33+
34+$test->describe('プリペアドステートメント', function($test): void {
35+ try {
36+ $db = new Mysql();
37+ $db->connect();
38+
39+ // データの入り
40+ $stmt = $db->prepare('INSERT INTO users (name, age) VALUES (?, ?)');
41+ $test->assertTrue($stmt);
42+
43+ $db->execute($stmt, ['山田太郎', 25]);
44+ // TODO: assert
45+
46+ $close = $db->demolish($stmt);
47+ $this->assertTrue($close);
48+
49+ // データの受け取り
50+ $stmt = $db->prepare('SELECT * FROM users WHERE age > ?');
51+ $test->assertTrue($stmt);
52+
53+ $res = $db->execute($stmt, [20]);
54+ // TODO: assert
55+ print_r($res);
56+
57+ $close = $db->demolish($stmt);
58+ $this->assertTrue($close);
59+
60+ $db->close();
61+ } catch (\Exception $e) {
62+ echo 'エラー: '.$e->getMessage()."\n";
63+ }
6464 });
@@ -1,23 +1,23 @@
1- <article class="news-card">
2- {@ if (isset($post['thumbnail']) && $post['thumbnail'] != '') @}
3- <div class="news-image">
4- <a href="/{{ $section }}/{{ $post['slug'] }}">
5- <img src="/static/article/{{ $post['thumbnail'] }}" alt="{{ $post['title'] }}" loading="lazy" />
6- </a>
7- </div>
8- {@ endif @}
9- <div class="news-content">
10- <div class="news-meta">
11- <span class="news-date">{{ $post['date'] }}</span>
12- {# {@ if (isset($post['category']) && is_array($post['category'])) @} #}
13- {@ foreach ($post['category'] as $cat) @}
14- <span class="news-category">{{ $cat }}</span>
15- {@ endforeach @}
16- {# {@ endif @} #}
17- </div>
18- <h2 class="news-title">
19- <a href="/{{ $section }}/{{ $post['slug'] }}{{{ isset($_GET['q']) ? '?q='.urlencode($_GET['q']) : '' }}}">{{{ $post['title'] }}}</a>
20- </h2>
21- <p class="news-preview">{{{ $post['preview'] }}}</p>
22- </div>
1+ <article class="news-card">
2+ {@ if (isset($post['thumbnail']) && $post['thumbnail'] != '') @}
3+ <div class="news-image">
4+ <a href="/{{ $section }}/{{ $post['slug'] }}">
5+ <img src="/static/article/{{ $post['thumbnail'] }}" alt="{{ $post['title'] }}" loading="lazy" />
6+ </a>
7+ </div>
8+ {@ endif @}
9+ <div class="news-content">
10+ <div class="news-meta">
11+ <span class="news-date">{{ $post['date'] }}</span>
12+ {# {@ if (isset($post['category']) && is_array($post['category'])) @} #}
13+ {@ foreach ($post['category'] as $cat) @}
14+ <span class="news-category">{{ $cat }}</span>
15+ {@ endforeach @}
16+ {# {@ endif @} #}
17+ </div>
18+ <h2 class="news-title">
19+ <a href="/{{ $section }}/{{ $post['slug'] }}{{{ isset($_GET['q']) ? '?q='.urlencode($_GET['q']) : '' }}}">{{{ $post['title'] }}}</a>
20+ </h2>
21+ <p class="news-preview">{{{ $post['preview'] }}}</p>
22+ </div>
2323 </article>
@@ -1,71 +1,71 @@
1- {@ if (isset($totalPages) && $totalPages > 1) @}
2- <div class="pagination">
3- {# 検索クエリがある場合はページネーションリンクに含める #}
4- {$ $queryParams = [] $}
5- {@ if (isset($_GET['q']) && !empty($_GET['q'])) @}
6- {$ $queryParams['q'] = $_GET['q'] $}
7- {@ endif @}
8-
9- {# 前のページへのリンク #}
10- {@ if (isset($currentPage) && $currentPage > 1) @}
11- {$ $prevParams = $queryParams $}
12- {$ $prevParams['page'] = $currentPage - 1 $}
13- {$ $prevQueryString = http_build_query($prevParams) $}
14- <a href="?{{ $prevQueryString }}" class="page-link">&laquo; 前</a>
15- {@ endif @}
16-
17- {# 表示するページ番号の範囲を計算(モバイル対応の為) #}
18- {# 最大表示ページ数 #}
19- {$ $rangeSize = 2 $}
20- {$ $startPage = max(1, $currentPage - floor($rangeSize / 2)) $}
21- {$ $endPage = min($totalPages, $startPage + $rangeSize - 1) $}
22-
23- {# 範囲の調整 #}
24- {@ if ($endPage - $startPage + 1 < $rangeSize && $startPage > 1) @}
25- {$ $startPage = max(1, $endPage - $rangeSize + 1) $}
26- {@ endif @}
27-
28- {# 最初のページへのリンク(多数のページがある場合) #}
29- {@ if ($startPage > 1) @}
30- {$ $firstParams = $queryParams $}
31- {$ $firstParams['page'] = 1 $}
32- {$ $firstQueryString = http_build_query($firstParams) $}
33-
34- <a href="?{{ $firstQueryString }}" class="page-link">1</a>
35- {@ if ($startPage > 2) @}
36- <span class="page-ellipsis">...</span>
37- {@ endif @}
38- {@ endif @}
39-
40- {@ for ($i = $startPage; $i <= $endPage; $i++) @}
41- {$ $pageParams = $queryParams $}
42- {$ $pageParams['page'] = $i $}
43- {$ $pageQueryString = http_build_query($pageParams) $}
44-
45- {@ if ($i == $currentPage) @}
46- <span class="page-current" aria-current="page">{{ $i }}</span>
47- {@ else @}
48- <a href="?{{ $pageQueryString }}" class="page-link">{{ $i }}</a>
49- {@ endif @}
50- {@ endfor @}
51-
52- {@ if ($endPage < $totalPages) @}
53- {# 最後のページへのリンク(多数のページがある場合) #}
54- {$ $lastParams = $queryParams $}
55- {$ $lastParams['page'] = $totalPages $}
56- {$ $lastQueryString = http_build_query($lastParams) $}
57- {@ if ($endPage < $totalPages - 1) @}
58- <span class="page-ellipsis">...</span>
59- {@ endif @}
60- <a href="?{{ $lastQueryString }}" class="page-link">{{ $totalPages }}</a>
61- {@ endif @}
62-
63- {# 次のページへのリンク #}
64- {@ if (isset($currentPage) && $currentPage < $totalPages) @}
65- {$ $nextParams = $queryParams $}
66- {$ $nextParams['page'] = $currentPage + 1 $}
67- {$ $nextQueryString = http_build_query($nextParams) $}
68- <a href="?{{ $nextQueryString }}" class="page-link">次 &raquo;</a>
69- {@ endif @}
70- </div>
1+ {@ if (isset($totalPages) && $totalPages > 1) @}
2+ <div class="pagination">
3+ {# 検索クエリがある場合はページネーションリンクに含める #}
4+ {$ $queryParams = [] $}
5+ {@ if (isset($_GET['q']) && !empty($_GET['q'])) @}
6+ {$ $queryParams['q'] = $_GET['q'] $}
7+ {@ endif @}
8+
9+ {# 前のページへのリンク #}
10+ {@ if (isset($currentPage) && $currentPage > 1) @}
11+ {$ $prevParams = $queryParams $}
12+ {$ $prevParams['page'] = $currentPage - 1 $}
13+ {$ $prevQueryString = http_build_query($prevParams) $}
14+ <a href="?{{ $prevQueryString }}" class="page-link">&laquo; 前</a>
15+ {@ endif @}
16+
17+ {# 表示するページ番号の範囲を計算(モバイル対応の為) #}
18+ {# 最大表示ページ数 #}
19+ {$ $rangeSize = 2 $}
20+ {$ $startPage = max(1, $currentPage - floor($rangeSize / 2)) $}
21+ {$ $endPage = min($totalPages, $startPage + $rangeSize - 1) $}
22+
23+ {# 範囲の調整 #}
24+ {@ if ($endPage - $startPage + 1 < $rangeSize && $startPage > 1) @}
25+ {$ $startPage = max(1, $endPage - $rangeSize + 1) $}
26+ {@ endif @}
27+
28+ {# 最初のページへのリンク(多数のページがある場合) #}
29+ {@ if ($startPage > 1) @}
30+ {$ $firstParams = $queryParams $}
31+ {$ $firstParams['page'] = 1 $}
32+ {$ $firstQueryString = http_build_query($firstParams) $}
33+
34+ <a href="?{{ $firstQueryString }}" class="page-link">1</a>
35+ {@ if ($startPage > 2) @}
36+ <span class="page-ellipsis">...</span>
37+ {@ endif @}
38+ {@ endif @}
39+
40+ {@ for ($i = $startPage; $i <= $endPage; $i++) @}
41+ {$ $pageParams = $queryParams $}
42+ {$ $pageParams['page'] = $i $}
43+ {$ $pageQueryString = http_build_query($pageParams) $}
44+
45+ {@ if ($i == $currentPage) @}
46+ <span class="page-current" aria-current="page">{{ $i }}</span>
47+ {@ else @}
48+ <a href="?{{ $pageQueryString }}" class="page-link">{{ $i }}</a>
49+ {@ endif @}
50+ {@ endfor @}
51+
52+ {@ if ($endPage < $totalPages) @}
53+ {# 最後のページへのリンク(多数のページがある場合) #}
54+ {$ $lastParams = $queryParams $}
55+ {$ $lastParams['page'] = $totalPages $}
56+ {$ $lastQueryString = http_build_query($lastParams) $}
57+ {@ if ($endPage < $totalPages - 1) @}
58+ <span class="page-ellipsis">...</span>
59+ {@ endif @}
60+ <a href="?{{ $lastQueryString }}" class="page-link">{{ $totalPages }}</a>
61+ {@ endif @}
62+
63+ {# 次のページへのリンク #}
64+ {@ if (isset($currentPage) && $currentPage < $totalPages) @}
65+ {$ $nextParams = $queryParams $}
66+ {$ $nextParams['page'] = $currentPage + 1 $}
67+ {$ $nextQueryString = http_build_query($nextParams) $}
68+ <a href="?{{ $nextQueryString }}" class="page-link">次 &raquo;</a>
69+ {@ endif @}
70+ </div>
7171 {@ endif @}
@@ -1,6 +1,6 @@
1- <div class="search-form">
2- <form action="/{{ $curblog != 'gd' ? $section : '' }}" method="GET">
3- <input type="text" name="q" value="{{{ isset($_GET['q']) ? htmlspecialchars($_GET['q']) : '' }}}" placeholder="キーワードを入力して下さい" />
4- <input type="submit" value="検索" />
5- </form>
1+ <div class="search-form">
2+ <form action="/{{ $curblog != 'gd' ? $section : '' }}" method="GET">
3+ <input type="text" name="q" value="{{{ isset($_GET['q']) ? htmlspecialchars($_GET['q']) : '' }}}" placeholder="キーワードを入力して下さい" />
4+ <input type="submit" value="検索" />
5+ </form>
66 </div>
@@ -1,15 +1,15 @@
1-{@ include(common/header) @}
2- <h1 class="paragraph">Moneroで支援♡</h1>
3- <p class="paragraph">
4- 欲しければ、モネロ(XMR)でご支援お願い申し上げます。
5- </p>
6-
7- <p>
8- <img src="https://ass.technicalsuwako.moe/keroxmr.png" alt="" />
9- <img src="https://ass.technicalsuwako.moe/xmr-qr.png" alt="" />
10- </p>
11-
12- <p class="paragraph">
13- <pre><code>88daW9ANXGVg9zHe6tzHSpQjxHN6JPFDz9wvZBecL1BfTFwmkuLYm9xRsLUt1WAVGPQ6h5pZX6nyu9zXFwE5efSz1gtE1oz</code></pre>
14- </p>
15-{@ include(common/footer) @}
1+{@ include(common/header) @}
2+ <h1 class="paragraph">Moneroで支援♡</h1>
3+ <p class="paragraph">
4+ 欲しければ、モネロ(XMR)でご支援お願い申し上げます。
5+ </p>
6+
7+ <p>
8+ <img src="https://ass.technicalsuwako.moe/keroxmr.png" alt="" />
9+ <img src="https://ass.technicalsuwako.moe/xmr-qr.png" alt="" />
10+ </p>
11+
12+ <p class="paragraph">
13+ <pre><code>88daW9ANXGVg9zHe6tzHSpQjxHN6JPFDz9wvZBecL1BfTFwmkuLYm9xRsLUt1WAVGPQ6h5pZX6nyu9zXFwE5efSz1gtE1oz</code></pre>
14+ </p>
15+{@ include(common/footer) @}
@@ -1,6 +1,6 @@
1-{@ include(common/header) @}
2- <h1 class="paragraph">秘密のページ</h1>
3- <p class="paragraph">
4- 内緒だね~
5- </p>
6-{@ include(common/footer) @}
1+{@ include(common/header) @}
2+ <h1 class="paragraph">秘密のページ</h1>
3+ <p class="paragraph">
4+ 内緒だね~
5+ </p>
6+{@ include(common/footer) @}