SVNからのミラー

守矢諏訪子 <suwako@076.moe>、2025-11-07 22:48
コミット 438c7d8aef6fd2522759f9ef17ebe17e3605b0fb ・ファイルを見る

68個のファイル、+7619 -0

追加 .gitignore
@@ -0,0 +1,5 @@
1+data/*.pem
2+data/*.txt
3+log/*.txt
4+config/config.php
5+public/static/*.pem
追加 LICENSE.md
@@ -0,0 +1,32 @@
1+Copyright (c) 2025 テクニカル諏訪子
2+
3+Permission is hereby granted to any person obtaining a copy of the software
4+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
5+sublicense, and/or sell copies of the Software, subject to the following conditions:
6+
7+ 1. **Origin Attribution**:
8+ - You must not misrepresent the origin of the Software; you must not claim
9+ you created the original Software.
10+ - If the Software is used in a product, you must either:
11+ a. Provide clear attribution in the product's documentation, user interface,
12+ or other visible areas, **OR**
13+ b. Pay the original developers a fee they specify in writing.
14+ 2. **Usage Restriction**:
15+ - The Software, or any derivative works, dependencies, or libraries
16+ incorporating it, must not be used for censorship or to suppress freedom of
17+ speech, expression, or creativity. Prohibited uses include, but are not
18+ limited to:
19+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
20+ ideas, or objective reality.
21+ - Tools or systems designed to restrict access to information or
22+ artistic works.
23+ 3. **Notice Preservation**:
24+ - This license and the above copyright notice must remain intact in all copies
25+ of the source code.
26+
27+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
28+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
29+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
30+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
31+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
32+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
追加 README-JP.md
@@ -0,0 +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
追加 README.md
@@ -0,0 +1,188 @@
1+# Little Beast
2+Simple, Pragmatic, Anti-bloat
3+
4+## What is Little Beast?
5+Little Beast is a PHP 8.3 or above framework made for 076.moe (game developer company) and technicalsuwako.moe (CEO's blog).\
6+The core mentality is to extend what you need, and remove what you don't need.\
7+Each core feature is split into libraries, so it is easy to choose what you need.\
8+All modules are written from scratch, nothing is to be require a database.
9+
10+Little Beast is TechnicalSuwako's final web project before moving back to game development completely.
11+
12+## What Little Beast is not?
13+* General purpose framework
14+* Hard to use
15+* Meant for web developers
16+* For everyone/wider audience
17+* Tedious to install
18+* Dogmatic
19+* Democratically run
20+
21+## What Little Beast has not?
22+* Database (everything is a file)
23+* Dependencies
24+* Package manager
25+* Docker/Kubernetes/Vagrant/Nix/etc containers
26+* Javascript
27+* Bloatware
28+* ORM
29+* Authentication/authorization system
30+* Queues or background job
31+* Cloud/Serverless integration
32+* Scaffolding
33+* Code generation
34+* Abstraction layers
35+* Unnecessary files
36+* DEI, Code of Conduct, or other discriminatory practises
37+
38+## Unique features
39+* No database
40+* No Composer or PEAR
41+* No server overhead
42+* No container
43+* Maron template engine
44+* Custom Markdown
45+* ActivityPub
46+* Multi-language support
47+* Multi-blog support
48+* 100% correct and beautiful HTML5 and CSS3
49+* 100% correct PHP
50+* SEO friendly
51+* Modular CSS
52+* Atom feeds
53+* Built-in test suite
54+
55+## How to install
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+Configure HTTP server to run `/public` as root via php-fpm.\
63+That's all!
64+
65+### OpenBSD server
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+## Required PHP module
188+* php_gmp
追加 autoload.php
@@ -0,0 +1,11 @@
1+<?php
2+spl_autoload_register(function ($class): void {
3+ $prefix = 'Site\\';
4+ $base = realpath(__DIR__.'/src');
5+
6+ if (strncmp($prefix, $class, strlen($prefix)) !== 0) return;
7+ $ps = str_replace('\\', DIRECTORY_SEPARATOR, $class);
8+ $file = $base.DIRECTORY_SEPARATOR.$ps.'.php';
9+ if (file_exists($file)) require $file;
10+ else error_log("クラス{$class}を見つけられません。試したパス:{$file}");
11+});
@@ -0,0 +1,64 @@
1+title: 【マークダウン】関数
2+uuid: a8c04518-4181-4ec6-9ef0-3f88f23b84b6
3+author: 諏訪子
4+date: 2025-11-07 20:33:42
5+thumbnail: o_53803618dc1691.28179609-orig.jpg
6+thumborient: center
7+category: test,おちんちん,でっかいおっぱい
8+css: code,blockquote,table,blink
9+----
10+# ケロケロ
11+```c
12+int main(int argc, char **argv) {
13+ return 0;
14+}
15+```
16+
17+----
18+
19+> ワイトもそう思います。\
20+> も・・・亡者はそうは思いません。
21+
22+## ケロケロ
23+| 名前 | キャラ |
24+|---------|---------|
25+| 諏訪子 | ロリ |
26+| チルノ | ⑨ |
27+
28+* 諏訪子
29+* チルノ
30+
31+- ロリ
32+- ⑨
33+
34+### ケロケロ
35+**ケロケロ**\
36+*kerokero*\
37+_ケロケロ_\
38+~ケロケロ~
39+
40+!:ケロケロ:!\
41+!:(5)ケロケロ:!\
42+!:(0.2)ケロケロ:!
43+
44+^(72)ケロケロ^\
45+^(6)ケロケロ^
46+
47+%(ff00ff)ケロケロ%\
48+%(34FB56)ケロケロ%
49+
50+#### ケロケロ
51+![](https://ass.technicalsuwako.moe/banner.png)\
52+![ケロケロ#max-width: 200px;](https://ass.technicalsuwako.moe/banner.png)\
53+$[audio/ogg](https://ass.technicalsuwako.moe/砕月着信音.ogg)\
54+#[video/mp4](https://ass.technicalsuwako.moe/フリーズ.mp4)
55+
56+##### ケロケロ
57+[076スタジオ合同会社](https://076.moe/)\
58+<諏訪子>(すわこ)\
59+`int i = 0;`\
60+
61+###### ケロケロ
62+1. アセンブリ言語
63+2. C言語
64+3. その他
@@ -0,0 +1,9 @@
1+title: はじめまして
2+uuid: b2308726-11a8-4d94-8e67-12babf4a9174
3+author: Little-san
4+date: 2025-04-10 00:00:00
5+category: test
6+----
7+投稿テストです
8+
9+マークダウンで、「<漢字>(かんじ)」を<入力>(nyuuryoku)すると、<漢字>(かんじ)が表示する<!>(!)
@@ -0,0 +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 | ★☆☆☆☆ | ゲイ!! |
22+| Zig | ★★★☆☆ | 良いけど、未だ開発中 |
@@ -0,0 +1,40 @@
1+<?php
2+define('REPO_ROOT', '/repos/');
3+define('TWITTER_HANDLE', '@techsuwako');
4+define('SITEINFO', [
5+ 'title' => 'Little Beast',
6+ 'description' => 'PHP Framework',
7+ 'tags' => 'game,tech,developer',
8+]);
9+define('MAILINFO', [
10+ 'from' => 'hogehoge@ho.ge',
11+ 'host' => 'smtp.ho.ge',
12+ 'port' => 587,
13+ 'user' => 'hogehoge@ho.ge',
14+ 'pass' => 'hogehogehoge',
15+]);
16+define('FEDIINFO', [
17+ 'actor' => 'loli',
18+ 'actorNick' => 'ロリ',
19+ 'desc' => '',
20+ 'icon' => '/static/logo.png',
21+ 'pubkey' => ROOT.'/public/static/pub.pem',
22+ 'privkey' => ROOT.'/data/priv.pem',
23+]);
24+define('DBINFO', [
25+ 'host' => 'localhost',
26+ 'username' => 'littlebeast',
27+ 'password' => '',
28+ 'dbname' => 'littlebeast',
29+ 'port' => 3306,
30+ 'debug' => false,
31+]);
32+
33+define('MAILER_ENABLED', false);
34+define('LOGGING_ENABLED', true);
35+define('ATOM_ENABLED', true);
36+define('RSS_ENABLED', false);
37+define('ACTIVITYPUB_ENABLED', false);
38+define('MYSQL_ENABLED', false);
39+define('CSV_ENABLED', false);
40+define('COPYRIGHT_YEAR', '2018-'.date('Y'));
追加 data/.kara
@@ -0,0 +1,34 @@
1+# Little Beast
2+Little Beast は、元々076.moeとtechnicalsuwako.moeの
3+為に作られたPHPフレームワークで、
4+これらのウェブサイトが以前使用していた静的サイトジェネレーターを置き換える為に
5+開発されました。
6+然し時間が経つに連れて、次々と機能を追加していき、
7+最終的には完全なPHPフレームワークに近い物になった為、
8+他の人々にも役立つかもしんと考えました。
9+
10+然し、 Little Beast の開発者は、PHPフレームワークが知識とスキルを奪い、
11+複雑で重いという理由で、その使用に反対しています。
12+その為、 Little Beast は逆のアプローチを取り、
13+ユーザーが裏側でどの様に動作しているかを理解出来る様に、
14+出来るだけシンプルで軽量に保つ様にしています。
15+
16+これは大量生産用に作られた物ではなく、
17+寧ろ一からフレームワークを作る方法の参考として提供されています。
18+実際には貴方が考えるよりもずっと簡単なのです。
19+
20+この為に、以下の原則が確立されています:
21+* composerやその他のパッケージマネージャーは使用しません。
22+* 全てのクラスと関数は一から書かれています。
23+* 開発者がフレームワークのコードを探索する事を推奨している為、
24+ 全てのクラスは徹底的に文書化されています。
25+* 全てのクラスは要点を押さえた物でなければならず、
26+ クラス間の移動は許可されていません。
27+* コンテナ化の必要性を排除し、必要なのはPHPとウェブサーバーだけです。
28+* 常に最新のPHPバージョンに対応し、警告や非推奨の通知は許容されません。
29+* 不要と判断されたコードは全て削除します。
30+
31+全てのライブラリは特に他の何にも依存しない様に設計されているので、
32+使用する予定のないクラスを削除する事を恐れないで下さい。
33+後で必要と判断された場合でも、シンプルなドラッグアンドドロップで追加し
34+直す事が出来ます。
@@ -0,0 +1,34 @@
1+# 仕組み
2+
3+Little Beastには魔法の様に動作する5つの主要ファイルが含まれていますが、
4+どれも複雑ではありません。
5+Webサーバーの設定で、ルートディレクトリを `/public` に
6+、インデックスファイルを `index.php` 設定します。
7+このindex.phpファイルがすることは、 `/public` ディレクトリの
8+下にある `route.php` ファイルをインクルードするだけです。
9+この設定により、クライアントは `/public` ディレクトリ内のファイルにのみ
10+アクセス出来る様になります。
11+これには画像、スタイルシート、JavaScript等が含まれます。
12+従って、設定、ルート、コントローラー等の機密データは、プログラマーが明示的に
13+アクセス可能にしない限りアクセス出来ません。
14+`/public/index.php` は編集しない様に設計されています。
15+
16+`route.php` ファイルには、以下のファイルがインクルードされています:
17+* autoload.php
18+* config/config.php
19+* util.php
20+
21+`autoload.php` ファイルは、バックエンド操作を全て含むsrcディレクトリ内で
22+オブジェクト指向プログラミングを可能にする為の物です。
23+このファイルは、何をしているか理解している場合を除き、
24+編集すべきではありません。
25+
26+`config/config.php` ファイルには、シンプルな `define` 文として全ての
27+設定が含まれています。
28+設定可能な物は全てサンプルファイルに含まれています。
29+プログラマーは必要に応じて他の変数を追加する事が出来ますが、
30+事前定義された物を削除しない事をお勧めします。
31+
32+そして、 `util.php` ファイルには、通常のPHPでは利用出来ない便利なカスタム
33+機能が全て含まれています。
34+プログラマーは必要に応じて追加機能を加える事が出来ます。
@@ -0,0 +1,89 @@
1+# Maron テンプレートの仕組み
2+Maron は Little Beast のカスタム HTML テンプレートエンジンです。
3+
4+## 構文
5+
6+構文は以下の通りです:
7+
8+```
9+{@ include(common/header.maron) @} // これは別の Maron ファイルを含めます。
10+ // シングルクォートやダブルクォートを
11+ // 追加しないで下さい!
12+
13+{@ kys($var) @} // これは変数を読み易い形式で出力し、
14+ // その後テンプレートの残りの実行を終了します。
15+
16+{@ if (true) @}
17+ // 何かを実行
18+{@ elif (null) @}
19+ // 別の事を実行
20+{@ else @}
21+ // その他を実行
22+{@ endif @}
23+
24+{@ foreach ($array as $value) @}
25+{{ $value }}<br />
26+{@ endforeach @}
27+
28+{@ foreach ($array as $key => $value) @}
29+{{ $key }}: {{ $value }}<br />
30+{@ endforeach @}
31+
32+{@ for ($i = 0; $i < 10; $i++) @}
33+{{ $i }}<br />
34+{@ endfor @}
35+
36+{# This is a comment #}
37+{{ $var }} // これは特殊文字をエスケープして変数を出力します
38+{{{ $var }}} // これは変数をそのままの形で出力します
39+{$ $var = 200 $} // これは $var を 200 として定義します
40+{! echo "hello world"; !} // これは生の PHP コードをそのまま実行します
41+```
42+
43+## コントローラー機能
44+更に、Little Beast では Maron テンプレート内で使用出来る
45+コントローラー用の機能も提供しています。
46+多言語ウェブサイトを使用する場合、
47+`new Template()` の後にその言語のルートディレクトリを指定出来ます。
48+但し、これには `/view` ディレクトリ内にそれらのルートディレクトリも
49+設定する必要があります。
50+例えば、構造が `/view/en/index.maron` の場合、
51+`new Template('/en')` と定義します。
52+然し構造が `/view/index.maron` の場合は、
53+単に `new Template()` とします。
54+
55+### assign()
56+`->assign()` メソッドを使用すると、
57+Maron テンプレートで使用したい変数を割り当てます。
58+
59+例:
60+```
61+$tmpl = new Template();
62+
63+$sum = 1 + 1;
64+$tmpl->assign('total', $sum);
65+```
66+
67+この場合、Maron テンプレートで `{{ $total }}` を
68+使用して `2` を出力します。
69+
70+### addCss()
71+デフォルトでは、 `style.css` は常に含まれています。
72+但し、全てのページ又は大部分のページで使用する
73+予定の物だけを `style.css` に入れ、
74+特定のページでのみ使用する予定の全ての物は、
75+`style-` の接頭辞を付けた独自のファイルに入れる事をお勧めします。
76+例えば、お問い合わせページがあり、
77+そのページでのみ使用する CSS がある場合は、
78+`/public/static` に新しい `style-contact.css` ファイルを作成し、
79+`render` メソッドの前に `$tmpl->addCss('contact');` を追加します。
80+
81+### render()
82+`render()` メソッドは Maron テンプレートの内容を取得し
83+、ウェブブラウザが理解出来る有効な HTML と PHP に全てを変換します。
84+Maron ファイルが `/view/index.maron` の場合、
85+`$tmpl->render('index');` を実行しますが、
86+`/view/en/index.maron` の場合、
87+`$tmpl = new Template('/en');` に続いて `$tmpl->render('index');` か、
88+`$tmpl = new Template();` に続いて `$tmpl->render('en/index');` の
89+いずれかを実行出来ます。
@@ -0,0 +1,16 @@
1+# ルートの仕組み
2+
3+`route.php` では、 `use Site\Lib\Route; `というインクルードもある事に
4+気づくでしょう。
5+これが全てのルートを処理します。
6+ルートの基本的な流れは次の通りです:
7+
8+1. 配列を定義します。
9+2. `Route::add(メソッドタイプ, クラス名 + メソッド, オプションのパラメータ);` で
10+ 配列内に新しいルートを追加します。
11+3. `Route::init(ルート配列)` でルートを初期化します。
12+4. ルートが見つからない場合に備えて、
13+ `Route::setFallback(コントローラークラス, そのクラス内のメソッド)` で
14+ フォールバックを設定します。
15+5. これで `Route::dispatch()` はクライアントが行くルートをチェックし、
16+ 正しいページに送信する事が出来ます。
追加 log/.kara
追加 newpost.php
@@ -0,0 +1,16 @@
1+<?php
2+if (!isset($argv[1])) die('usage: php newpost.php [slug]');
3+if (file_exists("blog/{$argv[1]}.md")) die("エラー: ファイル「blog/{$argv[1]}.md」は既に存在します。\n");
4+
5+include('util.php');
6+$post = fopen("blog/{$argv[1]}.md", "w");
7+fwrite($post, "title: 【】\n");
8+fwrite($post, "uuid: ".uuid()."\n");
9+fwrite($post, "author: 諏訪子\n");
10+fwrite($post, "date: ".date("Y-m-d H:i:s")."\n");
11+fwrite($post, "thumbnail: \n");
12+fwrite($post, "thumborient: center\n");
13+fwrite($post, "category: \n");
14+fwrite($post, "----\n");
15+fclose($post);
16+?>

バイナリファイル

@@ -0,0 +1,3 @@
1+<?php
2+require_once('../route.php');
3+?>
@@ -0,0 +1,1 @@
1+User-agent: * Disallow:
@@ -0,0 +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;
20+}
@@ -0,0 +1,9 @@
1+.blink {
2+ animation: blinker 1s linear infinite;
3+}
4+
5+@keyframes blinker {
6+ 50% {
7+ opacity: 0;
8+ }
9+}
@@ -0,0 +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;
8+}
@@ -0,0 +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+ }
36+}
@@ -0,0 +1,18 @@
1+pre {
2+ background: #232023;
3+ color: fcfcfc;
4+ padding: 1rem;
5+ border-radius: 6px;
6+ overflow: auto;
7+ font-family: Consolas,Monaco,monospace;
8+ border: 1px solid #f545f5;
9+}
10+
11+/*pre > code {
12+ color: #fcfcfc;
13+}*/
14+
15+code {
16+ color: #b421f8;
17+ line-height: 1.45;
18+}
@@ -0,0 +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;
40+}
@@ -0,0 +1,73 @@
1+.thumbnail {
2+ text-align: center;
3+ width: 100%;
4+ max-height: 300px;
5+ overflow: hidden;
6+ margin-bottom: 20px;
7+ position: relative;
8+}
9+
10+.thumbnail > img {
11+ width: 100%;
12+ height: 300px;
13+ object-fit: cover;
14+ object-position: center;
15+ display: block;
16+}
17+
18+.thumbnail.top > img {
19+ object-position: top;
20+}
21+
22+.thumbnail.bottom > img {
23+ object-position: bottom;
24+}
25+
26+.meta {
27+ display: inline;
28+}
29+
30+.meta-author, .meta-date, .meta-category {
31+ display: inline-block;
32+ margin: 4px;
33+ padding: 8px;
34+ border: 1px solid #ea79d8;
35+ border-radius: 10px;
36+}
37+
38+.meta-author, .meta-date {
39+ font-weight: bolder;
40+}
41+
42+.meta-author {
43+ background-color: #c016c6;
44+}
45+
46+.meta-date {
47+ background-color: #550f75;
48+ color: #fcfcfc;
49+}
50+
51+.meta-category {
52+ background-color: #ae6bdb;
53+}
54+
55+h1, h2, h3, h4, h5, h6 {
56+ border-bottom: 1px solid #ea79d8;
57+}
58+
59+/* 死ねー */
60+@media screen and (max-width: 480px) {
61+ .news-meta {
62+ flex-direction: column;
63+ align-items: flex-start;
64+ gap: 0.3rem;
65+ }
66+
67+ .meta-date, .meta-author, .meta-category {
68+ width: 90%;
69+ margin: 4px auto;
70+ text-align: center;
71+ display: block;
72+ }
73+}
@@ -0,0 +1,117 @@
1+.news-grid {
2+ display: block;
3+ padding: 1rem;
4+}
5+
6+.news-card {
7+ display: flex;
8+ flex-wrap: wrap;
9+ justify-content: space-between;
10+ align-items: center;
11+ margin: 12px 0px;
12+ background: #232023;
13+ border-radius: 8px;
14+ overflow: hidden;
15+ transition: transform 0.2s;
16+}
17+
18+.news-card:hover {
19+ transform: translateY(-5px);
20+}
21+
22+.news-image {
23+ width: 20%;
24+ position: relative;
25+ padding-top: 100px;
26+}
27+
28+.news-image img {
29+ position: absolute;
30+ top: 0;
31+ left: 10px;
32+ width: 100%;
33+ height: 100%;
34+ object-fit: cover;
35+}
36+
37+.news-content {
38+ width: 74%;
39+ padding: 1rem;
40+}
41+
42+.news-meta {
43+ display: flex;
44+ flex-wrap: wrap;
45+ gap: 0.5rem;
46+ margin-bottom: 0.5rem;
47+}
48+
49+.news-date {
50+ background: #443b44;
51+ padding: 0.25rem 0.5rem;
52+ border-radius: 4px;
53+ font-size: 0.875rem;
54+}
55+
56+.news-category {
57+ background: #c016c6;
58+ color: #232320;
59+ padding: 0.25rem 0.5rem;
60+ border-radius: 4px;
61+ font-size: 0.875rem;
62+}
63+
64+.news-title {
65+ margin: 0.5rem 0;
66+ font-size: 1.25rem;
67+}
68+
69+.news-title a {
70+ text-decoration: none;
71+}
72+
73+.news-title a:hover {
74+ color: #f545f5;
75+}
76+
77+.news-preview {
78+ font-size: 0.875rem;
79+ margin: 0;
80+}
81+
82+/* 糞フォンの方向け */
83+@media screen and (max-width: 768px) {
84+ .news-card {
85+ flex-direction: column;
86+ align-items: stretch;
87+ }
88+
89+ .news-image {
90+ width: 100%;
91+ padding-top: 60%;
92+ margin-bottom: 0.5rem;
93+ }
94+
95+ .news-image img {
96+ left: 0;
97+ }
98+
99+ .news-content {
100+ width: 100%;
101+ padding: 0.75rem;
102+ }
103+}
104+
105+/* 死ねー */
106+@media screen and (max-width: 480px) {
107+ .news-meta {
108+ flex-direction: column;
109+ align-items: flex-start;
110+ gap: 0.3rem;
111+ }
112+
113+ .news-date, .news-category {
114+ width: 80%;
115+ text-align: center;
116+ }
117+}
@@ -0,0 +1,66 @@
1+.pagination {
2+ display: flex;
3+ flex-wrap: wrap;
4+ justify-content: center;
5+ align-items: center;
6+ gap: 0.5rem;
7+ margin: 2rem 0;
8+}
9+
10+.page-link, .page-current, .page-ellipsis {
11+ display: flex;
12+ align-items: center;
13+ justify-content: center;
14+ min-width: 2.5rem;
15+ height: 2.5rem;
16+ padding: 0 0.5rem;
17+ border-radius: 4px;
18+ text-align: center;
19+}
20+
21+.page-link {
22+ background: #c016c6;
23+ color: #232023;
24+ text-decoration: none;
25+ transition: background 0.4s, transform 0.2s;
26+}
27+
28+.page-link:hover {
29+ background: #ea79d8;
30+ transform: translateY(-2px);
31+}
32+
33+.page-current {
34+ background: #443b44;
35+ color: #fcfcfc;
36+ font-weight: bold;
37+}
38+
39+.page-ellipsis {
40+ color: #c016c6;
41+}
42+
43+@media screen and (max-width: 768px) {
44+ .pagination {
45+ gap: 0.4rem;
46+ }
47+
48+ .page-link, .page-current, .page-ellipsis {
49+ min-width: 2.2rem;
50+ height: 2.2rem;
51+ font-size: 0.9rem;
52+ }
53+}
54+
55+@media screen and (max-width: 480px) {
56+ .pagination {
57+ gap: 0.3rem;
58+ }
59+
60+ .page-link, .page-current, .page-ellipsis {
61+ min-width: 1.8rem;
62+ height: 1.8rem;
63+ font-size: 0.8rem;
64+ padding: 0 0.3rem;
65+ }
66+}
@@ -0,0 +1,63 @@
1+.search-highlight {
2+ background-color: #c016c6;
3+ color: #000;
4+ font-weight: bold;
5+ padding: 1px 2px;
6+ border-radius: 3px;
7+}
8+
9+.search-form {
10+ text-align: center;
11+ margin: 20px 0;
12+}
13+
14+.search-form form {
15+ display: flex;
16+ justify-content: center;
17+ flex-wrap: wrap;
18+ gap: 10px;
19+}
20+
21+.search-form input[type="text"] {
22+ width: 60%;
23+ padding: 8px;
24+ border: 1px solid #ea79d8;
25+ background-color: #232320;
26+ color: #ea79d8;
27+ border-radius: 4px;
28+}
29+
30+.search-form input[type="submit"] {
31+ padding: 8px 16px;
32+ background-color: #c016c6;
33+ color: #12120f;
34+ border: none;
35+ border-radius: 4px;
36+ cursor: pointer;
37+ transition: background-color 0.3s;
38+}
39+
40+.search-form input[type="submit"]:hover {
41+ background-color: #ea79d8;
42+}
43+
44+@media screen and (max-width: 768px) {
45+ .search-form input[type="text"] {
46+ width: 70%;
47+ }
48+}
49+
50+@media screen and (max-width: 480px) {
51+ .search-form form {
52+ flex-direction: column;
53+ align-items: center;
54+ }
55+
56+ .search-form input[type="text"] {
57+ width: 90%;
58+ }
59+
60+ .search-form input[type="submit"] {
61+ width: 90%;
62+ }
63+}
@@ -0,0 +1,23 @@
1+table {
2+ border-collapse: separate;
3+ border-spacing: 0;
4+ border: 1px solid #c016c6;
5+ background-color: #232023;
6+ margin: auto;
7+ width: 95%;
8+ max-width: 1100px;
9+ overflow: hidden;
10+}
11+
12+th, td {
13+ border-bottom: 1px solid #ea79d8;
14+ text-align: left;
15+ padding: 10px;
16+ vertical-align: top;
17+}
18+
19+th {
20+ background-color: #550f75;
21+ color: #fcfcfc;
22+ width: 300px;
23+}
@@ -0,0 +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;
16+}
@@ -0,0 +1,97 @@
1+html, body {
2+ height: 100%;
3+ margin: 0;
4+ background-color: #232023;
5+ color: #fcfcfc;
6+}
7+
8+a {
9+ color: #ea79d8;
10+}
11+
12+header, main, footer {
13+ border: 1px solid #c016c6;
14+ margin: 20px;
15+ padding: 8px;
16+}
17+
18+.logo, nav {
19+ text-align: center;
20+}
21+
22+.logo {
23+ font-size: 32px;
24+ font-weight: bold;
25+}
26+
27+.menu-item {
28+ padding: 0 4px;
29+ transition: font-size 0.3s ease-in-out;
30+}
31+
32+.menu-item:hover {
33+ font-size: 140%;
34+}
35+
36+.nodeco {
37+ text-decoration: none;
38+}
39+
40+.menu-item.active {
41+ border: 1px solid #ea79d8;
42+ border-radius: 10px;
43+ background-color: #550f75;
44+ color: #ea79d8;
45+ transition: background-color 0.2s ease-in-out, font-size 0.3s ease-in-out;
46+}
47+
48+.menu-item.active:hover {
49+ background-color: #b421f8;
50+}
51+
52+.container {
53+ max-width: 1280px;
54+ margin: auto;
55+ padding-bottom: 10px;
56+}
57+
58+h1.paragraph, p.paragraph {
59+ text-align: left;
60+}
61+
62+footer {
63+ text-align: center;
64+}
65+
66+header, main, footer, .sns:hover {
67+ border-radius: 2px;
68+}
69+
70+@media screen and (max-width: 768px) {
71+ header, main, footer {
72+ margin: 10px;
73+ padding: 5px;
74+ }
75+
76+ .logo {
77+ font-size: 28px;
78+ }
79+
80+ nav {
81+ display: flex;
82+ flex-wrap: wrap;
83+ justify-content: center;
84+ gap: 5px;
85+ }
86+}
87+
88+@media screen and (max-width: 480px) {
89+ header, main, footer {
90+ margin: 5px;
91+ padding: 5px;
92+ }
93+
94+ .logo {
95+ font-size: 24px;
96+ }
97+}
追加 route.php
@@ -0,0 +1,66 @@
1+<?php
2+/* error_reporting(E_ALL); */
3+/* ini_set('display_errors', 1); */
4+
5+require_once __DIR__.DIRECTORY_SEPARATOR.'/autoload.php';
6+
7+use Site\Lib\Route;
8+
9+use Site\Controller\Atom;
10+use Site\Controller\Fediverse;
11+use Site\Controller\Home;
12+use Site\Controller\Notfound;
13+use Site\Controller\Page;
14+
15+define('ROOT', realpath(__DIR__));
16+
17+include(ROOT.'/config/config.php');
18+include(ROOT.'/util.php');
19+
20+$routes = [
21+ Route::add('GET', 'blog/{page}', Home::class.'@article'),
22+ Route::add('GET', 'about', Page::class.'@about'),
23+ Route::add('GET', 'monero', Page::class.'@monero'),
24+ Route::add('GET', 'secret', Page::class.'@secret'),
25+];
26+
27+if (ACTIVITYPUB_ENABLED) {
28+ $routes[] = Route::add('GET', '.well-known/webfinger', Home::class.'@apfinger');
29+ $routes[] = Route::add('GET', 'ap/following', Fediverse::class.'@apfollowing');
30+ $routes[] = Route::add('GET', 'ap/followers', Fediverse::class.'@apfollowers');
31+ $routes[] = Route::add('GET', 'ap/outbox', Fediverse::class.'@apoutbox');
32+ $routes[] = Route::add('GET', 'ap/activities/create/{uuid}',
33+ Fediverse::class.'@apactivity');
34+ $routes[] = Route::add('POST', 'ap/inbox', Fediverse::class.'@apinbox');
35+ $routes[] = Route::add('GET', 'ap/actor', Fediverse::class.'@apactor');
36+}
37+
38+/* if (RSS_ENABLED) {} */
39+
40+if (ATOM_ENABLED) {
41+ $routes[] = Route::add('GET', 'blog.atom', Atom::class.'@feed');
42+}
43+
44+$routes[] = Route::add('GET', '', Home::class.'@show');
45+
46+Route::init($routes);
47+Route::setFallback([new Notfound(), 'show']);
48+
49+$uri = urldecode($_SERVER['REQUEST_URI']);
50+
51+// .xhtmlで終わるURLのリダイレクト処理
52+if (preg_match('/(.+)\.xhtml$/', $uri, $matches)) {
53+ // 末尾の.xhtmlを取り除く
54+ $newUri = $matches[1];
55+ header('HTTP/1.1 301 Moved Permanently');
56+ header('Location: ' . $newUri);
57+ exit();
58+}
59+
60+$uri = urldecode($_SERVER['REQUEST_URI']);
61+if ($uri == '/index.php' || $uri == '/index.html') {
62+ header('Location: /');
63+ exit();
64+}
65+
66+Route::dispatch($uri);
@@ -0,0 +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+}
@@ -0,0 +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+}
@@ -0,0 +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+}
@@ -0,0 +1,252 @@
1+<?php
2+namespace Site\Controller;
3+
4+use Site\Controller\BlogPost;
5+use Site\Controller\Mods;
6+use Site\Lib\Activitypub;
7+use Site\Lib\Markdown;
8+use Site\Lib\Template;
9+
10+class Home extends BlogPost {
11+ use Mods;
12+
13+ private array $searchKeywords = [];
14+ private string $domain = 'technicalsuwako.moe';
15+
16+ //------------------------------------------
17+ // ページ
18+ //------------------------------------------
19+
20+ /**
21+ * ブログ投稿ページ
22+ *
23+ * @param array $params ページ番号等
24+ * @return void
25+ */
26+ public function show(array $params): void {
27+ try {
28+ $page = isset($_GET['page']) ? $_GET['page'] : 1;
29+ $postsPerPage = 20;
30+
31+ $tmpl = new Template('/');
32+ $pagetit = 'トップページ';
33+
34+ $description = 'テクニカル諏訪子ちゃんの個人ブログ';
35+
36+ $posts = $this->getPosts('/blog/');
37+ if (!is_array($posts)) $posts = [];
38+
39+ // 検索機能が使用されている場合
40+ if (isset($_GET['q']) && !empty($_GET['q'])) {
41+ $this->searchKeywords = array_map('trim', explode(',', $_GET['q']));
42+ $posts = $this->searchPosts($this->searchKeywords, $posts, '/blog/');
43+ $pagetit = '検索結果: '.htmlspecialchars($_GET['q']);
44+
45+ // 検索結果にキーワードをハイライト
46+ $posts = $this->highlightKeywords($posts);
47+ }
48+
49+ // ページネーション
50+ $totalPosts = count($posts);
51+ $totalPages = ceil($totalPosts / $postsPerPage);
52+ $page = min($page, $totalPages);
53+ $currentPosts = array_slice(
54+ $posts,
55+ ($page - 1) * $postsPerPage,
56+ $postsPerPage
57+ );
58+
59+ $tmpl->assign('currentPage', $page);
60+ $tmpl->assign('totalPages', $totalPages);
61+ $tmpl->assign('posts', $currentPosts);
62+ $tmpl->assign('pagetit', $pagetit);
63+ $tmpl->assign('curblog', 'gd');
64+ $tmpl->assign('section', 'blog');
65+ $tmpl->assign('curPage', 'blog');
66+ $tmpl->assign('custCss', false);
67+ $tmpl->assign('menu', $this->getMenu());
68+ $tmpl->assign('description', $description);
69+ $tmpl->assign('searchActive', !empty($this->searchKeywords));
70+
71+ $tmpl->addCss('news');
72+ $tmpl->addCss('search');
73+ $tmpl->addCss('blogtype');
74+ $tmpl->addCss('pagination');
75+ $tmpl->render('home');
76+ } catch (\Exception $e) {
77+ throw new \Exception($e->getMessage());
78+ }
79+ }
80+
81+ /**
82+ * ブログ投稿ページ
83+ *
84+ * @param array $params マークダウンファイル等
85+ * @return void
86+ */
87+ public function article(array $params): void {
88+ $page = '';
89+ if (isset($params['page'])) $page = $params['page'];
90+
91+ try {
92+ $tmpl = new Template('/');
93+ $md = new Markdown($page, '/blog/');
94+
95+ $meta = $md->getMetadata();
96+ $pagetit = $meta->title;
97+ $article = $md->parse();
98+ $description = 'テクニカル諏訪子ちゃんの個人ブログ';
99+
100+ // 検索からの遷移の場合、記事内のキーワードをハイライト
101+ if (isset($_GET['q']) && !empty($_GET['q'])) {
102+ $keywords = array_map('trim', explode(',', $_GET['q']));
103+ $article = $this->highlightTextContent($article, $keywords);
104+ $meta->title = $this->highlightTextContent($meta->title, $keywords);
105+ }
106+
107+ $tmpl->assign('pagetit', $pagetit);
108+ $tmpl->assign('curPage', 'blog');
109+ $tmpl->assign('custCss', false);
110+ $tmpl->assign('curblog', 'gd');
111+ $tmpl->assign('menu', $this->getMenu());
112+ $tmpl->assign('article', $article);
113+ $tmpl->assign('meta', $meta);
114+ $tmpl->assign('description', $description);
115+
116+ if (isset($meta->css) && !empty($meta->css)) {
117+ foreach ($meta->css as $v) {
118+ $tmpl->addCss($v);
119+ }
120+ }
121+
122+ $tmpl->addCss('news-article');
123+ $tmpl->addCss('search');
124+ $tmpl->addCss('blogtype');
125+ $tmpl->render('article');
126+ } catch (\Exception $e) {
127+ throw new \Exception($e->getMessage());
128+ }
129+ }
130+
131+ //------------------------------------------
132+ // 機能性
133+ //------------------------------------------
134+
135+ /**
136+ * キーワードに基づいて投稿を検索する
137+ *
138+ * @param array $keywords 検索キーワードの配列
139+ * @param array $posts 検索対象の投稿記事の配列
140+ * @return array 検索条件に一致する投稿記事の配列
141+ */
142+ private function searchPosts(array $keywords, array $posts, string $section): array {
143+ if (empty($keywords) || empty($posts)) {
144+ return $posts;
145+ }
146+
147+ $foundPosts = [];
148+ $path = ROOT.$section;
149+
150+ foreach ($posts as $post) {
151+ $matched = false;
152+
153+ // タイトルで検索
154+ foreach ($keywords as $keyword) {
155+ $keyword = trim($keyword);
156+ if (empty($keyword)) continue;
157+
158+ // タイトル内でキーワードが見つかった場合
159+ if (mb_stripos($post['title'], $keyword) !== false) {
160+ $foundPosts[] = $post;
161+ $matched = true;
162+ break;
163+ }
164+ }
165+
166+ // すでにマッチしていれば次の記事へ
167+ if ($matched) continue;
168+
169+ // 記事の本文をチェック
170+ $slug = $post['slug'];
171+ $filePath = $path.$slug.'.md';
172+
173+ if (file_exists($filePath)) {
174+ $content = file_get_contents($filePath);
175+ $parts = explode('----', $content, 2);
176+ if (count($parts) > 1) {
177+ $articleBody = trim($parts[1]);
178+
179+ foreach ($keywords as $keyword) {
180+ $keyword = trim($keyword);
181+ if (empty($keyword)) continue;
182+
183+ // 本文内でキーワードが見つかった場合
184+ if (mb_stripos($articleBody, $keyword) !== false) {
185+ $foundPosts[] = $post;
186+ break;
187+ }
188+ }
189+ }
190+ }
191+ }
192+
193+ return $foundPosts;
194+ }
195+
196+ /**
197+ * 検索結果の投稿内のキーワードをハイライトする
198+ *
199+ * @param array $posts 検索結果の投稿配列
200+ * @return array ハイライト処理後の投稿配列
201+ */
202+ private function highlightKeywords(array $posts): array {
203+ if (empty($this->searchKeywords) || empty($posts)) {
204+ return $posts;
205+ }
206+
207+ foreach ($posts as &$post) {
208+ // タイトルのハイライト
209+ if (!empty($post['title'])) {
210+ $post['title'] =
211+ $this->highlightTextContent($post['title'], $this->searchKeywords);
212+ }
213+
214+ // プレビューのハイライト
215+ if (!empty($post['preview'])) {
216+ $post['preview'] =
217+ $this->highlightTextContent($post['preview'], $this->searchKeywords);
218+ }
219+ }
220+
221+ return $posts;
222+ }
223+
224+ /**
225+ * テキスト内のキーワードをハイライトする
226+ *
227+ * @param string $text ハイライト対象のテキスト
228+ * @param array $keywords ハイライトするキーワード配列
229+ * @return string ハイライト処理後のテキスト
230+ */
231+ private function highlightTextContent(string $text, array $keywords): string {
232+ if (empty($keywords) || empty($text)) {
233+ return $text;
234+ }
235+
236+ $highlightedText = $text;
237+
238+ foreach ($keywords as $keyword) {
239+ $keyword = trim($keyword);
240+ if (empty($keyword)) continue;
241+
242+ // キーワードを大文字小文字を区別せずに置換
243+ $highlightedText = preg_replace(
244+ '/('.preg_quote($keyword, '/').')/iu',
245+ '<span class="search-highlight">$1</span>',
246+ $highlightedText
247+ );
248+ }
249+
250+ return $highlightedText;
251+ }
252+}
@@ -0,0 +1,38 @@
1+<?php
2+namespace Site\Controller;
3+
4+trait Mods {
5+ public function getMenu(): array {
6+ return [
7+ [
8+ 'class' => 'menu-item',
9+ 'href' => '/',
10+ 'page' => 'blog',
11+ 'text' => 'トップ',
12+ 'show' => true,
13+ ],
14+ [
15+ 'class' => 'menu-item',
16+ 'href' => '/about',
17+ 'page' => 'about',
18+ 'text' => '自己紹介',
19+ 'show' => true,
20+ ],
21+ [
22+ 'class' => 'menu-item',
23+ 'href' => '/monero',
24+ 'page' => 'monero',
25+ 'text' => '支援♡',
26+ 'show' => true,
27+ ],
28+ [
29+ 'class' => 'menu-item',
30+ 'href' => '/secret',
31+ 'page' => 'secret',
32+ 'text' => '秘密のページ',
33+ 'show' => false,
34+ ],
35+ ];
36+ }
37+}
38+?>
@@ -0,0 +1,26 @@
1+<?php
2+namespace Site\Controller;
3+
4+use Site\Lib\Template;
5+use Site\Controller\Mods;
6+
7+class Notfound {
8+ use Mods;
9+
10+ public function show(): void {
11+ try {
12+ $tmpl = new Template('/');
13+ $pagetit = 'Not found';
14+
15+ $tmpl->assign('pagetit', $pagetit);
16+ $tmpl->assign('curPage', '404');
17+ $tmpl->assign('menu', $this->getMenu());
18+ $tmpl->assign('description', '');
19+
20+ $tmpl->render('404');
21+ } catch (\Exception $e) {
22+ throw new \Exception($e->getMessage());
23+ }
24+ }
25+}
26+?>
@@ -0,0 +1,65 @@
1+<?php
2+namespace Site\Controller;
3+
4+use Site\Controller\Mods;
5+use Site\Lib\Template;
6+
7+class Page {
8+ use Mods;
9+
10+ public function about(array $params): void {
11+ try {
12+ $tmpl = new Template('/');
13+ $pagetit = '新ページ';
14+ $description = 'PHPフレームワークについて';
15+
16+ $tmpl->assign('pagetit', $pagetit);
17+ $tmpl->assign('curPage', 'about');
18+ $tmpl->assign('custCss', false);
19+ $tmpl->assign('menu', $this->getMenu());
20+ $tmpl->assign('description', $description);
21+
22+ $tmpl->render('about');
23+ } catch (\Exception $e) {
24+ throw new \Exception($e->getMessage());
25+ }
26+ }
27+
28+ public function monero(array $params): void {
29+ try {
30+ $tmpl = new Template('/');
31+ $pagetit = 'モネロ(XMR)で支援♡';
32+ $description = 'テクニカル諏訪子ちゃんをモネロで支援♡';
33+
34+ $tmpl->assign('pagetit', $pagetit);
35+ $tmpl->assign('curPage', 'support');
36+ $tmpl->assign('custCss', true);
37+ $tmpl->assign('menu', $this->getMenu());
38+ $tmpl->assign('description', $description);
39+
40+ $tmpl->addCss('code');
41+ $tmpl->render('monero');
42+ } catch (\Exception $e) {
43+ throw new \Exception($e->getMessage());
44+ }
45+ }
46+
47+ public function secret(array $params): void {
48+ try {
49+ $tmpl = new Template('/');
50+ $pagetit = '秘密のページ';
51+ $description = 'ケロ';
52+
53+ $tmpl->assign('pagetit', $pagetit);
54+ $tmpl->assign('curPage', 'support');
55+ $tmpl->assign('custCss', false);
56+ $tmpl->assign('menu', $this->getMenu());
57+ $tmpl->assign('description', $description);
58+
59+ $tmpl->render('secret');
60+ } catch (\Exception $e) {
61+ throw new \Exception($e->getMessage());
62+ }
63+ }
64+}
65+?>
@@ -0,0 +1,558 @@
1+<?php
2+namespace Site\Lib;
3+
4+use Site\Lib\Curl;
5+
6+/**
7+ * ActivityPubプロトコルの実装クラス
8+ *
9+ * このクラスはActivityPubプロトコルを利用して分散型
10+ * ソーシャルネットワーキングを実装します。
11+ */
12+class ActivityPub {
13+ private string $domain;
14+ private string $actor;
15+ private string $actorNick;
16+ private string $desc;
17+ private string $icon;
18+ private array $posts = [];
19+
20+ /**
21+ * コンストラクタ
22+ *
23+ * @param array $posts 投稿データの配列
24+ */
25+ public function __construct(array $posts = []) {
26+ $this->domain = $_SERVER['SERVER_NAME'];
27+ $this->actor = FEDIINFO['actor'];
28+ $this->actorNick = FEDIINFO['actorNick'];
29+ $this->desc = FEDIINFO['desc'];
30+ $this->icon = "https://{$this->domain}".FEDIINFO['icon'];
31+ $this->posts = $posts;
32+ }
33+
34+ /**
35+ * ActivityPubアクタープロフィールを受け取る
36+ *
37+ * @return string アクターオブジェクト
38+ * @throws \Exception 公開鍵の読み込みに失敗した場合
39+ */
40+ public function getActor(): string {
41+ $pubkey = file_get_contents(FEDIINFO['pubkey']);
42+ if ($pubkey === false) {
43+ throw new \Exception('公開鍵の受取に失敗。パス:'.FEDIINFO['pubkey']);
44+ }
45+
46+ $actor = [
47+ '@context' => [
48+ 'https://www.w3.org/ns/activitystreams',
49+ 'https://w3id.org/security/v1',
50+ ],
51+ 'id' => "https://{$this->domain}/ap/actor",
52+ 'name' => $this->actorNick,
53+ 'summary' => $this->desc,
54+ 'manuallyApprovesFollowers' => false,
55+ 'icon' => [
56+ 'type' => 'Image',
57+ 'mediaType' => 'image/png',
58+ 'url' => $this->icon,
59+ ],
60+ 'image' => [
61+ 'type' => 'Image',
62+ 'url' =>
63+ "https://{$this->domain}/static/article/o_53803618dc1691.28179609.jpg",
64+ 'mediaType' => 'image/jpeg',
65+ ],
66+ 'type' => 'Person',
67+ 'url' => "https://{$this->domain}",
68+ 'preferredUsername' => $this->actor,
69+ 'inbox' => "https://{$this->domain}/ap/inbox",
70+ 'outbox' => "https://{$this->domain}/ap/outbox",
71+ 'followers' => "https://{$this->domain}/ap/followers",
72+ 'following' => "https://{$this->domain}/ap/following",
73+ 'published' => '2025-03-28T18:00:00Z',
74+ 'updated' => gmdate('c'),
75+ 'publicKey' => [
76+ 'id' => "https://{$this->domain}/ap/actor#main-key",
77+ 'owner' => "https://{$this->domain}/ap/actor",
78+ 'publicKeyPem' => $pubkey,
79+ ],
80+ ];
81+
82+ return json_encode($actor, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
83+ }
84+
85+ /**
86+ * 特定のUUIDに対応するActivityを取得する
87+ *
88+ * @param string $uuid 取得するアクティビティのUUID
89+ * @return string JSONエンコードされたアクティビティデータ
90+ */
91+ public function getActivity(string $uuid): string {
92+ $items = [];
93+
94+ foreach ($this->posts as $post) {
95+ if ($post['uuid'] != $uuid) continue;
96+
97+ $items = [
98+ '@context' => [
99+ 'https://www.w3.org/ns/activitystreams',
100+ 'https://w3id.org/security/v1',
101+ [
102+ 'Emoji' => 'toot:Emoji',
103+ 'EmojiReact' => 'litepub:EmojiReact',
104+ 'Hashtag' => 'as:Hashtag',
105+ 'litepub' => 'http://litepub.social/ns#',
106+ 'sensitive' => 'as:sensitive',
107+ 'toot' => 'http://joinmastodon.org/ns#',
108+ ],
109+ ],
110+ 'id' => "https://{$this->domain}/ap/activities/create/{$post['uuid']}",
111+ 'type' => 'Create',
112+ 'actor' => "https://{$this->domain}/ap/actor",
113+ 'cc' => [
114+ "https://{$this->domain}/ap/followers",
115+ ],
116+ 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
117+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
118+ 'object' => [
119+ 'id' => "https://{$this->domain}/ap/objects/{$post['uuid']}",
120+ 'type' => 'Note',
121+ 'name' => $post['title'],
122+ 'attributedTo' => "https://{$this->domain}/ap/actor",
123+ 'cc' => [
124+ "https://{$this->domain}/ap/followers",
125+ ],
126+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
127+ 'content' =>
128+ $post['preview']."<br /><br /><a href=\"https://{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
129+ 'url' => "https://{$this->domain}/blog/{$post['slug']}",
130+ 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
131+ 'replies' => "https://{$this->domain}/ap/objects/{$uuid}/replies",
132+ 'sensitive' => false,
133+ ],
134+ ];
135+
136+ if (isset($post['category']) && !empty($post['category'])) {
137+ $item['tag'] = [];
138+ foreach ($post['category'] as $cat) {
139+ $items['tag'][] = $cat;
140+ }
141+ }
142+
143+ if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
144+ $imgurl = "https://technicalsuwako.moe/static/article/{$post['thumbnail']}";
145+ $imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
146+ $imgraw = file_get_contents($imgpath);
147+
148+ $items['attachment'] = [
149+ [
150+ 'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
151+ 'mediaType' => mime_content_type($imgpath),
152+ 'type' => "Image",
153+ 'url' => $imgurl,
154+ ],
155+ ];
156+ }
157+
158+ break;
159+ }
160+
161+ return json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
162+ }
163+
164+ /**
165+ * アウトボックスデータを取得する
166+ *
167+ * @return string JSONエンコードされたアウトボックスデータ
168+ */
169+ public function getOutbox(): string {
170+ $items = [];
171+ $counter = 0;
172+
173+ foreach ($this->posts as $post) {
174+ $uid = $post['uuid'];
175+
176+ $items[] = [
177+ '@context' => [
178+ 'https://www.w3.org/ns/activitystreams',
179+ 'https://w3id.org/security/v1',
180+ [
181+ 'Emoji' => 'toot:Emoji',
182+ 'EmojiReact' => 'litepub:EmojiReact',
183+ 'Hashtag' => 'as:Hashtag',
184+ 'litepub' => 'http://litepub.social/ns#',
185+ 'sensitive' => 'as:sensitive',
186+ 'toot' => 'http://joinmastodon.org/ns#',
187+ ],
188+ ],
189+ 'id' => "https://{$this->domain}/ap/activities/create/{$uid}",
190+ 'type' => 'Create',
191+ 'actor' => "https://{$this->domain}/ap/actor",
192+ 'cc' => [
193+ "https://{$this->domain}/ap/followers",
194+ ],
195+ 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
196+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
197+ 'object' => [
198+ 'id' => "https://{$this->domain}/ap/objects/{$uid}",
199+ 'type' => 'Note',
200+ 'name' => $post['title'],
201+ 'attributedTo' => "https://{$this->domain}/ap/actor",
202+ 'cc' => [
203+ "https://{$this->domain}/ap/followers",
204+ ],
205+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
206+ 'content' =>
207+ $post['preview']."<br /><br /><a href=\"https://{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
208+ 'url' => "https://{$this->domain}/blog/{$post['slug']}",
209+ 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
210+ 'replies' => "https://{$this->domain}/ap/objects/{$uid}/replies",
211+ 'sensitive' => false,
212+ ],
213+ ];
214+
215+ if (isset($post['category']) && !empty($post['category'])) {
216+ $items[$counter]['tag'] = [];
217+ foreach ($post['category'] as $cat) {
218+ $items[$counter]['tag'][] = $cat;
219+ }
220+ }
221+
222+ if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
223+ $imgurl = "https://technicalsuwako.moe/static/article/{$post['thumbnail']}";
224+ $imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
225+ $imgraw = file_get_contents($imgpath);
226+
227+ $items[$counter]['attachment'] = [
228+ [
229+ 'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
230+ 'mediaType' => mime_content_type($imgpath),
231+ 'type' => "Image",
232+ 'url' => $imgurl,
233+ ],
234+ ];
235+ }
236+
237+ $counter++;
238+ }
239+
240+ $outbox = [
241+ '@context' => [
242+ 'https://www.w3.org/ns/activitystreams',
243+ 'https://w3id.org/security/v1',
244+ ],
245+ 'id' => "https://{$this->domain}/ap/outbox",
246+ 'type' => 'OrderedCollection',
247+ 'totalItems' => count($items),
248+ 'orderedItems' => $items,
249+ ];
250+
251+ return json_encode($outbox, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
252+ }
253+
254+ /**
255+ * WebFingerデータを取得する
256+ *
257+ * @return string JSONエンコードされたWebFingerデータ
258+ */
259+ public function getWebfinger(): string {
260+ $webfinger = [
261+ 'subject' => "acct:{$this->actor}@{$this->domain}",
262+ 'links' => [
263+ [
264+ 'rel' => 'self',
265+ 'type' => 'application/activity+json',
266+ 'href' => "https://{$this->domain}/ap/actor",
267+ ],
268+ ],
269+ ];
270+
271+ return json_encode($webfinger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
272+ }
273+
274+ /**
275+ * フォロワーのリストを取得する
276+ *
277+ * @return string JSONエンコードされたフォロワーのリスト
278+ */
279+ public function getFollowers(): string {
280+ $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
281+
282+ $followers = [
283+ '@context' => [
284+ 'https://www.w3.org/ns/activitystreams',
285+ 'https://w3id.org/security/v1',
286+ ],
287+ 'id' => "https://{$this->domain}/ap/followers",
288+ 'type' => 'OrderredCollection',
289+ 'totalItems' => count($f),
290+ 'orderedItems' => $f,
291+ ];
292+
293+ return json_encode($followers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
294+ }
295+
296+ /**
297+ * フォローしているアカウントのリストを取得する
298+ *
299+ * @return string JSONエンコードされたフォローリスト
300+ */
301+ public function getFollowing(): string {
302+ $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/following.txt')));
303+
304+ $following = [
305+ '@context' => [
306+ 'https://www.w3.org/ns/activitystreams',
307+ 'https://w3id.org/security/v1',
308+ ],
309+ 'id' => "https://{$this->domain}/ap/following",
310+ 'type' => 'OrderredCollection',
311+ 'totalItems' => count($f),
312+ 'orderedItems' => $f,
313+ ];
314+
315+ return json_encode($following, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
316+ }
317+
318+ /**
319+ * インボックスにアクティビティを投稿する
320+ *
321+ * @param array $activity 処理するアクティビティデータ
322+ * @return void
323+ */
324+ public function postInbox(array $activity): void {
325+ switch ($activity['type']) {
326+ case 'Follow':
327+ $this->acceptFollower($activity);
328+ break;
329+ default:
330+ header('HTTP/1.1 501 Not Implemented');
331+ header('Content-Type: application/activity+json');
332+ echo json_encode(['error' =>
333+ '未対応なアクティビティタイプ: '.$activity['type']]);
334+ exit;
335+ }
336+
337+ header('HTTP/1.1 200 OK');
338+ header('Content-Type: application/activity+json');
339+ echo json_encode(['status' => 'OK'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
340+ exit;
341+ }
342+
343+ /**
344+ * アクタープロフィールの更新アクティビティを作成する
345+ *
346+ * @return string JSONエンコードされた更新アクティビティ
347+ */
348+ public function update(): string {
349+ $update = [
350+ '@context' => [
351+ 'https://www.w3.org/ns/activitystreams',
352+ 'https://w3id.org/security/v1',
353+ ],
354+ 'id' => "https://{$this->domain}/ap/activities/update/".uuid(),
355+ 'type' => 'Update',
356+ 'actor' => "https://{$this->domain}/ap/actor",
357+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
358+ 'object' => json_decode($this->getActor(), true),
359+ ];
360+
361+ return json_encode($update, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
362+ }
363+
364+ /**
365+ * アクター更新をフォロワーに送信する
366+ *
367+ * @param array $params パラメータ配列
368+ * @return void
369+ */
370+ public function sendActorUpdate(array $params): void {
371+ $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
372+ $ap = new Activitypub();
373+ $inboxes = implode("\n", $f);
374+ $update = json_decode($ap->update(), true);
375+
376+ foreach ($f as $inbox) {
377+ $this->sendActivity($inbox, $update);
378+ }
379+ }
380+
381+ // 機能性メソッド
382+
383+ /**
384+ * 指定されたインボックスURLにアクティビティを送信する
385+ *
386+ * @param string $inboxUrl 送信先のインボックスURL
387+ * @param array $activity 送信するアクティビティデータ
388+ * @return void
389+ */
390+ private function sendActivity(string $inboxUrl, array $activity): void {
391+ $privFile = FEDIINFO['privkey'];
392+ $priv = file_get_contents($privFile);
393+ if ($priv === false) {
394+ logger(\LogType::ActivityPub, "エラー:秘密鍵「{$privFile}」の読込に失敗");
395+ header('HTTP/1.1 500 Internal Server Error');
396+ header('Content-Type: application/activity+json');
397+ echo json_encode(['error' => '秘密鍵の読込に失敗']);
398+ exit;
399+ }
400+
401+ $body = json_encode($activity, JSON_UNESCAPED_SLASHES);
402+ $digest = base64_encode(hash('sha256', $body, true));
403+ $date = gmdate('D, d M Y H:i:s \G\M\T');
404+ $host = parse_url($inboxUrl, PHP_URL_HOST);
405+
406+ $headers = [
407+ 'Host' => $host,
408+ 'Date' => $date,
409+ 'Content-Type' => 'application/activity+json',
410+ 'Digest' => "SHA-256=$digest",
411+ ];
412+
413+ $stringToSign = "host: {$headers['Host']}\n"
414+ ."date: {$headers['Date']}\n"
415+ ."digest: {$headers['Digest']}";
416+ logger(\LogType::ActivityPub, "署名対象: {$stringToSign}");
417+
418+ if (!openssl_sign($stringToSign, $signature, $priv, OPENSSL_ALGO_SHA256)) {
419+ $error = openssl_error_string();
420+ logger(\LogType::ActivityPub, "エラー:署名に失敗: {$error}");
421+ header('HTTP/1.1 500 Internal Server Error');
422+ header('Content-Type: application/activity+json');
423+ echo json_encode(['error' => '署名に失敗']);
424+ exit;
425+ }
426+
427+ $sigValue = base64_encode($signature);
428+ $headers['Signature'] = "keyId=\"https://{$this->domain}/ap/actor#main-key\",";
429+ $headers['Signature'] .= 'algorithm="rsa-sha256",';
430+ $headers['Signature'] .= 'headers="host date digest",';
431+ $headers['Signature'] .= 'signature="'.$sigValue.'"';
432+ logger(\LogType::ActivityPub,
433+ "署名: {$headers['Signature']}\n送信データ: {$body}");
434+
435+ $curl = new Curl($inboxUrl);
436+ $curl->setMethod('POST')
437+ ->setPostRaw($body)
438+ ->setHeaders(array_map(fn($k, $v) => "$k: $v",
439+ array_keys($headers), $headers))
440+ ->setCaInfo('/etc/ssl/cert.pem')
441+ ->setVerbose(true)
442+ ->setStderr(fopen(ROOT.'/log/ap_log.txt', 'a'));
443+
444+ $success = $curl->execute();
445+ $res = $curl->getResponseBody();
446+ $code = $curl->getResponseCode();
447+ $err = $curl->getError();
448+
449+ var_dump(print_r($res));
450+ logger(\LogType::ActivityPub,
451+ "アクティビティは「{$inboxUrl}」に送信しました: HTTP {$code}");
452+ logger(\LogType::ActivityPub, "エラー: {$err}");
453+ logger(\LogType::ActivityPub, "レスポンス: {$res}");
454+ }
455+
456+ /**
457+ * フォロワーを受け入れる
458+ *
459+ * @param array $activity フォローアクティビティデータ
460+ * @return void
461+ */
462+ private function acceptFollower(array $activity): void {
463+ $followerActor = $activity['actor'] ?? null;
464+ if (!$followerActor) {
465+ header('HTTP/1.1 400 Bad Request');
466+ header('Content-Type: application/activity+json');
467+ echo json_encode(['error' => 'アクターがない']);
468+ exit;
469+ }
470+
471+ $this->storeFollower($followerActor);
472+
473+ $inbox = $this->getInboxFromActor($followerActor);
474+ if (!$inbox) {
475+ header('HTTP/1.1 500 Internal Server Error');
476+ header('Content-Type: application/activity+json');
477+ echo json_encode(['error' => 'フォロワーの受付ボックスの受取に失敗']);
478+ exit;
479+ }
480+
481+ $accept = [
482+ '@context' => [
483+ 'https://www.w3.org/ns/activitystreams',
484+ 'https://w3id.org/security/v1',
485+ ],
486+ 'id' => "https://{$this->domain}/ap/activities/".uniqid(),
487+ 'type' => 'Accept',
488+ 'actor' => "https://{$this->domain}/ap/actor",
489+ 'object' => $activity,
490+ ];
491+
492+ $this->sendActivity($inbox, $accept);
493+ }
494+
495+ /**
496+ * フォロワーをストレージに保存する
497+ *
498+ * @param string $followerActor フォロワーのアクターURL
499+ * @return void
500+ */
501+ private function storeFollower(string $followerActor): void {
502+ $file = ROOT.'/data/followers.txt';
503+ if (!file_exists($file)) {
504+ touch($file);
505+ chmod($file, 0644);
506+ }
507+
508+ $followers = $this->getFollowersList();
509+ if (!in_array($followerActor, $followers)) {
510+ file_put_contents($file, "$followerActor\n", FILE_APPEND);
511+ }
512+ }
513+
514+ /**
515+ * フォロワーのリストを配列として取得する
516+ *
517+ * @return array フォロワーのURLの配列
518+ */
519+ private function getFollowersList(): array {
520+ $file = ROOT.'/data/followers.txt';
521+ $f = array_filter(explode("\n", file_get_contents($file)));
522+ return file_exists($file)
523+ ? array_filter(explode("\n", file_get_contents($file))) : [];
524+ }
525+
526+ /**
527+ * アクターのインボックスURLを取得する
528+ *
529+ * @param string $actor アクターのURL
530+ * @return string|null インボックスURL、取得に失敗した場合はnull
531+ */
532+ private function getInboxFromActor(string $actor): ?string {
533+ $curl = new Curl($actor);
534+ $curl->setHeaders(['Accept: application/activity+json'])
535+ ->setFollowRedirects(true)
536+ ->setMaxRedirects(5)
537+ ->setCaInfo('/etc/ssl/cert.pem');
538+
539+ logger(\LogType::ActivityPub, "アクターURLにリクエスト: {$actor}");
540+ $success = $curl->execute();
541+ if (!$success) {
542+ logger(\LogType::ActivityPub, "アクターリクエストに失敗: ".$curl->getError());
543+ return null;
544+ }
545+
546+ $res = $curl->getResponseBody();
547+ $code = $curl->getResponseCode();
548+ $err = $curl->getError();
549+
550+ if ($code !== 200) {
551+ logger(\LogType::ActivityPub, "アクター取得に失敗: HTTP {$code}, エラー: {$err}");
552+ return null;
553+ }
554+
555+ $data = json_decode($res, true);
556+ return $data['inbox'] ?? null;
557+ }
558+}
@@ -0,0 +1,85 @@
1+<?php
2+namespace Site\Lib;
3+
4+enum Delimiter: int {
5+ case COMMA = 1;
6+ case SEMICOLON = 2;
7+ case TAB = 3;
8+ case PIPE = 4;
9+
10+ public static function default(): self {
11+ return self::COMMA;
12+ }
13+
14+ public function getChar(): string {
15+ return match ($this) {
16+ self::COMMA => ",",
17+ self::SEMICOLON => ";",
18+ self::TAB => "\t",
19+ self::PIPE => "|",
20+ };
21+ }
22+}
23+
24+/**
25+ * CSVパーシングクラス
26+ */
27+class Csv {
28+ // リクエスト関連のプロパティ
29+ private bool $isHeader = false;
30+ private int $length = 8192;
31+ private Delimiter $delimiter;
32+ private string $filename;
33+ private $fp;
34+
35+ public function __construct(string $filename, int $length = 8192) {
36+ $this->length = $length;
37+ $this->filename = $filename;
38+ $this->delimiter = Delimiter::default();
39+
40+ $this->fp = fopen($this->filename, 'r');
41+ if ($this->fp === false) {
42+ $msg = "ファイルを開けられません。";
43+ logger(\LogType::Csv, $msg);
44+ throw new \Exception($msg);
45+ }
46+ }
47+
48+ public function __destruct() {
49+ if ($this->fp !== false) {
50+ fclose($this->fp);
51+ }
52+ }
53+
54+ public function parse(?Delimiter $delimiter = null, bool $isHeader = false): array {
55+ $res = [];
56+ $this->isHeader = $isHeader;
57+ $this->delimiter = $delimiter ?? $this->delimiter;
58+
59+ $delimChar = $this->delimiter->getChar();
60+
61+ rewind($this->fp);
62+
63+ if ($this->isHeader) {
64+ $res = ['header' => [], 'body' => []];
65+ $head = fgets($this->fp, $this->length);
66+ if ($head !== false) {
67+ $res['header'] = str_getcsv($head, $delimChar);
68+ }
69+ }
70+
71+ while (($buffer = fgets($this->fp, $this->length)) !== false) {
72+ $row = str_getcsv($buffer, $delimChar);
73+ if ($this->isHeader) $res['body'][] = $row;
74+ else $res[] = $row;
75+ }
76+
77+ if (!feof($this->fp)) {
78+ $msg = "エラー:fgets()の失敗";
79+ logger(\LogType::Csv, $msg);
80+ throw new \Exception($msg);
81+ }
82+
83+ return $res;
84+ }
85+}
@@ -0,0 +1,691 @@
1+<?php
2+namespace Site\Lib;
3+
4+/**
5+ * php_curlへの依存を排除するための独自のCURL実装
6+ */
7+class Curl {
8+ // リクエスト関連のプロパティ
9+ private string $url = '';
10+ private string $method = 'GET';
11+ private int $timeout = 30;
12+ private array $headers = [];
13+ private array $cookies = [];
14+ private array $postFields = [];
15+ private string $postRaw = '';
16+ private string $userAgent = 'LittleBeast/1.0';
17+ private bool $followRedirects = true;
18+ private int $maxRedirects = 5;
19+ private bool $verbose = false;
20+ private $stderr = null;
21+ private string $caInfoPath = '';
22+ private bool $verifySSL = true;
23+ private string $username = '';
24+ private string $password = '';
25+ private string $referer = '';
26+
27+ // レスポンス関連のプロパティ
28+ private array $responseHeaders = [];
29+ private string $responseBody = '';
30+ private int $responseCode = 0;
31+ private string $responseError = '';
32+ private array $info = [];
33+
34+ /**
35+ * コンストラクタ
36+ *
37+ * @param string|null $url リクエスト先のURL
38+ */
39+ public function __construct(?string $url = null) {
40+ if ($url !== null) {
41+ $this->url = $url;
42+ }
43+ }
44+
45+ /**
46+ * リクエスト先のURLを設定する
47+ *
48+ * @param string $url リクエスト先のURL
49+ * @return Curl このインスタンス(メソッドチェーン用)
50+ */
51+ public function setUrl(string $url): Curl {
52+ $this->url = $url;
53+ return $this;
54+ }
55+
56+ /**
57+ * リクエストメソッドを設定する
58+ *
59+ * @param string $method GE又はPOST等のHTTPメソッド
60+ * @return Curl このインスタンス(メソッドチェーン用)
61+ */
62+ public function setMethod(string $method): Curl {
63+ $this->method = strtoupper($method);
64+ return $this;
65+ }
66+
67+ /**
68+ * リクエストのタイムアウト秒数を設定する
69+ *
70+ * @param int $seconds タイムアウト秒数
71+ * @return Curl このインスタンス(メソッドチェーン用)
72+ */
73+ public function setTimeout(int $seconds): Curl {
74+ $this->timeout = (int)$seconds;
75+ return $this;
76+ }
77+
78+ /**
79+ * リクエストヘッダーを設定する
80+ *
81+ * @param array $headers リクエストヘッダーの配列
82+ * @return Curl このインスタンス(メソッドチェーン用)
83+ */
84+ public function setHeaders(array $headers): Curl {
85+ $this->headers = $headers;
86+ return $this;
87+ }
88+
89+ /**
90+ * 単一のヘッダーを追加する
91+ *
92+ * @param string $name ヘッダー名
93+ * @param mixed $value ヘッダー値
94+ * @return Curl このインスタンス(メソッドチェーン用)
95+ */
96+ public function addHeader(string $name, mixed $value): Curl {
97+ $this->headers[$name] = $value;
98+ return $this;
99+ }
100+
101+ /**
102+ * リクエストのクッキーを設定する
103+ *
104+ * @param array $cookies クッキーの配列
105+ * @return Curl このインスタンス(メソッドチェーン用)
106+ */
107+ public function setCookies(array $cookies): Curl {
108+ $this->cookies = $cookies;
109+ return $this;
110+ }
111+
112+ /**
113+ * 単一のクッキーを追加する
114+ *
115+ * @param string $name クッキー名
116+ * @param mixed $value クッキー値
117+ * @return Curl このインスタンス(メソッドチェーン用)
118+ */
119+ public function addCookie(string $name, mixed $value): Curl {
120+ $this->cookies[$name] = $value;
121+ return $this;
122+ }
123+
124+ /**
125+ * POSTフィールドを設定する
126+ *
127+ * @param array $fields POSTデータの配列
128+ * @return Curl このインスタンス(メソッドチェーン用)
129+ */
130+ public function setPostFields(array $fields): Curl {
131+ $this->postFields = $fields;
132+ return $this;
133+ }
134+
135+ /**
136+ * 生のPOSTデータを設定する
137+ *
138+ * @param string $data 生のPOSTデータ
139+ * @return Curl このインスタンス(メソッドチェーン用)
140+ */
141+ public function setPostRaw(string $data): Curl {
142+ $this->postRaw = $data;
143+ return $this;
144+ }
145+
146+ /**
147+ * ユーザーエージェントを設定する
148+ *
149+ * @param string $userAgent カスタムユーザーエージェント
150+ * @return Curl このインスタンス(メソッドチェーン用)
151+ */
152+ public function setUserAgent(string $userAgent): Curl {
153+ $this->userAgent = $userAgent;
154+ return $this;
155+ }
156+
157+ /**
158+ * リダイレクトを追跡するかどうかを設定する
159+ *
160+ * @param bool $follow 追跡するかどうか(デフォルトはtrue)
161+ * @return Curl このインスタンス(メソッドチェーン用)
162+ */
163+ public function setFollowRedirects(bool $follow): Curl {
164+ $this->followRedirects = (bool)$follow;
165+ return $this;
166+ }
167+
168+ /**
169+ * 追跡するリダイレクトの最大数を設定する
170+ *
171+ * @param int $max リダイレクトの最大数
172+ * @return Curl このインスタンス(メソッドチェーン用)
173+ */
174+ public function setMaxRedirects(int $max): Curl {
175+ $this->maxRedirects = (int)$max;
176+ return $this;
177+ }
178+
179+ /**
180+ * SSL証明書を検証するかどうかを設定する
181+ *
182+ * @param bool $verify SSL検証を行うかどうか(デフォルトはtrue)
183+ * @return Curl このインスタンス(メソッドチェーン用)
184+ */
185+ public function setVerifySSL(bool $verify): Curl {
186+ $this->verifySSL = (bool)$verify;
187+ return $this;
188+ }
189+
190+ /**
191+ * 基本認証の資格情報を設定する
192+ *
193+ * @param string $username ユーザー名
194+ * @param string $password パスワード
195+ * @return Curl このインスタンス(メソッドチェーン用)
196+ */
197+ public function setBasicAuth(string $username, string $password): Curl {
198+ $this->username = $username;
199+ $this->password = $password;
200+ return $this;
201+ }
202+
203+ /**
204+ * リファラーURLを設定する
205+ *
206+ * @param string $referer リファラーURL
207+ * @return Curl このインスタンス(メソッドチェーン用)
208+ */
209+ public function setReferer(string $referer): Curl {
210+ $this->referer = $referer;
211+ return $this;
212+ }
213+
214+ /**
215+ * 詳細ログを有効にする
216+ *
217+ * @param bool $verbose 詳細ログを有効にするかどうか
218+ * @return Curl このインスタンス(メソッドチェーン用)
219+ */
220+ public function setVerbose(bool $verbose): Curl {
221+ $this->verbose = (bool)$verbose;
222+ return $this;
223+ }
224+
225+ /**
226+ * エラー出力先を設定する
227+ *
228+ * @param resource $handle エラー出力先のファイルハンドル
229+ * @return Curl このインスタンス(メソッドチェーン用)
230+ */
231+ public function setStderr($handle): Curl {
232+ $this->stderr = $handle;
233+ return $this;
234+ }
235+
236+ /**
237+ * SSL証明書のファイルパスを設定する
238+ *
239+ * @param string $path 証明書ファイルのパス
240+ * @return Curl このインスタンス(メソッドチェーン用)
241+ */
242+ public function setCaInfo(string $path): Curl {
243+ $this->caInfoPath = $path;
244+ return $this;
245+ }
246+
247+ /**
248+ * リクエストを実行する
249+ *
250+ * @return bool 成功または失敗
251+ */
252+ public function execute(): bool {
253+ if (empty($this->url)) {
254+ $this->responseError = 'URLがありません';
255+ return false;
256+ }
257+
258+ // レスポンスデータのリセット
259+ $this->responseHeaders = [];
260+ $this->responseBody = '';
261+ $this->responseCode = 0;
262+ $this->responseError = '';
263+ $this->info = [
264+ 'url' => $this->url,
265+ 'content_type' => '',
266+ 'http_code' => 0,
267+ 'header_size' => 0,
268+ 'request_size' => 0,
269+ 'total_time' => 0,
270+ 'redirect_count' => 0,
271+ 'redirect_url' => '',
272+ ];
273+
274+ $startTime = microtime(true);
275+
276+ // ソケットベースの実装を使用する
277+ $redirectCount = 0;
278+ $currentUrl = $this->url;
279+ $originalMethod = $this->method;
280+
281+ do {
282+ if ($this->verbose && $this->stderr) {
283+ fwrite($this->stderr, "* 接続中: {$currentUrl}\n");
284+ }
285+
286+ $parsed = parse_url($currentUrl);
287+ if (!$parsed) {
288+ $this->responseError = "無効なURL: {$currentUrl}";
289+ return false;
290+ }
291+
292+ $scheme = isset($parsed['scheme']) ? strtolower($parsed['scheme']) : 'http';
293+ $host = $parsed['host'];
294+ $port = isset($parsed['port'])
295+ ? $parsed['port'] : ($scheme === 'https' ? 443 : 80);
296+ $path = isset($parsed['path']) ? $parsed['path'] : '/';
297+ if (isset($parsed['query'])) {
298+ $path .= '?'.$parsed['query'];
299+ }
300+
301+ // Basic認証
302+ $authHeader = '';
303+ if (!empty($this->username) && !empty($this->password)) {
304+ $authHeader = "Authorization: Basic "
305+ .base64_encode($this->username.':'.$this->password)."\r\n";
306+ } elseif (isset($parsed['user']) && isset($parsed['pass'])) {
307+ $authHeader = "Authorization: Basic "
308+ .base64_encode($parsed['user'].':'.$parsed['pass'])."\r\n";
309+ }
310+
311+ // 送信するHTTPリクエストを構築
312+ $method = $this->method;
313+ $httpData = '';
314+
315+ if ($method === 'POST' || $method === 'PUT') {
316+ if (!empty($this->postRaw)) {
317+ $httpData = $this->postRaw;
318+ } elseif (!empty($this->postFields)) {
319+ $httpData = http_build_query($this->postFields);
320+ if (!isset($this->headers['Content-Type'])) {
321+ $this->headers['Content-Type'] = 'application/x-www-form-urlencoded';
322+ }
323+ }
324+ $this->headers['Content-Length'] = strlen($httpData);
325+ }
326+
327+ // HTTPリクエストを構築
328+ $accept = 'Accept: */*';
329+ foreach ($this->headers as $h) {
330+ if (str_contains($h, 'Accept:')) $accept = $h;
331+ }
332+
333+ $request = "{$method} {$path} HTTP/1.1\r\n";
334+ $request .= "Host: {$host}\r\n";
335+ $request .= "User-Agent: {$this->userAgent}\r\n";
336+ $request .= "{$accept}\r\n";
337+ $request .= "Connection: close\r\n";
338+
339+ if (!empty($authHeader)) {
340+ $request .= $authHeader;
341+ }
342+
343+ // ヘッダーを追加
344+ foreach ($this->headers as $name => $value) {
345+ $request .= "{$name}: {$value}\r\n";
346+ }
347+
348+ // リファラーが設定されていれば追加
349+ if (!empty($this->referer) && !isset($this->headers['Referer'])) {
350+ $request .= "Referer: {$this->referer}\r\n";
351+ }
352+
353+ // クッキーヘッダーを追加
354+ if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
355+ $cookieStrings = [];
356+ foreach ($this->cookies as $name => $value) {
357+ $cookieStrings[] = $name.'='.urlencode($value);
358+ }
359+ $request .= 'Cookie: '.implode('; ', $cookieStrings)."\r\n";
360+ }
361+
362+ $request .= "\r\n";
363+
364+ // POSTデータを追加
365+ if ($method === 'POST' || $method === 'PUT') {
366+ $request .= $httpData;
367+ }
368+
369+ if ($this->verbose && $this->stderr) {
370+ fwrite($this->stderr, "* リクエストヘッダー:\n{$request}\n");
371+ }
372+
373+ // ソケット接続を確立
374+ $errno = 0;
375+ $errstr = '';
376+
377+ if ($scheme === 'https') {
378+ $sslOptions = [
379+ 'verify_peer' => $this->verifySSL,
380+ 'verify_peer_name' => $this->verifySSL
381+ ];
382+
383+ if (!empty($this->caInfoPath) && file_exists($this->caInfoPath)) {
384+ $sslOptions['cafile'] = $this->caInfoPath;
385+ }
386+
387+ $context = stream_context_create(['ssl' => $sslOptions]);
388+
389+ $socket = @stream_socket_client(
390+ "tls://{$host}:{$port}",
391+ $errno,
392+ $errstr,
393+ $this->timeout,
394+ STREAM_CLIENT_CONNECT,
395+ $context
396+ );
397+ } else {
398+ $socket = @fsockopen($host, $port, $errno, $errstr, $this->timeout);
399+ }
400+
401+ if (!$socket) {
402+ $this->responseError = "接続出来ません: {$errstr} ({$errno})";
403+ if ($this->verbose && $this->stderr) {
404+ fwrite($this->stderr, "* エラー: {$this->responseError}\n");
405+ }
406+ return false;
407+ }
408+
409+ // タイムアウトを設定
410+ stream_set_timeout($socket, $this->timeout);
411+
412+ // リクエストを送信
413+ fwrite($socket, $request);
414+
415+ // レスポンスを読み込む
416+ $rawResponse = '';
417+ $headers = '';
418+ $body = '';
419+ $headersComplete = false;
420+
421+ // ヘッダーとボディを分けて読み込む
422+ while (!feof($socket)) {
423+ $line = fgets($socket);
424+ if ($line === false) {
425+ break;
426+ }
427+
428+ $rawResponse .= $line;
429+
430+ if (!$headersComplete) {
431+ if (trim($line) === '') {
432+ $headersComplete = true;
433+ } else {
434+ $headers .= $line;
435+ }
436+ } else {
437+ $body .= $line;
438+ }
439+ }
440+
441+ fclose($socket);
442+
443+ // レスポンスヘッダーを解析
444+ $headerLines = explode("\r\n", $headers);
445+
446+ // ステータスコードを取得
447+ $statusLine = isset($headerLines[0]) ? $headerLines[0] : '';
448+ $statusParts = explode(' ', $statusLine, 3);
449+ $this->responseCode = isset($statusParts[1]) ? (int)$statusParts[1] : 0;
450+ $this->info['http_code'] = $this->responseCode;
451+
452+ // ヘッダーを解析
453+ $this->responseHeaders = [];
454+ $redirectUrl = '';
455+
456+ foreach ($headerLines as $index => $header) {
457+ if ($index === 0) continue;
458+
459+ if (strpos($header, ':') !== false) {
460+ list($name, $value) = explode(':', $header, 2);
461+ $name = trim($name);
462+ $value = trim($value);
463+ $this->responseHeaders[$name] = $value;
464+
465+ if (strtolower($name) === 'content-type') {
466+ $this->info['content_type'] = $value;
467+ }
468+
469+ // リダイレクトをチェック
470+ if ($this->followRedirects &&
471+ strtolower($name) === 'location' &&
472+ $this->responseCode >= 300 &&
473+ $this->responseCode < 400) {
474+
475+ $redirectUrl = $value;
476+
477+ // 相対URLを絶対URLに変換
478+ if (strpos($redirectUrl, 'http') !== 0) {
479+ if ($redirectUrl[0] === '/') {
480+ $redirectUrl = "{$scheme}://{$host}"
481+ .($port != 80 && $port != 443 ? ":{$port}" : '').$redirectUrl;
482+ } else {
483+ $redirectUrl = "{$scheme}://{$host}"
484+ .($port != 80 && $port != 443 ? ":{$port}" : '')
485+ .dirname($path).'/'.$redirectUrl;
486+ }
487+ }
488+
489+ $this->info['redirect_url'] = $redirectUrl;
490+ }
491+ }
492+ }
493+
494+ $this->info['header_size'] += strlen($headers);
495+ $this->responseBody .= $body;
496+
497+ if ($this->verbose && $this->stderr) {
498+ fwrite($this->stderr, "* レスポンスコード: {$this->responseCode}\n");
499+ fwrite($this->stderr, "* レスポンスヘッダー:\n{$headers}\n");
500+ if (!empty($redirectUrl)) {
501+ fwrite($this->stderr, "* リダイレクト先: {$redirectUrl}\n");
502+ }
503+ }
504+
505+ // リダイレクトが必要な場合
506+ if (!empty($redirectUrl) && $redirectCount < $this->maxRedirects) {
507+ $currentUrl = $redirectUrl;
508+ $redirectCount++;
509+ $this->info['redirect_count'] = $redirectCount;
510+
511+ // 302や303リダイレクトはGETにメソッドを変更
512+ if ($this->responseCode == 302 || $this->responseCode == 303) {
513+ $this->method = 'GET';
514+ $this->postRaw = '';
515+ $this->postFields = [];
516+ }
517+ } else {
518+ break;
519+ }
520+ } while (true);
521+
522+ // リクエスト完了後、元のメソッドに戻す
523+ $this->method = $originalMethod;
524+ $this->info['total_time'] = microtime(true) - $startTime;
525+
526+ return true;
527+ }
528+
529+ /**
530+ * レスポンスボディを取得する
531+ *
532+ * @return string レスポンスボディ
533+ */
534+ public function getResponseBody(): string {
535+ return $this->responseBody;
536+ }
537+
538+ /**
539+ * レスポンスヘッダーを取得する
540+ *
541+ * @return array レスポンスヘッダーの配列
542+ */
543+ public function getResponseHeaders(): array {
544+ return $this->responseHeaders;
545+ }
546+
547+ /**
548+ * HTTPレスポンスコードを取得する
549+ *
550+ * @return int HTTPレスポンスコード
551+ */
552+ public function getResponseCode(): int {
553+ return $this->responseCode;
554+ }
555+
556+ /**
557+ * エラーメッセージがあれば取得する
558+ *
559+ * @return string エラーメッセージ
560+ */
561+ public function getError(): string {
562+ return $this->responseError;
563+ }
564+
565+ /**
566+ * リクエスト/レスポンス情報を取得する
567+ *
568+ * @return array 情報の配列
569+ */
570+ public function getInfo(): array {
571+ return $this->info;
572+ }
573+
574+ // 機能性メソッド
575+
576+ /**
577+ * リダイレクトURLを確認する
578+ *
579+ * @param string $name ヘッダー名
580+ * @param string $value ヘッダー値
581+ * @param string $currentUrl 現在のURL
582+ * @return string リダイレクトURL、リダイレクトがない場合は空文字
583+ */
584+ private function checkReds(string $name, string $value, string $currentUrl): string {
585+ $redirectUrl = '';
586+
587+ if ($this->followRedirects && (strtolower($name) === 'location'
588+ && $this->responseCode >= 300 && $this->responseCode < 400)) {
589+ $redirectUrl = $value;
590+
591+ if (strpos($redirectUrl, 'http') !== 0) {
592+ if ($redirectUrl[0] === '/') {
593+ $parsed = parse_url($currentUrl);
594+ $redirectUrl = $parsed['scheme'].'://'.$parsed['host']
595+ .(isset($parsed['port']) ? ':'.$parsed['port'] : '')
596+ .$redirectUrl;
597+ } else {
598+ $redirectUrl = dirname($currentUrl).'/'.$redirectUrl;
599+ }
600+ }
601+ }
602+
603+ return $redirectUrl;
604+ }
605+
606+ /**
607+ * ヘッダー文字列を構築する
608+ *
609+ * @return string 構築されたヘッダー文字列
610+ */
611+ private function buildHeaderString(): string {
612+ $headers = [];
613+
614+ // ユーザー指定のヘッダーを追加
615+ foreach ($this->headers as $name => $value) {
616+ $headers[] = "{$name}: {$value}";
617+ }
618+
619+ // リファラーが設定されていれば追加
620+ if (!empty($this->referer) && !isset($this->headers['Referer'])) {
621+ $headers[] = "Referer: {$this->referer}";
622+ }
623+
624+ // 必要に応じてクッキーヘッダーを追加
625+ if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
626+ $cookieStrings = [];
627+ foreach ($this->cookies as $name => $value) {
628+ $cookieStrings[] = $name.'='.urlencode($value);
629+ }
630+
631+ $headers[] = 'Cookie: '.implode('; ', $cookieStrings);
632+ }
633+
634+ return implode("\r\n", $headers)."\r\n";
635+ }
636+
637+ /**
638+ * レスポンスを解析してヘッダーとボディに分割する
639+ *
640+ * @param string $response 完全なHTTPレスポンス
641+ * @return array [ヘッダー配列, ボディ文字列]
642+ */
643+ private function parseResponse(string $response): array {
644+ $parts = explode("\r\n\r\n", $response, 2);
645+
646+ if (count($parts) < 2) {
647+ return [[], ''];
648+ }
649+
650+ $headers = explode("\r\n", $parts[0]);
651+ $body = $parts[1];
652+
653+ // チャンク転送エンコーディングを処理
654+ if (isset($this->responseHeaders['Transfer-Encoding']) &&
655+ strtolower($this->responseHeaders['Transfer-Encoding']) === 'chunked') {
656+ $body = $this->decodeChunkedBody($body);
657+ }
658+
659+ return [$headers, $body];
660+ }
661+
662+ /**
663+ * チャンク転送エンコーディングされたボディをデコードする
664+ *
665+ * @param string $body チャンクエンコードされたボディ
666+ * @return string デコードされたボディ
667+ */
668+ private function decodeChunkedBody(string $body): string {
669+ $decodedBody = '';
670+ $position = 0;
671+
672+ while ($position < strlen($body)) {
673+ $lineEnd = strpos($body, "\r\n", $position);
674+ if ($lineEnd === false) {
675+ break;
676+ }
677+
678+ $chunkSize = hexdec(substr($body, $position, $lineEnd - $position));
679+
680+ if ($chunkSize === 0) {
681+ break;
682+ }
683+
684+ $position = $lineEnd + 2;
685+ $decodedBody .= substr($body, $position, $chunkSize);
686+ $position += $chunkSize + 2; // チャンクサイズ + CRLF
687+ }
688+
689+ return $decodedBody;
690+ }
691+}
@@ -0,0 +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+ }
130+}
@@ -0,0 +1,238 @@
1+<?php
2+namespace Site\Lib;
3+
4+class Mailer {
5+ private $socket;
6+ private string $host;
7+ private int $port;
8+ private ?string $user;
9+ private ?string $pass;
10+
11+ private ?string $pgpKey;
12+ private ?string $pgpPass;
13+
14+ /**
15+ * コンストラクタ
16+ */
17+ public function __construct() {
18+ if (!MAILER_ENABLED) {
19+ throw new \Exception("メーラーは無効です。");
20+ }
21+
22+ $this->host = MAILINFO['host'];
23+ $this->port = MAILINFO['port'];
24+ $this->user = MAILINFO['user'];
25+ $this->pass = MAILINFO['pass'];
26+ }
27+
28+ /**
29+ * ソケット接続を開く。
30+ *
31+ * @return void
32+ */
33+ public function connect(): void {
34+ $this->socket = fsockopen($this->host, $this->port, $errno, $err, 30);
35+ if (!$this->socket) {
36+ $msg = "接続に失敗: {$err} ({$errno})";
37+ logger(\LogType::Mailer, $msg);
38+ throw new \Exception($msg);
39+ }
40+
41+ $this->readResponse();
42+ }
43+
44+ /**
45+ * ソケット接続を切断する。
46+ *
47+ * @return void
48+ */
49+ public function disconnect(): void {
50+ $this->sendCommand('QUIT', 221);
51+ fclose($this->socket);
52+ }
53+
54+ /**
55+ * サーバーで認証する。
56+ * ユーザー名とパスワードが指定されている場合はAUTH LOGINを使用する。
57+ *
58+ * @return void
59+ */
60+ public function authenticate(): void {
61+ $ehloRes = $this->sendCommand('EHLO '.$this->host, 250);
62+
63+ // STARTTLSは対応するかどうか確認
64+ if (strpos($ehloRes, 'STARTTLS') !== false) {
65+ $this->sendCommand('STARTTLS', 220);
66+
67+ // TLS暗号化
68+ if (!stream_socket_enable_crypto(
69+ $this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
70+ $msg = "TLSハンドシェイクに失敗";
71+ logger(\LogType::Mailer, $msg);
72+ throw new \Exception($msg);
73+ }
74+
75+ $this->sendCommand('EHLO '.gethostname(), 250);
76+ }
77+
78+ if ($this->user && $this->pass) {
79+ $this->sendCommand('AUTH LOGIN', 334);
80+ $this->sendCommand(base64_encode($this->user), 334);
81+ $this->sendCommand(base64_encode($this->pass), 235);
82+ }
83+ }
84+
85+ /**
86+ * TLSハンドシェイクに失敗する。
87+ *
88+ * @param string $to 受信者のメールアドレス。
89+ * @param string $subject メールの件名。
90+ * @param string $body メールの本文。
91+ * @param string|null $toName 受信者の名前。
92+ * @param string|null $replyTo 返信先メールアドレス。
93+ * @param string|null $replyToName 返信先の名前。
94+ * @param bool $pgpSign PGPで署名するかどうか(現在は正常に動作していません)。
95+ *
96+ * @return void
97+ */
98+ public function send(
99+ string $to,
100+ string $subject,
101+ string $body,
102+ ?string $toName = null,
103+ ?string $replyTo = null,
104+ ?string $replyToName = null,
105+ bool $pgpSign = false
106+ ): void {
107+ $from = MAILINFO['from'];
108+
109+ $this->sendCommand("MAIL FROM: <{$from}>", 250);
110+ $this->sendCommand("RCPT TO: <{$to}>", 250);
111+ $this->sendCommand('DATA', 354);
112+
113+ $headers = "Date: ".date('r')."\r\n"; // RFC 2822
114+
115+ $encSubject = mb_encode_mimeheader($subject, 'UTF-8', 'Q');
116+ $headers .= "Subject: {$encSubject}\r\n";
117+
118+ $fromHeader = mb_encode_mimeheader(SITEINFO['title'], 'UTF-8', 'Q')." <{$from}>";
119+ $headers .= "From: {$fromHeader}\r\n";
120+
121+ $toHeader = $toName
122+ ? mb_encode_mimeheader($toName, 'UTF-8', 'Q')." <{$to}>" : $to;
123+ $headers .= "To: {$toHeader}\r\n";
124+
125+ if ($replyTo) {
126+ $replyToHeader = $replyToName
127+ ? mb_encode_mimeheader($replyToName, 'UTF-8', 'Q')." <{$replyTo}>" : $replyTo;
128+ $headers .= "Reply-To: {$replyToHeader}\r\n";
129+ }
130+
131+ $headers .= "MIME-Version: 1.0\r\n";
132+
133+ if ($pgpSign) {
134+ $boundary = uniqid('BOUNDARY_');
135+ $headers .= "Content-Type: multipart/signed;\r\n";
136+ $headers .= " protocol=\"application/pgp-signature\";\r\n";
137+ $headers .= " micalg=php-sha512;\r\n";
138+ $headers .= " boundary=\"{$boundary}\"\r\n";
139+ } else {
140+ $headers .= "Content-Type: text/plain; charset=utf-8\r\n";
141+ $headers .= "Content-Transfer-Encoding: quoted-printable\r\n";
142+ }
143+
144+ $headers .= "X-Mailer: 076 Little Beast\r\n";
145+
146+ $encBody = quoted_printable_encode($body);
147+ $data = $headers."\r\n".$encBody;
148+
149+ if ($pgpSign) {
150+ $signature = $this->signMessage($data);
151+ $data = "--{$boundary}\r\n"
152+ .$data."\r\n"
153+ ."Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n"
154+ ."Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n"
155+ .$signature."\r\n"
156+ ."--{$boundary}--\r\n";
157+ }
158+
159+ $data .= "\r\n.\r\n";
160+ fwrite($this->socket, $data);
161+
162+ $response = $this->readResponse();
163+ if (substr($response, 0, 3) != '250') {
164+ $msg = "メール送信に失敗: {$response}";
165+ logger(\LogType::Mailer, $msg);
166+ throw new \Exception($msg);
167+ }
168+ }
169+
170+ /**
171+ * お好みのタイムゾーンを設定する。
172+ * 設定しない場合、php.iniで設定されたタイムゾーンがデフォルトになる。
173+ * それも設定されていない場合、GMTタイムゾーンがデフォルトになる。
174+ *
175+ * @param string $zone IANAタイムゾーンデータベース標準のタイムゾーン(例:Asia/Tokyo)
176+ * @return void
177+ */
178+ public function setTimezone(string $zone): void {
179+ date_default_timezone_set($zone);
180+ }
181+
182+ /**
183+ * PGPキーとオプションでパスフレーズを設定する。
184+ *
185+ * @param string $keypath PGP署名に使用する秘密鍵へのパス。
186+ * @param string|null $passphrase 設定されている場合、署名用のパスフレーズ。
187+ *
188+ * @return void
189+ */
190+ public function enablePGP(string $keypath, ?string $passphrase = null): void {
191+ $this->pgpKey = file_get_contents($keypath);
192+ $this->pgpPass = $passphrase;
193+ }
194+
195+ // 機能性メソッド
196+
197+ private function sendCommand(string $command, int $retcode): string {
198+ fwrite($this->socket, $command."\r\n");
199+ $res = $this->readResponse();
200+ if (substr($res, 0, 3) != $retcode) {
201+ $msg = "「{$command}」に対する予期しないレスポンス: {$res}";
202+ logger(\LogType::Mailer, $msg);
203+ throw new \Exception($msg);
204+ }
205+
206+ return $res;
207+ }
208+
209+ private function readResponse(): string {
210+ $res = '';
211+
212+ while ($line = fgets($this->socket, 515)) {
213+ $res .= $line;
214+ if (substr($line, 3, 1) == ' ') break; // レスポンスの終了だ
215+ }
216+
217+ return $res;
218+ }
219+
220+ private function signMessage(string $message): string {
221+ if (extension_loaded('gnupg')) {// gnupg延長は有効の場合
222+ $gpg = new \gnupg();
223+ $gpg->addsignkey($this->pgpKey, $this->pgpPass);
224+ $gpg->setsignmode(\gnupg::SIG_MODE_DETACH);
225+ return $gpg->sign($message);
226+ } else { // なければ、CLIツールを使う(gnupgをインストールは必須)
227+ $tmp = tempnam(sys_get_temp_dir(), 'pgpmsg');
228+ file_put_contents($tmp, $message);
229+ $sig = shell_exec(
230+ "gpg --batch "
231+ .($this->pgpPass ? "--passphrase {$this->pgpPass} " : '')
232+ ."--detach-sign --armor {$tmp} 2>&1"
233+ );
234+ unlink($tmp);
235+ return $sig;
236+ }
237+ }
238+}
@@ -0,0 +1,646 @@
1+<?php
2+namespace Site\Lib;
3+
4+class Markdown {
5+ private string $content;
6+ private array $html;
7+ private string $path;
8+ private bool $inCodeBlock = false;
9+ private string $codeBlockLanguage = '';
10+ private array $codeBlockContent = [];
11+ private const METADATA_LINE = "----";
12+ private array $algebraicPlaceholder = [];
13+
14+ public function __construct(string $path, string $section, ?string $lang = null) {
15+ $this->html = [];
16+ if ($lang) $this->path = ROOT.$section.$lang.'/'.$path.'.md';
17+ else $this->path = ROOT.$section.$path.'.md';
18+
19+ if (!file_exists($this->path)) {
20+ header('Location: /404');
21+ exit();
22+ }
23+ }
24+
25+ /**
26+ * メタデータを取得する
27+ *
28+ * @return \stdClass メタデータオブジェクト
29+ */
30+ public function getMetadata(): \stdClass {
31+ $content = file_get_contents($this->path);
32+ $metadata = new \stdClass();
33+
34+ $parts = explode(self::METADATA_LINE, $content, 2);
35+ if (count($parts) < 2) return $metadata;
36+
37+ $lines = explode("\n", trim($parts[0]));
38+ foreach ($lines as $line) {
39+ $line = trim($line);
40+ if (empty($line)) continue;
41+
42+ $colonPos = strpos($line, ':');
43+ if ($colonPos === false) continue;
44+
45+ $key = trim(substr($line, 0, $colonPos));
46+ $value = trim(substr($line, $colonPos + 1));
47+ $value = trim($value, '"\'');
48+
49+ if ($key == 'category' || $key == 'css') {
50+ $cat = explode(',', $value);
51+ $value = $cat;
52+ }
53+
54+ $metadata->$key = $value;
55+ }
56+
57+ return $metadata;
58+ }
59+
60+ /**
61+ * Markdownをパースする
62+ *
63+ * @return string HTMLとしてレンダリングされたコンテンツ
64+ */
65+ public function parse(): string {
66+ $content = file_get_contents($this->path);
67+ $parts = explode(self::METADATA_LINE, $content, 2);
68+ $this->content = count($parts) > 1 ? trim($parts[1]): trim($content);
69+ $this->html = [];
70+
71+ $lines = explode("\n", $this->content);
72+ $currentParagraph = [];
73+
74+ $inList = false;
75+ $listItems = [];
76+ $listLevel = 0;
77+ $inBlockquote = false;
78+ $blockquoteContent = [];
79+ $tableHeaders = [];
80+ $tableRows = [];
81+ $inTable = false;
82+ $i = 0;
83+
84+ foreach ($lines as $line) {
85+ $i++;
86+ $hasBR = substr($line, -1) === '\\';
87+ $line = rtrim($line, " \t\r\n\\");
88+
89+ // コメント
90+ // if (str_starts_with($line, '//')) {
91+ // if ($hasBR && !empty($currentParagraph)) {
92+ // $currentParagraph[] = '';
93+ // }
94+ // continue;
95+ // }
96+
97+ // コードブロック
98+ if (preg_match('/^```(\w*)$/', $line, $matches)) {
99+ if (!$this->inCodeBlock) {
100+ if (!empty($currentParagraph)) {
101+ $this->html[] = " <p>".implode("", $currentParagraph)."</p>";
102+ $currentParagraph = [];
103+ }
104+ $this->inCodeBlock = true;
105+ $this->codeBlockLanguage = $matches[1];
106+ continue;
107+ } else {
108+ $this->html[] = $this->createCodeBlock();
109+ $this->inCodeBlock = false;
110+ $this->codeBlockLanguage = '';
111+ $this->codeBlockContent = [];
112+ continue;
113+ }
114+ }
115+
116+ if ($this->inCodeBlock) {
117+ $this->codeBlockContent[] = $line;
118+ continue;
119+ }
120+
121+ // テーブルの処理
122+ if (preg_match('/^\|(.+)\|$/', $line)) {
123+ if (!empty($currentParagraph)) {
124+ $this->html[] = " <p>".implode("", $currentParagraph)."</p>";
125+ $currentParagraph = [];
126+ }
127+ $cells = array_map('trim', explode('|', trim($line, '|')));
128+
129+ if (!$inTable) {
130+ $tableHeaders = $cells;
131+ $inTable = true;
132+ } elseif (preg_match('/^\|(\s*:?-+:?\s*\|)+$/', $line)) {
133+ continue;
134+ } else {
135+ $tableRows[] = $cells;
136+ }
137+ continue;
138+ } elseif ($inTable) {
139+ $this->html[] = $this->createTable($tableHeaders, $tableRows);
140+ $tableHeaders = [];
141+ $tableRows = [];
142+ $inTable = false;
143+ }
144+
145+ // 水平線の処理
146+ if (preg_match('/^([\-\*\_])\1{2,}$/', $line)) {
147+ if (!empty($currentParagraph)) {
148+ $this->html[] = " <p>".implode("", $currentParagraph)."</p>";
149+ $currentParagraph = [];
150+ }
151+ $this->html[] = "<hr>";
152+ continue;
153+ }
154+
155+ // 引用ブロックの処理
156+ if (preg_match('/^>\s(.+)/', $line, $matches)) {
157+ if (!empty($currentParagraph)) {
158+ $this->html[] = " <p>".implode("", $currentParagraph)."</p>";
159+ $currentParagraph = [];
160+ }
161+ $inBlockquote = true;
162+ $blockquoteContent[] = $this->parseInline($matches[1]);
163+ continue;
164+ } elseif ($inBlockquote && empty($line)) {
165+ $this->html[] = $this->createBlockquote($blockquoteContent);
166+ $blockquoteContent = [];
167+ $inBlockquote = false;
168+ continue;
169+ }
170+
171+ // 空行をスキップ
172+ if (empty($line)) {
173+ if ($inList) {
174+ $this->html[] = $this->createList($listItems);
175+ $listItems = [];
176+ $inList = false;
177+ }
178+ if (!empty($currentParagraph)) {
179+ $this->html[] = " <p>".implode("", $currentParagraph)."</p>";
180+ $currentParagraph = [];
181+ }
182+
183+ continue;
184+ }
185+
186+ // ヘッダー
187+ if (preg_match('/^(#{1,6})\s(.+)/', $line, $m)) {
188+ if ($inList) {
189+ $this->html[] = $this->createList($listItems);
190+ $listItems = [];
191+ $inList = false;
192+ }
193+ if (!empty($currentParagraph)) {
194+ $this->html[] = " <p>".implode("", $currentParagraph)."</p>";
195+ $currentParagraph = [];
196+ }
197+
198+ $level = strlen($m[1]);
199+ $this->html[] = "<h{$level}>".$this->parseInline($m[2])."</h{$level}>";
200+ continue;
201+ }
202+
203+ // 箇条書きリスト
204+ if (preg_match('/^(\s*)([\*\-])\s(.+)/', $line, $m)) {
205+ if (!empty($currentParagraph)) {
206+ $this->html[] = " <p>".implode("", $currentParagraph)."</p>";
207+ $currentParagraph = [];
208+ }
209+ $inList = true;
210+ $currentLevel = strlen($m[1]) / 2;
211+ $listLevel = max($listLevel, $currentLevel);
212+ $listItems[] = [
213+ 'content' => $this->parseInline($m[3]),
214+ 'level' => $currentLevel,
215+ 'type' => 'ul',
216+ ];
217+
218+ continue;
219+ }
220+
221+ // 番号付きリスト
222+ if (preg_match('/^(\s*)\d+\.\s(.+)/', $line, $m)) {
223+ if (!empty($currentParagraph)) {
224+ $this->html[] = " <p>".implode("", $currentParagraph)."</p>";
225+ $currentParagraph = [];
226+ }
227+ $inList = true;
228+ $currentLevel = strlen($m[1]) / 2;
229+ $listLevel = max($listLevel, $currentLevel);
230+ $listItems[] = [
231+ 'content' => $this->parseInline($m[2]),
232+ 'level' => $currentLevel,
233+ 'type' => 'ol',
234+ ];
235+
236+ continue;
237+ }
238+
239+ if ($inList) {
240+ $this->html[] = $this->createList($listItems);
241+ $listItems = [];
242+ $inList = false;
243+ $listLevel = 0;
244+ }
245+
246+ $parsedLine = $this->parseInline($line);
247+ $currentParagraph[] = $parsedLine;
248+ if ($hasBR) {
249+ $currentParagraph[] = "<br />";
250+ }
251+ }
252+
253+ if ($inList) $this->html[] = $this->createList($listItems);
254+ if ($inBlockquote) $this->html[] = $this->createBlockquote($blockquoteContent);
255+ if ($inTable) $this->html[] = $this->createTable($tableHeaders, $tableRows);
256+ if (!empty($currentParagraph))
257+ $this->html[] = " <p>".implode("", $currentParagraph)."</p>";
258+
259+ return implode("\n", $this->html);
260+ }
261+
262+ // 機能性メソッド
263+
264+ /**
265+ * インラインのMarkdown記法をパースする
266+ *
267+ * @param string $text パースするテキスト
268+ * @return string HTMLとしてレンダリングされたテキスト
269+ */
270+ private function parseInline(string $text): string {
271+ $this->algebraicPlaceholder = [];
272+ $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
273+
274+ $patterns = [
275+ // 数式
276+ '/\$\$([^$]+)\$\$/u' => function($matches): string {
277+ $placeholder = "{{ALG".count($this->algebraicPlaceholder).'ALG}}';
278+ $this->algebraicPlaceholder[$placeholder] = $matches[1];
279+ return $placeholder;
280+ },
281+ // 太字
282+ '/\*\*(.+?)\*\*/u' => '<strong>$1</strong>',
283+ // 斜体
284+ '/\*(.+?)\*/u' => '<em>$1</em>',
285+ // 下線
286+ '/\_(.+?)\_/u' => '<u>$1</u>',
287+ // 取り消し線
288+ '/\~(.+?)\~/u' => '<s>$1</s>',
289+ // Blink (with speed)
290+ '/!:\((.+?)\)(.+?):!/u' => '<span class="blink" style="animation-duration: $1s;">$2</span>',
291+ // Blink
292+ '/!:(.+?):!/u' => '<span class="blink">$1</span>',
293+ // フォントの大きさ
294+ '/\^\((.+?)\)(.+?)\^/u' => '<span style="font-size: $1px;">$2</span>',
295+ // フォントカラー
296+ '/\%\((.+?)\)(.+?)\%/u' => '<span style="color: #$1;">$2</span>',
297+ // 画像
298+ '/\!\[(.*?)(?:#([^\]]*))?\]\((.+?)\)/u' => '<img style="width: 100%; $2" src="$3" alt="$1" />',
299+ // 音楽
300+ '/\$\[([^\]]+)\]\(([^\)]+)\)/u' => '<audio controls><source src="$2" type="$1" /></audio>',
301+ // 動画
302+ '/\#\[([^\]]+)\]\(([^\)]+)\)/u' => '<video style="max-width: 100%;" controls><source src="$2" type="$1" /></video>',
303+ // リンク
304+ '/\[(.+?)\]\((.+?)\)/u' => '<a href="$2">$1</a>',
305+ // 振り仮名
306+ '/\&lt;(.+?)\&gt;\((.+?)\)/u' => '<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>',
307+ // インラインコード
308+ '/`(.+?)`/u' => '<code>$1</code>',
309+ ];
310+
311+ foreach ($patterns as $pattern => $replacement) {
312+ if (is_callable($replacement)) {
313+ $text = preg_replace_callback($pattern, $replacement, $text);
314+ } else {
315+ $result = preg_replace($pattern, $replacement, $text);
316+ if ($result === null) continue;
317+ $text = $result;
318+ }
319+ }
320+
321+ // プレースホルダーの数式に交換
322+ foreach ($this->algebraicPlaceholder as $placeholder => $expr) {
323+ $text = str_replace($placeholder, $this->parseAlgebraic($expr), $text);
324+ }
325+
326+ return $text;
327+ }
328+
329+ /**
330+ * 数式記法をパースする
331+ *
332+ * @param string $expression パースする数式
333+ * @return string MathJax用にフォーマットされた数式
334+ */
335+ private function parseAlgebraic(string $expression): string {
336+ // HTMLエスケープ
337+ $expression = htmlspecialchars_decode($expression, ENT_QUOTES);
338+
339+ // ふぃりしゃもじのマッピング
340+ $greekLetters = [
341+ 'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ', 'delta' => 'δ',
342+ 'epsilon' => 'ε', 'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ',
343+ 'iota' => 'ι', 'kappa' => 'κ', 'lambda' => 'λ', 'mu' => 'μ',
344+ 'nu' => 'ν', 'xi' => 'ξ', 'omicron' => 'ο', 'pi' => 'π',
345+ 'rho' => 'ρ', 'sigma' => 'σ', 'tau' => 'τ', 'upsilon' => 'υ',
346+ 'phi' => 'φ', 'chi' => 'χ', 'psi' => 'ψ', 'omega' => 'ω',
347+ 'Alpha' => 'Α', 'Beta' => 'Β', 'Gamma' => 'Γ', 'Delta' => 'Δ',
348+ 'Epsilon' => 'Ε', 'Zeta' => 'Ζ', 'Eta' => 'Η', 'Theta' => 'Θ',
349+ 'Iota' => 'Ι', 'Kappa' => 'Κ', 'Lambda' => 'Λ', 'Mu' => 'Μ',
350+ 'Nu' => 'Ν', 'Xi' => 'Ξ', 'Omicron' => 'Ο', 'Pi' => 'Π',
351+ 'Rho' => 'Ρ', 'Sigma' => 'Σ', 'Tau' => 'Τ', 'Upsilon' => 'Υ',
352+ 'Phi' => 'Φ', 'Chi' => 'Χ', 'Psi' => 'Ψ', 'Omega' => 'Ω',
353+ ];
354+
355+ // ギリシャ文字を置換
356+ foreach ($greekLetters as $text => $symbol) {
357+ $expression = preg_replace("/\b$text\b/", $symbol, $expression);
358+ }
359+
360+ // 上付き文字(例: x^2)
361+ $expression = preg_replace_callback('/(\w+)\^(\{?(\d+|\w+)\}?)/', function($matches) {
362+ $base = $matches[1];
363+ $exponent = $matches[2];
364+ if ($exponent[0] === '{') $exponent = substr($exponent, 1, -1);
365+
366+ $superscripts = str_split($exponent);
367+ $supMap = [
368+ '0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴',
369+ '5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹',
370+ 'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ',
371+ 'i' => 'ⁱ', 'n' => 'ⁿ',
372+ ];
373+ $converted = '';
374+
375+ foreach ($superscripts as $char) $converted .= $supMap[$char] ?? $char;
376+
377+ return $base.'<sup>'.$converted.'</sup>';
378+ }, $expression);
379+
380+ // 下付き文字(例: x_1)
381+ $expression = preg_replace_callback('/(\w+)_((\d+|\w+)\}?)/', function($matches) {
382+ $base = $matches[1];
383+ $subscript = $matches[2];
384+
385+ if ($subscript[0] === '{') $subscript = substr($subscript, 1, -1);
386+
387+ $subscripts = str_split($subscript);
388+ $subMap = [
389+ '0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄',
390+ '5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉',
391+ ];
392+ $converted = '';
393+
394+ foreach ($subscripts as $char) {
395+ $converted .= $subMap[$char] ?? $char;
396+ }
397+
398+ return $base.'<sub>'.$converted.'</sub>';
399+ }, $expression);
400+
401+ // 分数(例: 4/5x, a/b)
402+ $expression = preg_replace('/(\d+|\w+)\/(\d*\w+)/',
403+ '<span class="fraction"><span class="numerator">$1</span><span class="denominator">$2</span></span>',
404+ $expression);
405+
406+ // 演算子
407+ $operators = [
408+ '\*' => '・',
409+ '!=' => '≠',
410+ '>=' => '≥',
411+ '<=' => '≤',
412+ 'sqrt' => '√',
413+ ];
414+
415+ foreach ($operators as $op => $symbol) $expression = preg_replace("/\b$op\b/", $symbol, $expression);
416+
417+ // 数式全体をspanで囲む
418+ return '<span class="algebraic">'.$expression.'</span>';
419+ }
420+
421+ /**
422+ * リストを作成する
423+ *
424+ * @param array $items リストアイテムの配列
425+ * @param int $maxLevel 最大ネストレベル
426+ * @return string HTMLのリスト
427+ */
428+ private function createList(array $items, int $maxLevel = 1): string {
429+ if ($items === []) return '';
430+
431+ $html = '';
432+ $currentLevel = 0;
433+ $listStack = [];
434+ $currentType = '';
435+
436+ foreach ($items as $item) {
437+ $level = isset($item['level']) ? $item['level'] : $currentLevel;
438+
439+ while ($currentLevel > $level)
440+ $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
441+
442+ while ($currentLevel < $level) {
443+ $currentLevel++;
444+ $listStack[] = $item['type'];
445+ $html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
446+ }
447+
448+ if ($currentType != $item['type'] && $currentLevel == $item['level']) {
449+ if (!empty($listStack)) {
450+ $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
451+ $listStack[] = $item['type'];
452+ $html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
453+ }
454+ }
455+
456+ $currentType = $item['type'];
457+ }
458+
459+ $html .= "<{$currentType}>\n";
460+ foreach ($items as $item) {
461+ $html .= str_repeat(' ', $currentLevel)." <li>".$item['content']."</li>\n";
462+ }
463+ $html .= "</{$currentType}>";
464+
465+ while (!empty($listStack)) {
466+ $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
467+ $currentLevel--;
468+ }
469+
470+ return rtrim($html);
471+ }
472+
473+ /**
474+ * コードブロックを作成する
475+ *
476+ * @return string HTMLのコードブロック
477+ */
478+ private function createCodeBlock(): string {
479+ $code = htmlspecialchars(implode("\n", $this->codeBlockContent));
480+ $class = $this->codeBlockLanguage ? " class=\"language-{$this->codeBlockLanguage}\"" : '';
481+ return "<pre><code{$class}>{$code}</code></pre>";
482+ // $raw = implode("\n", $this->codeBlockContent);
483+ // $lang = $this->codeBlockLanguage ?: 'txt';
484+ // $class = $lang === 'txt' ? '' : " class=\"language-{$lang}\"";
485+ // $highlighted = $this->highlightCode($raw, $lang);
486+ // return "<pre><code{$class}>{$highlighted}</code></pre>";
487+ }
488+
489+ private function createBlockquote(array $content): string {
490+ return "<blockquote>\n <p>".implode("</p>\n <p>", $content)."</p>\n</blockquote>";
491+ }
492+
493+ /**
494+ * テーブルを作成する
495+ *
496+ * @param array $headers ヘッダー配列
497+ * @param array $rows 行データの配列
498+ * @return string HTMLのテーブル
499+ */
500+ private function createTable(array $headers, array $rows): string {
501+ $html = "<table>\n";
502+
503+ // ヘッダーを追加
504+ if (!empty($headers)) {
505+ $html .= " <thead>\n <tr>\n";
506+ foreach ($headers as $header) {
507+ $html .= " <th>".$this->parseInline($header)."</th>\n";
508+ }
509+ $html .= " </tr>\n </thead>\n";
510+ }
511+
512+ // 行を追加
513+ if (!empty($rows)) {
514+ $html .= " <tbody>\n";
515+ foreach ($rows as $row) {
516+ $html .= " <tr>\n";
517+ foreach ($row as $cell) {
518+ $html .= " <td>".$this->parseInline($cell)."</td>\n";
519+ }
520+ $html .= " </tr>\n";
521+ }
522+ $html .= " </tbody>\n";
523+ }
524+
525+ $html .= "</table>";
526+ return $html;
527+ }
528+
529+ /**
530+ * Pure-PHP syntax highlighter
531+ *
532+ * @param string $code Raw source code
533+ * @param string $lang Language identifier (html, css, js, php, …)
534+ * @return string HTML with <span class="hl-…"> tokens
535+ */
536+ private function highlightCode(string $code, string $lang): string
537+ {
538+ $lang = strtolower(trim($lang));
539+ $rules = [];
540+
541+ // ---- COMMON -------------------------------------------------------
542+ $rules['comment'] = [
543+ // /* … */ (multi-line)
544+ ['/\/\*(?:.|\n)*?\*\//', 'hl-comment'],
545+ // // …\n
546+ ['/\/\/.*(?=\n|$)/', 'hl-comment'],
547+ ];
548+
549+ $rules['string'] = [
550+ // "…" and '…' (allow escaped quotes)
551+ ['/"(?:\\.|[^"\\\n])*"/', 'hl-string'],
552+ ["/'(?:\\.|[^'\\\n])*'/", 'hl-string'],
553+ ];
554+
555+ $rules['number'] = [
556+ ['/\b(?:0x[0-9a-fA-F]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/', 'hl-number'],
557+ ];
558+
559+ // ---- HTML ---------------------------------------------------------
560+ if ($lang === 'html') {
561+ $rules['tag'] = [['/&lt;\/?[a-zA-Z0-9\-]+/', 'hl-tag']];
562+ $rules['attr'] = [['/\s[a-zA-Z0-9\-]+(?==)/', 'hl-attr']];
563+ $rules['value'] = [['/(?<==)"(?:\\.|[^"\\\n])*"(?=[>\s])/', 'hl-value']];
564+ }
565+
566+ // ---- CSS ----------------------------------------------------------
567+ if ($lang === 'css') {
568+ $rules['selector'] = [['/^[\t ]*[a-zA-Z0-9#\.\-\:$$ $$\=\*\>\+\~\^]+(?=\s*\{)/m', 'hl-selector']];
569+ $rules['property'] = [['/\b[a-z\-]+(?=\s*:)/', 'hl-property']];
570+ $rules['value'] = [['/(?<=\:)\s*[^;]+(?=;)/', 'hl-value']];
571+ $rules['unit'] = [['/\b\d+(px|em|rem|%|vh|vw|pt|pc|cm|mm|in)\b/i', 'hl-unit']];
572+ }
573+
574+ // ---- PHP ----------------------------------------------------------
575+ if ($lang === 'php') {
576+ $keywords = 'abstract|and|array|as|break|callable|case|catch|class|clone|'
577+ . 'const|continue|declare|default|die|do|echo|else|elseif|empty|'
578+ . 'enddeclare|endfor|endforeach|endif|endswitch|endwhile|eval|exit|'
579+ . 'extends|final|finally|for|foreach|function|global|goto|if|'
580+ . 'implements|include|include_once|instanceof|insteadof|interface|'
581+ . 'isset|list|namespace|new|or|print|private|protected|public|'
582+ . 'require|require_once|return|static|switch|throw|trait|try|unset|'
583+ . 'use|var|while|xor|yield';
584+ $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
585+ $rules['function']= [['/\b([a-zA-Z_\x7f-\xff][\w\x7f-\xff]*)(?=\s*\()/', 'hl-function']];
586+ $rules['variable']= [['/\$[a-zA-Z_\x7f-\xff][\w\x7f-\xff]*/', 'hl-variable']];
587+ }
588+
589+ // ---- Shell --------------------------------------------------------
590+ if ($lang === 'sh') {
591+ $keywords = 'if|then|else|elif|fi|case|esac|for|select|while|until|do|done|in';
592+ $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
593+ $rules['variable']= [['/\$\{?[a-zA-Z_][\w]*\}?/', 'hl-variable']];
594+ }
595+
596+ // ---- JSON ---------------------------------------------------------
597+ if ($lang === 'json') {
598+ $rules['property'] = [['/"([^"]+)":/', '<span class="hl-property">$1</span>:']];
599+ }
600+
601+ // ---- C / C++ -----------------------------------------------
602+ if (in_array($lang, ['c', 'cpp', 'cs'])) {
603+ $keywords = 'abstract|assert|boolean|break|byte|case|catch|char|class|const|'
604+ . 'continue|default|do|double|else|enum|extends|false|final|finally|'
605+ . 'float|for|goto|if|implements|import|instanceof|int|interface|long|'
606+ . 'native|new|null|package|private|protected|public|return|short|static|'
607+ . 'strictfp|super|switch|synchronized|this|throw|throws|transient|true|'
608+ . 'try|void|volatile|while';
609+ $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
610+ $rules['type'] = [['/\b(?:int|float|double|char|bool|void|string)\b/', 'hl-type']];
611+ $rules['define'] = [['/(#include)|(#define)/', 'hl-define']];
612+ }
613+
614+ if ($lang === 'markdown') {
615+ $rules['header'] = [['/^(#{1,6})\s+(.+)$/', '<span class="hl-header">$1 $2</span>']];
616+ $rules['link'] = [['/\[([^\]]+)\]\(([^)]+) $$/', '<span class="hl-link">[$1]($2)</span>']];
617+ }
618+
619+ $html = htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8');
620+
621+ $order = ['comment', 'string', 'number'];
622+ foreach (['tag','attr','value','selector','property','unit','keyword','function','variable','type','header','link'] as $k) {
623+ if (isset($rules[$k])) $order[] = $k;
624+ }
625+
626+ foreach ($order as $type) {
627+ if (!isset($rules[$type])) continue;
628+ foreach ($rules[$type] as $rule) {
629+ [$pattern, $class] = $rule;
630+ $html = preg_replace_callback(
631+ $pattern,
632+ function ($m) use ($class) {
633+ // For JSON property we already built the <span>
634+ if (strpos($m[0], '<span') === 0) return $m[0];
635+ $inner = $m[0];
636+ // Preserve already escaped entities inside strings/comments
637+ return '<span class="'.$class.'">'.$inner.'</span>';
638+ },
639+ $html
640+ );
641+ }
642+ }
643+
644+ return $html;
645+ }
646+}
@@ -0,0 +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+ }
1559+}
@@ -0,0 +1,163 @@
1+<?php
2+namespace Site\Lib;
3+
4+class Route {
5+ protected static array $routes = [];
6+ protected static array $fallback = [];
7+
8+ /**
9+ * ルート設定を言語固有のハンドラで初期化する
10+ *
11+ * @param array $routes ルート設定の配列
12+ * @return void
13+ */
14+ public static function init(array $routes): void {
15+ self::$routes = $routes;
16+ }
17+
18+ /**
19+ * ルートを追加する
20+ *
21+ * @param string $method HTTPメソッド
22+ * @param string $path URLパス
23+ * @param string|callable $class ハンドラクラスとメソッド、またはコールバック
24+ * @param array $params オプションのパラメータ
25+ * @return array ルート設定
26+ */
27+ public static function add(string $method, string $path, string|callable $class,
28+ array $params = []): array {
29+ $route = [
30+ 'method' => $method,
31+ 'path' => $path,
32+ 'class' => $class,
33+ 'params' => $params,
34+ ];
35+
36+ self::$routes[] = $route;
37+ return $route;
38+ }
39+
40+ /**
41+ * 404処理用のフォールバックルートを設定する
42+ *
43+ * @param array|string|callable $class
44+ * @return void
45+ */
46+ public static function setFallback(array|string|callable $class): void {
47+ self::$fallback = [
48+ 'class' => $class,
49+ 'params' => [],
50+ ];
51+ }
52+
53+ /**
54+ * 適切なルートをマッチさせて実行する
55+ *
56+ * @param string $uri リクエストURI
57+ * @return void
58+ */
59+ public static function dispatch(string $uri): void {
60+ // URIをパスとクエリ文字列に分割
61+ $uriParts = explode('?', $uri, 2);
62+ $path = trim($uriParts[0], " \t\n\r\0\x0B/");
63+
64+ // ルートパスの処理(/?page=2のようなクエリパラメータを含む場合も処理)
65+ if ($path === '') {
66+ self::executeClass([
67+ 'class' => [new \Site\Controller\Home(), 'show'],
68+ 'params' => ['lang' => 'ja'],
69+ ]);
70+ return;
71+ }
72+
73+ if ($path === 'en') {
74+ self::executeClass([
75+ 'class' => [new \Site\Controller\Home(), 'show'],
76+ 'params' => ['lang' => 'en'],
77+ ]);
78+ return;
79+ }
80+
81+ // パスに対してルートをマッチングする
82+ foreach (self::$routes as $route) {
83+ $matches = [];
84+
85+ if (self::matchRoute($route['path'], $path, $matches)) {
86+ $params = self::extractParams($route['path'], $path);
87+ $params = array_merge($route['params'], $params);
88+
89+ if (is_string($route['class'])) {
90+ [ $class, $method ] = explode('@', $route['class']);
91+ $controller = new $class();
92+ self::executeClass([
93+ 'class' => [ $controller, $method ],
94+ 'params' => $params,
95+ ]);
96+
97+ return;
98+ } elseif (is_callable($route['class'])) {
99+ self::executeClass([
100+ 'class' => $route['class'],
101+ 'params' => $params,
102+ ]);
103+
104+ return;
105+ }
106+ }
107+ }
108+
109+ // マッチするルートがない場合、フォールバックを実行
110+ self::executeClass(self::$fallback);
111+ }
112+
113+ /**
114+ * ルートパターンとパスをマッチングする
115+ *
116+ * @param string $pattern ルートパターン
117+ * @param string $path 現在のパス
118+ * @param array $matches マッチを格納する参照
119+ * @return bool
120+ */
121+ protected static function matchRoute(string $pattern, string $path,
122+ array &$matches = []): bool {
123+ // ルートパターンを正規表現パターンに変換
124+ $pattern = preg_replace('/\{([^:}]+)(?::([^}]+))?\}/', '(?P<$1>[^/]+)', $pattern);
125+ $pattern = str_replace('/', '\/', $pattern);
126+ return (bool)preg_match('/^'.$pattern.'$/', $path, $matches);
127+ }
128+
129+ /**
130+ * パターンに基づいてパスから名前付きパラメータを抽出する
131+ *
132+ * @param string $pattern ルートパターン
133+ * @param string $path 現在のパス
134+ * @return array
135+ */
136+ protected static function extractParams(string $pattern, string $path): array {
137+ $params = [];
138+ $patternParts = explode('/', $pattern);
139+ $pathParts = explode('/', $path);
140+
141+ foreach ($patternParts as $k => $v) {
142+ if (preg_match('/\{([^:}]+)(?::([^}]+))?\}/', $v, $matches)) {
143+ if (isset($pathParts[$k])) {
144+ $params[$matches[1]] = $pathParts[$k];
145+ }
146+ }
147+ }
148+
149+ return $params;
150+ }
151+
152+ /**
153+ * ルートクラスを実行する
154+ *
155+ * @param array $route ルート設定
156+ * @return void
157+ */
158+ protected static function executeClass(array $route): void {
159+ if (is_callable($route['class'])) {
160+ call_user_func($route['class'], $route['params'] ?? []);
161+ }
162+ }
163+}
@@ -0,0 +1,243 @@
1+<?php
2+namespace Site\Lib;
3+
4+class Template {
5+ private string $tmplExt = '.maron';
6+ private string $tmplPath;
7+ private array $vars = [];
8+ private array $blocks = [];
9+ private array $custFunc = [];
10+ private array $custCss = [];
11+
12+ /**
13+ * テンプレートクラスのコンストラクタ
14+ *
15+ * @param string $tmplPath テンプレートのパス
16+ * @return void
17+ */
18+ public function __construct(string $tmplPath) {
19+ $this->tmplPath = rtrim($tmplPath, '/');
20+
21+ if (substr($this->tmplPath, 0, 1) !== '/') {
22+ $this->tmplPath = '/'.$this->tmplPath;
23+ }
24+ }
25+
26+ /**
27+ * テンプレート変数に値を割り当てる
28+ *
29+ * @param string $name 変数名
30+ * @param mixed $value 値
31+ * @return void
32+ */
33+ public function assign(string $name, mixed $value): void {
34+ $this->vars[$name] = $value;
35+ }
36+
37+ /**
38+ * カスタムCSSファイルを追加する
39+ *
40+ * @param string $name CSSファイル名
41+ * @return void
42+ */
43+ public function addCss(string $name): void {
44+ $this->custCss[] =
45+ '<link rel="stylesheet" type="text/css" href="/static/style-'.$name.'.css" />';
46+ $this->assign('custCss', $this->custCss);
47+ }
48+
49+ /**
50+ * カスタム関数を登録する
51+ *
52+ * @param string $name 関数名
53+ * @param callable $callback コールバック関数
54+ * @return void
55+ */
56+ public function registerFunction(string $name, callable $callback): void {
57+ $this->custFunc[$name] = $callback;
58+ }
59+
60+ /**
61+ * テンプレートブロックを定義する
62+ *
63+ * @param string $name ブロック名
64+ * @param string $content ブロック内容
65+ * @return void
66+ */
67+ public function defineBlock(string $name, string $content): void {
68+ if (!isset($this->blocks[$name]))
69+ $this->blocks[$name] = $content;
70+ }
71+
72+ /**
73+ * テンプレートをレンダリングする
74+ *
75+ * @param string $tmplName テンプレート名
76+ * @return void
77+ */
78+ public function render(string $tmplName): void {
79+ $tmplPath = ROOT.'/view'.$this->tmplPath.'/'.$tmplName.$this->tmplExt;
80+ if (!file_exists($tmplPath))
81+ throw new \RuntimeException("テンプレートファイルを見つけません:{$tmplPath}");
82+
83+ extract($this->vars);
84+
85+ $content = file_get_contents($tmplPath);
86+
87+ // インクルードディレクティブを処理
88+ while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
89+ $content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string {
90+ $inclPath = ROOT.'/view/'.trim($m[1], "'\" ").$this->tmplExt;
91+ if (!file_exists($inclPath))
92+ throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
93+ return file_get_contents($inclPath);
94+ }, $content);
95+ }
96+
97+ $content = $this->procDirs($content);
98+ $content = $this->procVars($content);
99+ $content = $this->procFuncs($content);
100+
101+ $tmpFile = tempnam(sys_get_temp_dir(), 'tmpl_');
102+ file_put_contents($tmpFile, $content);
103+
104+ include $tmpFile;
105+ unlink($tmpFile);
106+ }
107+
108+ // 機能性メソッド
109+
110+ /**
111+ * テンプレートディレクティブを処理する
112+ *
113+ * @param string $content テンプレート内容
114+ * @return string|null 処理後の内容
115+ */
116+ private function procDirs(string $content): string|null {
117+ // includeディレクティブの処理
118+ while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
119+ $content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string {
120+ $inclPath = ROOT.'/view/'.trim($m[1], "'\" ").'.php';
121+ if (!file_exists($inclPath))
122+ throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
123+ return file_get_contents($inclPath);
124+ }, $content);
125+ }
126+
127+ $content = preg_replace('/\{@\s*if\s*\((.*?)\):\s*@\}/', '{@ if ($1) @}', $content);
128+ $content = preg_replace('/\{@\s*endif;\s*@\}/', '{@ endif @}', $content);
129+
130+ $processDirectives = function($c): array|string|null {
131+ // kysディレクティブの処理
132+ $c = preg_replace_callback('/\{@\s*kys\((.*?)\)\s*@\}/s', function($m): string {
133+ return "<?php echo '<pre>'; print_r({$m[1]}); echo '</pre>'; die(); ?>";
134+ }, $c);
135+
136+ // foreachループとネストした内容の処理
137+ $c = preg_replace_callback('/\{@\s*foreach\s*\((.*?)\)\s*@\}/s', function($m): string {
138+ return "<?php foreach({$m[1]}): ?>";
139+ }, $c);
140+
141+ $c = preg_replace_callback('/\{@\s*endforeach\s*@\}/s', function($m): string {
142+ return "<?php endforeach; ?>";
143+ }, $c);
144+
145+ // forループの処理
146+ $c = preg_replace_callback('/\{@\s*for\s*\((.*?)\)\s*@\}/s', function($m): string {
147+ return "<?php for({$m[1]}): ?>";
148+ }, $c);
149+
150+ $c = preg_replace_callback('/\{@\s*endfor\s*@\}/s', function($m): string {
151+ return "<?php endfor; ?>";
152+ }, $c);
153+
154+ // if-elif-else-endifの処理
155+ $c = preg_replace_callback('/\{@\s*if\s*\((.*?)\)\s*@\}/s', function($m): string {
156+ return "<?php if ({$m[1]}): ?>";
157+ }, $c);
158+
159+ $c = preg_replace_callback('/\{@\s*elif\s*\((.*?)\)\s*@\}/s', function($m): string {
160+ return "<?php elseif ({$m[1]}): ?>";
161+ }, $c);
162+
163+ $c = preg_replace_callback('/\{@\s*else\s*@\}/s', function($m): string {
164+ return "<?php else: ?>";
165+ }, $c);
166+
167+ $c = preg_replace_callback('/\{@\s*endif\s*@\}/s', function($m): string {
168+ return "<?php endif; ?>";
169+ }, $c);
170+
171+ return $c;
172+ };
173+
174+ $previousContent = '';
175+ $maxIterations = 10;
176+ $iterations = 0;
177+
178+ while ($previousContent !== $content && $iterations < $maxIterations) {
179+ $previousContent = $content;
180+ $content = $processDirectives($content);
181+ $iterations++;
182+ }
183+
184+ return $content;
185+ }
186+
187+ /**
188+ * テンプレート変数を処理する
189+ *
190+ * @param string $content テンプレート内容
191+ * @return string 処理後の内容
192+ */
193+ private function procVars(string $content): string {
194+ // 変数の出力(エスケープ処理なし)
195+ $content = preg_replace_callback('/\{\{\{s*(.*?)\s*\}\}\}/', function($m): string {
196+ return '<?= '.trim($m[1]).' ?>';
197+ }, $content);
198+
199+ // 変数の出力(エスケープ処理あり)
200+ $content = preg_replace_callback('/\{\{\s*(.*?)\s*\}\}/', function($m): string {
201+ return '<?= htmlspecialchars('.trim($m[1]).', ENT_QUOTES, \'UTF-8\') ?>';
202+ }, $content);
203+
204+ // 変数の代入
205+ $content = preg_replace_callback('/\{\$\s*(.*?)\s*\$\}/', function($m): string {
206+ $parts = explode('=', $m[1], 2);
207+ if (count($parts) !== 2)
208+ throw new \RuntimeException("不正な値の形式");
209+ return '<?php '.trim($parts[0]).' = '.trim($parts[1]).'; ?>';
210+ }, $content);
211+
212+ // コメント
213+ $content = preg_replace_callback('/\{#\s*(.*?)\s*#\}/', function($m): string {
214+ return '<?php /*'.trim($m[1]).'*/ ?>';
215+ }, $content);
216+
217+ // PHPコードの実行
218+ $content = preg_replace_callback('/\{\!\s*(.*?)\s*\!\}/', function($m): string {
219+ return '<?php '.trim($m[1]).' ?>';
220+ }, $content);
221+
222+ return $content;
223+ }
224+
225+ /**
226+ * カスタム関数を処理する
227+ *
228+ * @param string $content テンプレート内容
229+ * @return string 処理後の内容
230+ */
231+ private function procFuncs(string $content): string {
232+ foreach ($this->custFunc as $name => $cb) {
233+ $pattern = "/\{@\s*{$name}\((.*?)\)\s*@\}/";
234+ $content = preg_replace_callback($pattern, function($m) use ($cb): mixed {
235+ $args = explode(',', $m[1]);
236+ $args = array_map('trim', $args);
237+ return call_user_func_array($cb, $args);
238+ }, $content);
239+ }
240+
241+ return $content;
242+ }
243+}
@@ -0,0 +1,587 @@
1+<?php
2+namespace Site\Lib;
3+
4+/**
5+ * 軽量なユニットテストフレームワーク
6+ */
7+class Tester {
8+ // テスト統計
9+ private int $testCount = 0;
10+ private int $passCount = 0;
11+ private int $failCount = 0;
12+ private int $errorCount = 0;
13+
14+ // 現在のテストケース情報
15+ private string $currentTestCase = '';
16+ private string $currentTest = '';
17+
18+ // テストスイート設定
19+ private bool $colorOutput = true;
20+ private bool $verboseOutput = true;
21+ private bool $stopOnFailure = false;
22+ private array $beforeEachCallbacks = [];
23+ private array $afterEachCallbacks = [];
24+ private array $beforeAllCallbacks = [];
25+ private array $afterAllCallbacks = [];
26+
27+ // ターミナル出力用の色
28+ private array $colors = [
29+ 'reset' => "\033[0m",
30+ 'red' => "\033[31m",
31+ 'green' => "\033[32m",
32+ 'yellow' => "\033[33m",
33+ 'blue' => "\033[34m",
34+ 'magenta' => "\033[35m",
35+ 'cyan' => "\033[36m",
36+ 'white' => "\033[37m",
37+ 'bold' => "\033[1m",
38+ ];
39+
40+ private array $failures = [];
41+ private array $errors = [];
42+
43+ /**
44+ * コンストラクタ
45+ *
46+ * @param array $options 設定オプション
47+ */
48+ public function __construct(array $options = []) {
49+ // オプションを設定
50+ if (isset($options['colorOutput'])) {
51+ $this->colorOutput = (bool)$options['colorOutput'];
52+ }
53+
54+ if (isset($options['verboseOutput'])) {
55+ $this->verboseOutput = (bool)$options['verboseOutput'];
56+ }
57+
58+ if (isset($options['stopOnFailure'])) {
59+ $this->stopOnFailure = (bool)$options['stopOnFailure'];
60+ }
61+
62+ // サポートされていない場合は色を無効にする
63+ if (PHP_SAPI !== 'cli' || strtoupper(substr(PHP_OS, 0, 3)) == 'WIN'
64+ && !getenv('ANSICON')) {
65+ $this->colorOutput = false;
66+ }
67+ }
68+
69+ /**
70+ * 各テストの前に実行する関数を登録する
71+ *
72+ * @param callable $callback コールバック関数
73+ * @return Tester このインスタンス
74+ */
75+ public function beforeEach(callable $callback): Tester {
76+ $this->beforeEachCallbacks[] = $callback;
77+ return $this;
78+ }
79+
80+ /**
81+ * 各テストの後に実行する関数を登録する
82+ *
83+ * @param callable $callback コールバック関数
84+ * @return Tester このインスタンス
85+ */
86+ public function afterEach(callable $callback): Tester {
87+ $this->afterEachCallbacks[] = $callback;
88+ return $this;
89+ }
90+
91+ /**
92+ * すべてのテストの前に実行する関数を登録する
93+ *
94+ * @param callable $callback コールバック関数
95+ * @return Tester このインスタンス
96+ */
97+ public function beforeAll(callable $callback): Tester {
98+ $this->beforeAllCallbacks[] = $callback;
99+ return $this;
100+ }
101+
102+ /**
103+ * すべてのテストの後に実行する関数を登録する
104+ *
105+ * @param callable $callback コールバック関数
106+ * @return Tester このインスタンス
107+ */
108+ public function afterAll(callable $callback): Tester {
109+ $this->afterAllCallbacks[] = $callback;
110+ return $this;
111+ }
112+
113+ /**
114+ * テストケースを定義する
115+ *
116+ * @param string $description テストケースの説明
117+ * @param callable $callback テストケース関数
118+ * @return Tester このインスタンス
119+ */
120+ public function describe(string $description, callable $callback): Tester {
121+ $this->currentTestCase = $description;
122+ $this->output($this->colorize('bold', "テストケース: {$description}"));
123+
124+ try {
125+ foreach ($this->beforeAllCallbacks as $before) {
126+ call_user_func($before);
127+ }
128+
129+ call_user_func($callback, $this);
130+
131+ foreach ($this->afterAllCallbacks as $after) {
132+ call_user_func($after);
133+ }
134+ } catch (\Throwable $e) {
135+ $this->recordError(
136+ "テストケースのセットアップ/ティアダウンでエラー: ".$e->getMessage(),
137+ $e->getTraceAsString());
138+ }
139+
140+ $this->output('');
141+ return $this;
142+ }
143+
144+ /**
145+ * 単一のテストを実行する
146+ *
147+ * @param string $description テストの説明
148+ * @param callable $callback テスト関数
149+ * @return Tester このインスタンス
150+ */
151+ public function it(string $description, callable $callback): Tester {
152+ $this->currentTest = $description;
153+ $this->testCount++;
154+
155+ if ($this->verboseOutput) {
156+ $this->output(" ⋄ テスト中: {$description}... ", false);
157+ }
158+
159+ try {
160+ foreach ($this->beforeEachCallbacks as $before) {
161+ call_user_func($before);
162+ }
163+
164+ call_user_func($callback, $this);
165+
166+ foreach ($this->afterEachCallbacks as $after) {
167+ call_user_func($after);
168+ }
169+
170+ // Test has passed.
171+ $this->passCount++;
172+
173+ if ($this->verboseOutput) {
174+ $this->output($this->colorize('green', "合格"));
175+ }
176+ } catch (AssertionFailedException $e) {
177+ $this->failCount++;
178+
179+ if ($this->verboseOutput) {
180+ $this->output($this->colorize('red', "失敗"));
181+ $this->output($this->colorize('red', " → ".$e->getMessage()));
182+ }
183+
184+ $this->recordFailure($e->getMessage());
185+
186+ if ($this->stopOnFailure) {
187+ $this->printSummary();
188+ exit(1);
189+ }
190+ } catch (\Throwable $e) {
191+ $this->errorCount++;
192+
193+ if ($this->verboseOutput) {
194+ $this->output($this->colorize('yellow', "エラー"));
195+ $this->output($this->colorize('yellow', " → ".$e->getMessage()));
196+ }
197+
198+ $this->recordError($e->getMessage(), $e->getTraceAsString());
199+
200+ if ($this->stopOnFailure) {
201+ $this->printSummary();
202+ exit(1);
203+ }
204+ }
205+
206+ return $this;
207+ }
208+
209+ /**
210+ * テストをスキップする
211+ *
212+ * @param string $description テストの説明
213+ * @param string $reason スキップする理由。デフォルト: "まだ実装されていません"
214+ * @return Tester このインスタンス
215+ */
216+ public function skip(string $description,
217+ string $reason = 'まだ実装されていません'): Tester {
218+ if ($this->verboseOutput) {
219+ $this->output(" ⋄ スキップ: {$description}... "
220+ .$this->colorize('cyan', "スキップ"));
221+ $this->output($this->colorize('cyan', " → {$reason}"));
222+ }
223+
224+ return $this;
225+ }
226+
227+ /**
228+ * 条件がtrueである事をアサートする
229+ *
230+ * @param bool $condition チェックする条件
231+ * @param string $message 失敗時のオプションメッセージ
232+ * @throws AssertionFailedException アサーションが失敗した場合
233+ * @return Tester このインスタンス
234+ */
235+ public function assertTrue(bool $condition,
236+ string $message = '条件がtrueであることを期待しました'): Tester {
237+ if ($condition !== true) {
238+ throw new AssertionFailedException($message);
239+ }
240+
241+ return $this;
242+ }
243+
244+ /**
245+ * 条件がfalseである事をアサートする
246+ *
247+ * @param bool $condition チェックする条件
248+ * @param string $message 失敗時のオプションメッセージ
249+ * @throws AssertionFailedException アサーションが失敗した場合
250+ * @return Tester このインスタンス
251+ */
252+ public function assertFalse(bool $condition,
253+ string $message = '条件がfalseであることを期待しました'): Tester {
254+ if ($condition !== false) {
255+ throw new AssertionFailedException($message);
256+ }
257+
258+ return $this;
259+ }
260+
261+ /**
262+ * 二つの値が等しい事をアサートする
263+ *
264+ * @param mixed $expected 期待値
265+ * @param mixed $actual 実際の値
266+ * @param string|null $message 失敗時のオプションメッセージ
267+ * @throws AssertionFailedException アサーションが失敗した場合
268+ * @return Tester このインスタンス
269+ */
270+ public function assertEquals(mixed $expected, mixed $actual,
271+ ?string $message = null): Tester {
272+ if ($expected != $actual) {
273+ if ($message === null) {
274+ $expected = $this->exportValue($expected);
275+ $actual = $this->exportValue($actual);
276+ $message = "{$expected}を期待しましたが、{$actual}が得られました";
277+ }
278+
279+ throw new AssertionFailedException($message);
280+ }
281+
282+ return $this;
283+ }
284+
285+ /**
286+ * 二つの値が同一である事をアサートする
287+ *
288+ * @param mixed $expected 期待値
289+ * @param mixed $actual 実際の値
290+ * @param string|null $message 失敗時のオプションメッセージ
291+ * @throws AssertionFailedException アサーションが失敗した場合
292+ * @return Tester このインスタンス
293+ */
294+ public function assertSame(mixed $expected, mixed $actual,
295+ ?string $message = null): Tester {
296+ if ($expected !== $actual) {
297+ if ($message === null) {
298+ $expected = $this->exportValue($expected);
299+ $actual = $this->exportValue($actual);
300+ $message =
301+ "{$expected}を期待しましたが、{$actual}が得られました(厳密な比較)";
302+ }
303+
304+ throw new AssertionFailedException($message);
305+ }
306+
307+ return $this;
308+ }
309+
310+ /**
311+ * 値がnullである事をアサートする
312+ *
313+ * @param mixed $actual チェックする値
314+ * @param string|null $message 失敗時のオプションメッセージ
315+ * @throws AssertionFailedException アサーションが失敗した場合
316+ * @return Tester このインスタンス
317+ */
318+ public function assertNull(mixed $actual, ?string $message = null): Tester {
319+ if ($actual !== null) {
320+ if ($message === null) {
321+ $actual = $this->exportValue($actual);
322+ $message = "nullを期待しましたが、{$actual}が得られました";
323+ }
324+
325+ throw new AssertionFailedException($message);
326+ }
327+
328+ return $this;
329+ }
330+
331+ /**
332+ * 値がnullでない事をアサートする
333+ *
334+ * @param mixed $actual チェックする値
335+ * @param string|null $message 失敗時のオプションメッセージ
336+ * @throws AssertionFailedException アサーションが失敗した場合
337+ * @return Tester このインスタンス
338+ */
339+ public function assertNotNull(mixed $actual, ?string $message = null): Tester {
340+ if ($actual === null) {
341+ if ($message === null) {
342+ $message = "値がnullでない事を期待しました";
343+ }
344+
345+ throw new AssertionFailedException($message);
346+ }
347+
348+ return $this;
349+ }
350+
351+ /**
352+ * 値が特定のキーを持つ事をアサートする
353+ *
354+ * @param mixed $key チェックするキー
355+ * @param array $array チェックする配列
356+ * @param string|null $message 失敗時のオプションメッセージ
357+ * @throws AssertionFailedException アサーションが失敗した場合
358+ * @return Tester このインスタンス
359+ */
360+ public function assertArrayHasKey(mixed $key, array $array,
361+ ?string $message = null): Tester {
362+ if (!is_array($array) && !($array instanceof \ArrayAccess)) {
363+ throw new AssertionFailedException(
364+ '第2引数は配列又はArrayAccessを実装している必要があります');
365+ }
366+
367+ if (!array_key_exists($key, $array)) {
368+ if ($message === null) {
369+ $message = "配列がキー '{$key}' を持つ事を期待しました";
370+ }
371+
372+ throw new AssertionFailedException($message);
373+ }
374+
375+ return $this;
376+ }
377+
378+ /**
379+ * 文字列がサブ文字列を含むことをアサートする
380+ *
381+ * @param string $needle 検索するサブ文字列
382+ * @param string $haystack 検索対象の文字列
383+ * @param string|null $message 失敗時のオプションメッセージ
384+ * @throws AssertionFailedException アサーションが失敗した場合
385+ * @return Tester このインスタンス
386+ */
387+ public function assertStringContains(string $needle, string $haystack,
388+ ?string $message = null): Tester {
389+ if (!is_string($needle) || !is_string($haystack)) {
390+ throw new AssertionFailedException('両方の引数は文字列である必要があります');
391+ }
392+
393+ if (strpos($haystack, $needle) === false) {
394+ if ($message === null) {
395+ $message = "文字列 '{$haystack}' が '{$needle}' を含む事を期待しました";
396+ }
397+
398+ throw new AssertionFailedException($message);
399+ }
400+
401+ return $this;
402+ }
403+
404+ /**
405+ * コールバックが例外をスローする事をアサートする
406+ *
407+ * @param callable $callback 実行するコールバック
408+ * @param string $exceptionClass 期待される例外クラス
409+ * @param string|null $message 失敗時のオプションメッセージ
410+ * @throws AssertionFailedException アサーションが失敗した場合
411+ * @return Tester このインスタンス
412+ */
413+ public function assertThrows(callable $callback, string $exceptionClass,
414+ ?string $message = null): Tester {
415+ try {
416+ call_user_func($callback);
417+
418+ if ($message === null) {
419+ $message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした";
420+ }
421+
422+ throw new AssertionFailedException($message);
423+ } catch (\Throwable $e) {
424+ if (!($e instanceof $exceptionClass)) {
425+ if ($message === null) {
426+ $message = "'{$exceptionClass}' 型の例外を期待しましたが、"
427+ .get_class($e)." が得られました";
428+ }
429+
430+ throw new AssertionFailedException($message);
431+ }
432+ }
433+
434+ return $this;
435+ }
436+
437+ /**
438+ * Print a summary of the test results
439+ *
440+ * @return Tester
441+ */
442+ public function printSummary(): Tester {
443+ $this->output('');
444+ $this->output($this->colorize('bold', "テスト結果の概要:"));
445+ $this->output(" テスト総数: {$this->testCount}");
446+ $this->output(" ".$this->colorize('green', "合格: {$this->passCount}"));
447+
448+ if ($this->failCount > 0) {
449+ $this->output(" ".$this->colorize('red', "失敗: {$this->failCount}"));
450+ } else {
451+ $this->output(" 失敗: 0");
452+ }
453+
454+ if ($this->errorCount > 0) {
455+ $this->output(" ".$this->colorize('yellow', "エラー: {$this->errorCount}"));
456+ } else {
457+ $this->output(" エラー: 0");
458+ }
459+
460+ $this->output('');
461+
462+ // 失敗を書き出す
463+ if (count($this->failures) > 0) {
464+ $this->output($this->colorize('bold', "失敗:"));
465+
466+ foreach ($this->failures as $i => $f) {
467+ $num = $i + 1;
468+ $this->output(" {$num}) {$f['testCase']} → {$f['test']}");
469+ $this->output(" ".$this->colorize('red', $f['message']));
470+ $this->output('');
471+ }
472+ }
473+
474+ // エラーを書き出す
475+ if (count($this->errors) > 0) {
476+ $this->output($this->colorize('bold', "エラー:"));
477+
478+ foreach ($this->errors as $i => $e) {
479+ $num = $i + 1;
480+ $this->output(" {$num}) {$e['testCase']} → {$e['test']}");
481+ $this->output(" ".$this->colorize('yellow', $e['message']));
482+
483+ if (isset($e['trace'])) {
484+ $this->output(" ".$this->colorize('yellow', "スタックトレース:"));
485+ $this->output(" ".$this->colorize('yellow', $e['trace']));
486+ }
487+
488+ $this->output('');
489+ }
490+ }
491+
492+ if ($this->failCount === 0 && $this->errorCount === 0) {
493+ $this->output($this->colorize('green', "全てのテストに合格しました!"));
494+ } else {
495+ $this->output($this->colorize('red', "テストが失敗・エラーで完了しました。"));
496+ }
497+
498+ return $this;
499+ }
500+
501+ // 機能性メソッド
502+
503+ /**
504+ * コンソールにテキストを出力する
505+ *
506+ * @param string $text 出力するテキスト
507+ * @param bool $newline 改行を追加するかどうか
508+ * @return void
509+ */
510+ private function output(string $text, bool $newline = true): void {
511+ echo $text.($newline ? PHP_EOL : '');
512+ }
513+
514+ /**
515+ * 有効な場合はテキストに色を適用する
516+ *
517+ * @param string $color 色名
518+ * @param string $text 色付けするテキスト
519+ * @return string
520+ */
521+ private function colorize(string $color, string $text): string {
522+ if (!$this->colorOutput || !isset($this->colors[$color])) {
523+ return $text;
524+ }
525+
526+ return $this->colors[$color].$text.$this->colors['reset'];
527+ }
528+
529+ /**
530+ * 値を表示用の文字列としてエクスポートする
531+ *
532+ * @param mixed $value エクスポートする値
533+ * @return string
534+ */
535+ private function exportValue(mixed $value): string {
536+ if (is_null($value)) return 'null';
537+ if (is_bool($value)) return $value ? 'true' : 'false';
538+ if (is_array($value)) return 'Array('.count($value).')';
539+ if (is_object($value)) return get_class($value).' Object';
540+
541+ if (is_string($value)) {
542+ if (strlen($value) > 40) {
543+ return "'".substr($value, 0, 37)."...'";
544+ }
545+
546+ return "'{$value}'";
547+ }
548+
549+ return (string)$value;
550+ }
551+
552+ /**
553+ * テストの失敗を記録する
554+ *
555+ * @param string $message 失敗メッセージ
556+ * @return void
557+ */
558+ private function recordFailure(string $message): void {
559+ $this->failures[] = [
560+ 'testCase' => $this->currentTestCase,
561+ 'test' => $this->currentTest,
562+ 'message' => $message,
563+ ];
564+ }
565+
566+ /**
567+ * テストのエラーを記録する
568+ *
569+ * @param string $message エラーメッセージ
570+ * @param string|null $trace スタックトレース
571+ * @return void
572+ */
573+ private function recordError(string $message, ?string $trace = null): void {
574+ $this->errors[] = [
575+ 'testCase' => $this->currentTestCase,
576+ 'test' => $this->currentTest,
577+ 'message' => $message,
578+ 'trace' => $trace,
579+ ];
580+ }
581+}
582+
583+/**
584+ * アサーション失敗用のカスタム例外
585+ */
586+class AssertionFailedException extends \Exception {
587+}
@@ -0,0 +1,122 @@
1+<?php
2+namespace Site\Test;
3+
4+require_once __DIR__.'/../../../autoload.php';
5+
6+use Site\Lib\Tester;
7+use Site\Lib\Csv;
8+use Site\Lib\Delimiter;
9+
10+$test = new Tester([
11+ 'colorOutput' => true,
12+ 'verboseOutput' => true
13+]);
14+
15+$test->describe('Csvの基本的なパーシング', function($test): void {
16+ $test->it('簡単なCSVファイルをパーシングするはず', function($test): void {
17+ $str = "岩田聡,プロジューサー\n宮本茂,デザイナー\nJeffrey Epstein,幼児性愛者";
18+
19+ $tmpFile = tempnam(sys_get_temp_dir(), 'csv_test');
20+ file_put_contents($tmpFile, $str);
21+
22+ $csv = new Csv($tmpFile);
23+ $res = $csv->parse();
24+
25+ unlink($tmpFile);
26+
27+ $expect = [
28+ ["岩田聡", "プロジューサー"],
29+ ["宮本茂", "デザイナー"],
30+ ["Jeffrey Epstein", "幼児性愛者"]
31+ ];
32+
33+ $test->assertNotNull($res);
34+ $test->assertEquals($res, $expect);
35+ });
36+
37+ $test->it('異なるデリミタでCSVをパーシングするはず', function ($test): void {
38+ // セミコロン
39+ $semiStr = "岩田聡;プロジューサー\n宮本茂;デザイナー";
40+ $tmpFile = tempnam(sys_get_temp_dir(), 'csv_semi_test');
41+ file_put_contents($tmpFile, $semiStr);
42+
43+ $csv = new Csv($tmpFile);
44+ $res = $csv->parse(Delimiter::SEMICOLON);
45+
46+ unlink($tmpFile);
47+
48+ $expect = [
49+ ["岩田聡", "プロジューサー"],
50+ ["宮本茂", "デザイナー"]
51+ ];
52+
53+ $test->assertEquals($res, $expect, "セミコロンデリミタでパーシングに失敗");
54+
55+ // タブ
56+ $tabStr = "岩田聡\tプロジューサー\n宮本茂\tデザイナー";
57+ $tmpFile = tempnam(sys_get_temp_dir(), 'csv_tab_test');
58+ file_put_contents($tmpFile, $tabStr);
59+
60+ $csv = new Csv($tmpFile);
61+ $res = $csv->parse(Delimiter::TAB);
62+
63+ unlink($tmpFile);
64+
65+ $test->assertEquals($res, $expect, "タブデリミタでパーシングに失敗");
66+
67+ // パイプ
68+ $pipeStr = "岩田聡|プロジューサー\n宮本茂|デザイナー";
69+ $tmpFile = tempnam(sys_get_temp_dir(), 'csv_pipe_test');
70+ file_put_contents($tmpFile, $pipeStr);
71+
72+ $csv = new Csv($tmpFile);
73+ $res = $csv->parse(Delimiter::PIPE);
74+
75+ unlink($tmpFile);
76+
77+ $test->assertEquals($res, $expect, "パイプデリミタでパーシングに失敗");
78+ });
79+
80+ $test->it('ヘッダー付きCSVをパーシングするはず', function ($test) {
81+ $str = "name,job title\n岩田聡,プロジューサー\n宮本茂,デザイナー";
82+
83+ $tmpFile = tempnam(sys_get_temp_dir(), 'csv_header_test');
84+ file_put_contents($tmpFile, $str);
85+
86+ $csv = new Csv($tmpFile);
87+ $res = $csv->parse(Delimiter::COMMA, true); // isHeader = true
88+
89+ unlink($tmpFile);
90+
91+ $expect = [
92+ 'header' => ["name", "job title"],
93+ 'body' => [
94+ ["岩田聡", "プロジューサー"],
95+ ["宮本茂", "デザイナー"],
96+ ],
97+ ];
98+
99+ $test->assertNotNull($res);
100+ $test->assertEquals($res, $expect, "ヘッダーパーシングに失敗");
101+ });
102+
103+ $test->it('セル内にカンマを含むCSVをパーシングするはず', function ($test): void {
104+ $str = "\"守矢, 諏訪子\",エンジニア\n\"青, 猫ちゃん\",サーバー管理者";
105+
106+ $tmpFile = tempnam(sys_get_temp_dir(), 'csv_quoted_comma_test');
107+ file_put_contents($tmpFile, $str);
108+
109+ $csv = new Csv($tmpFile);
110+ $res = $csv->parse(\Site\Lib\delimiter::COMMA);
111+
112+ unlink($tmpFile);
113+
114+ $expect = [
115+ ["守矢, 諏訪子", "エンジニア"],
116+ ["青, 猫ちゃん", "サーバー管理者"]
117+ ];
118+
119+ $test->assertNotNull($res);
120+ $test->assertEquals($res, $expect, "セル内にカンマを含むパーシングに失敗");
121+ });
122+});
@@ -0,0 +1,92 @@
1+<?php
2+namespace Site\Test;
3+
4+require_once __DIR__.'/../../../autoload.php';
5+
6+use Site\Lib\Tester;
7+use Site\Lib\Curl;
8+
9+$test = new Tester([
10+ 'colorOutput' => true,
11+ 'verboseOutput' => true
12+]);
13+
14+$test->describe('Curlの基本的な機能性', function($test): void {
15+ $test->it('URLで使って新しいインスタンスを作成するはず', function($test): void {
16+ $curl = new Curl('https://076.moe');
17+ $test->assertNotNull($curl);
18+ });
19+
20+ $test->it('メソッドでURLを設定出来るはす', function($test): void {
21+ $curl = new Curl();
22+ $curl->setUrl('https://076.moe');
23+ $test->assertNotNull($curl);
24+ });
25+
26+ $test->it('ヘッダー文字を作成出来るはず', function($test): void {
27+ $curl = new Curl('https://076.moe');
28+ $curl->setHeaders([
29+ 'Accept' => 'application/json',
30+ 'User-Agent' => 'LoliTest/1.0'
31+ ]);
32+
33+ $reflectionClass = new \ReflectionClass($curl);
34+ $method = $reflectionClass->getMethod('buildHeaderString');
35+ $method->setAccessible(true);
36+
37+ $headerString = $method->invoke($curl);
38+ $test->assertStringContains('Accept: application/json', $headerString);
39+ $test->assertStringContains('User-Agent: LoliTest/1.0', $headerString);
40+ });
41+
42+ $test->skip('移転を対応するはず', '作成中・・・');
43+
44+ $test->it('メソッドチェーニングを対応するはず', function($test): void {
45+ $curl = new Curl();
46+ $result = $curl->setUrl('https://076.moe')
47+ ->setMethod('GET')
48+ ->setTimeout(30);
49+
50+ $test->assertSame($curl, $result);
51+ });
52+});
53+
54+$test->describe('Curl HTTP リクエスト', function($test): void {
55+ $networkAvailable = true;
56+
57+ if (!$networkAvailable) {
58+ $test->skip('076.moeでGETリクエストの確認', 'ネットワークが無効です。');
59+ $test->skip('postman-echo.comでPOSTリクエストの確認', 'ネットワークが無効です。');
60+ return;
61+ }
62+
63+ $test->it('076.moeでGETリクエストの確認', function($test): void {
64+ $curl = new Curl('https://076.moe');
65+ $result = $curl->execute();
66+
67+ $test->assertTrue($result);
68+ $test->assertEquals(200, $curl->getResponseCode());
69+ $test->assertNotNull($curl->getResponseBody());
70+ $test->assertStringContains('<html', $curl->getResponseBody());
71+ });
72+
73+ $test->it('postman-echo.comでPOSTリクエストの確認', function($test): void {
74+ $curl = new Curl();
75+ $curl->setUrl('https://postman-echo.com/post')
76+ ->setMethod('POST')
77+ ->setPostFields([
78+ 'name' => '山田太郎',
79+ 'email' => 't.yamada@example.com'
80+ ]);
81+
82+ $result = $curl->execute();
83+ $test->assertTrue($result);
84+ $test->assertEquals(200, $curl->getResponseCode());
85+
86+ $responseBody = $curl->getResponseBody();
87+ $test->assertStringContains('山田太郎', $responseBody);
88+ $test->assertStringContains('t.yamada@example.com', $responseBody);
89+ });
90+});
91+
92+$test->printSummary();
@@ -0,0 +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+ }
64+});
追加 tester.php
@@ -0,0 +1,62 @@
1+<?php
2+require_once __DIR__.DIRECTORY_SEPARATOR.'/autoload.php';
3+
4+$testDir = __DIR__.DIRECTORY_SEPARATOR.'src/Site/Test';
5+$testFiles = glob($testDir.'/*.php');
6+
7+if (empty($testFiles)) {
8+ echo "テストファイルは{$testDir}にありません\n";
9+ exit(1);
10+}
11+
12+echo "{$testDir}にある".count($testFiles)."個テストファイルを実行中:\n";
13+echo "------------------------------------------------\n";
14+
15+$totalFiles = 0;
16+$successFiles = 0;
17+$failedFiles = [];
18+
19+foreach ($testFiles as $testFile) {
20+ $filename = basename($testFile);
21+ echo "{$filename}のテストを実行中... ";
22+
23+ $totalFiles++;
24+
25+ try {
26+ ob_start();
27+ require $testFile;
28+ $output = ob_get_clean();
29+
30+ echo "完成\n";
31+ echo $output;
32+ echo "\n";
33+
34+ $successFiles++;
35+ } catch (\Throwable $e) {
36+ $output = ob_get_clean();
37+ if (!empty($output)) {
38+ echo $output."\n";
39+ }
40+ echo "エラー: ".$e->getMessage()."\n";
41+ echo "ファイル: ".$e->getFile()." (行: ".$e->getLine().")\n\n";
42+ $failedFiles[] = [
43+ 'file' => $filename,
44+ 'error' => $e->getMessage(),
45+ 'line' => $e->getLine(),
46+ ];
47+ }
48+}
49+
50+echo "\n";
51+echo "テスト結果:\n";
52+echo "------------------------------------------------\n";
53+echo "テストファイル数: {$totalFiles}\n";
54+echo "成功: {$successFiles}\n";
55+echo "失敗: ".count($failedFiles)."\n";
56+
57+if (!empty($failedFiles)) {
58+ echo "\n失敗したテストファイル:\n";
59+ foreach ($failedFiles as $failed) {
60+ echo "- {$failed['file']} (行: {$failed['line']}): {$failed['error']}\n";
61+ }
62+}
追加 util.php
@@ -0,0 +1,61 @@
1+<?php
2+enum LogType {
3+ case ActivityPub;
4+ case Mailer;
5+ case MySQL;
6+ case Csv;
7+}
8+
9+function uuid(): string {
10+ $data = random_bytes(16);
11+ $data[6] = chr(ord($data[6]) & 0x0f | 0x40);
12+ $data[8] = chr(ord($data[8]) & 0x3f | 0x80);
13+ return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
14+}
15+
16+function kys(mixed $arg): void {
17+ echo '<style>html { color: #fcfcfc; background-color: #232023; } body { margin: 0; }</style>';
18+ echo '<header style="padding: 10px 0; display: flex; justify-content: space-evenly; background-color: #550f75; margin-bottom: 20px; position: sticky; top: 0;"><div><b>K</b>ILL</div> <div><b>Y</b>OUR</div> <div><b>S</b>ELF</div></header>';
19+ echo '<pre>';
20+ print_r($arg);
21+ echo '<pre>';
22+ die();
23+}
24+
25+function base58btc_encode(string $bin): string {
26+ $a = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
27+ $base = 58;
28+ $num = \gmp_import($bin, 1, GMP_LSW_FIRST | GMP_NATIVE_ENDIAN);
29+ $res = '';
30+
31+ if (\gmp_cmp($num, 0) == 0) return '1';
32+
33+ while (\gmp_cmp($num, 0) > 0) {
34+ $mod = \gmp_intval(\gmp_mod($num, $base));
35+ $res = $a[$mod].$res;
36+ $num = \gmp_div_q($num, $base);
37+ }
38+
39+ $bytes = str_split($bin);
40+ foreach ($bytes as $byte) {
41+ if (ord($byte) === 0) {
42+ $res = '1'.$res;
43+ } else {
44+ break;
45+ }
46+ }
47+
48+ return $res;
49+}
50+
51+function logger(LogType $section, mixed $arg): void {
52+ if (LOGGING_ENABLED) {
53+ $logfile = ROOT.'/log/';
54+ if ($section == LogType::ActivityPub) $logfile .= 'ap_log.txt';
55+ else if ($section == LogType::Mailer) $logfile .= 'mail_log.txt';
56+ else if ($section == LogType::MySQL) $logfile .= 'mysql_log.txt';
57+ else if ($section == LogType::Csv) $logfile .= 'csv_log.txt';
58+
59+ file_put_contents($logfile, $arg."\n", FILE_APPEND);
60+ }
61+}
@@ -0,0 +1,3 @@
1+{@ include(common/header) @}
2+ <p>見つけられません。ごめんなしゃーい!!</p>
3+{@ include(common/footer) @}
@@ -0,0 +1,6 @@
1+{@ include(common/header) @}
2+ <h1 class="paragraph">新ページ</h1>
3+ <p class="paragraph">
4+ 新ページだ
5+ </p>
6+{@ include(common/footer) @}
@@ -0,0 +1,16 @@
1+{@ include(common/header) @}
2+ <div class="news-article">
3+ {@ if (isset($meta->thumbnail) && $meta->thumbnail != '') @}
4+ <div class="thumbnail{{ isset($meta->thumborient) && $meta->thumborient != 'center' ? ' '.$meta->thumborient : '' }}">
5+ <img src="/static/article/{{ $meta->thumbnail }}" alt="" />
6+ </div>
7+ {@ endif @}
8+ <div class="meta">
9+ <div class="meta-date">{{ $meta->date }}</div>
10+ <div class="meta-author">{{ $meta->author }}</div>
11+ </div>
12+
13+ <h1 class="title">{{ $meta->title }}</h1>
14+ {{{ $article }}}
15+ </div>
16+{@ include(common/footer) @}
@@ -0,0 +1,8 @@
1+ </main>
2+ <footer>
3+ <address>Copyright{{ defined('COPYRIGHT_YEAR') ? ' '.COPYRIGHT_YEAR : '' }} © <a href="https://076.moe/">076スタジオ合同会社</a></address><br />
4+ Powered By <a href="https://technicalsuwako.moe/littlebeast">Little Beast</a>
5+ </footer>
6+ </div>
7+</body>
8+</html>
@@ -0,0 +1,59 @@
1+<!DOCTYPE html>
2+<html lang="ja">
3+<head>
4+ <meta content="text/html; charset=utf-8" http-equiv="content-type" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <link rel="icon" href="/favicon.ico" id="favicon">
7+ <title>{{ SITEINFO['title'] }}: {{ $pagetit }}</title>
8+ <link rel="stylesheet" type="text/css" href="/static/style.css" />
9+{@ if (isset($custCss) && !empty($custCss) && !is_bool($custCss)) @}
10+ {@ foreach ($custCss as $css) @}
11+ {{{ $css }}}
12+ {@ endforeach @}
13+{@ endif @}
14+{@ if (isset($meta)) @}
15+ <meta name="author" content="{{ $meta->author }}" />
16+ {@ if (isset($meta->thumbnail)) @}
17+ <meta name="thumbnail" content="/static/{{ $meta->thumbnail }}" />
18+ {@ endif @}
19+{@ endif @}
20+ <meta name="description" content="{{ $description }}" />
21+ <meta name="keywords" content="{{ SITEINFO['tags'].',' }}{@ if (isset($meta)) @}{@ foreach ($meta->category as $cat) @}{{ $cat.',' }}{@ endforeach @}{@ endif @}" />
22+
23+ <meta property="og:title" content="{{ SITEINFO['title'] }}: {{ $pagetit }}" />
24+ <meta property="og:description" content="{{ $description }}" />
25+ <meta property="og:type" content="{{ isset($meta) && isset($meta->thumbnail) ? 'article' : 'website' }}" />
26+ <meta property="og:url" content="{{ isset($_SERVER['REQUEST_URI']) ? 'https://'.$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'] : 'https://'.$_SERVER['HTTP_HOST'] }}" />
27+ {@ if (isset($meta) && isset($meta->thumbnail)) @}
28+ <meta property="og:image" content="https://{{ $_SERVER['HTTP_HOST'] }}/static/article/{{ $meta->thumbnail }}" />
29+ {@ endif @}
30+
31+ {@ if (TWITTER_HANDLE != '') @}
32+ <meta name="twitter:card" content="summary_large_image" />
33+ <meta name="twitter:card" content="{{ TWITTER_HANDLE }}" />
34+ <meta name="twitter:title" content="{{ SITEINFO['title'] }}: {{ $pagetit }}" />
35+ <meta name="twitter:url" content="{{ isset($_SERVER['REQUEST_URI']) ? 'https://'.$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'] : 'https://'.$_SERVER['HTTP_HOST'] }}" />
36+ <meta name="twitter:description" content="{{ $description }}" />
37+ {@ if (isset($meta) && isset($meta->thumbnail)) @}
38+ <meta name="twitter:image:src" content="https://{{ $_SERVER['HTTP_HOST'] }}/static/article/{{ $meta->thumbnail }}" />
39+ {@ endif @}
40+ {@ endif @}
41+ {@ if (ATOM_ENABLED) @}
42+ <link rel="alternate" type="application/atom+xml" title="{{ SITEINFO['title'] }} feed" href="/blog.atom" />
43+ {@ endif @}
44+</head>
45+<body>
46+ <div class="container">
47+ <header>
48+ <div class="logo">
49+ {{ SITEINFO['title'] }}
50+ </div>
51+ <nav>
52+{@ foreach ($menu as $m) @}
53+ {@ if ($m['show']) @}
54+ <a class="{{ $m['class'] }}{{ $curPage == $m['page'] ? ' active' : '' }}" href="{{ $m['href'] }}">{{ $m['text'] }}</a>
55+ {@ endif @}
56+{@ endforeach @}
57+ </nav>
58+ </header>
59+ <main>
@@ -0,0 +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>
23+ </article>
@@ -0,0 +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>
71+ {@ endif @}
@@ -0,0 +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>
6+ </div>
@@ -0,0 +1,9 @@
1+{@ include(common/header) @}
2+{@ include(common/search) @}
3+ <div class="news-grid">
4+ {@ foreach ($posts as $post) @}
5+ {@ include(common/newscard) @}
6+ {@ endforeach @}
7+ </div>
8+ {@ include(common/pagination) @}
9+{@ include(common/footer) @}
@@ -0,0 +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) @}
@@ -0,0 +1,6 @@
1+{@ include(common/header) @}
2+ <h1 class="paragraph">秘密のページ</h1>
3+ <p class="paragraph">
4+ 内緒だね~
5+ </p>
6+{@ include(common/footer) @}