commit 438c7d8aef6fd2522759f9ef17ebe17e3605b0fb Author: 守矢諏訪子 Date: Fri Nov 7 22:48:07 2025 +0900 SVNからのミラー diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4aa3fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +data/*.pem +data/*.txt +log/*.txt +config/config.php +public/static/*.pem diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..89d680c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,32 @@ +Copyright (c) 2025 テクニカル諏訪子 + +Permission is hereby granted to any person obtaining a copy of the software +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, +sublicense, and/or sell copies of the Software, subject to the following conditions: + + 1. **Origin Attribution**: + - You must not misrepresent the origin of the Software; you must not claim + you created the original Software. + - If the Software is used in a product, you must either: + a. Provide clear attribution in the product's documentation, user interface, + or other visible areas, **OR** + b. Pay the original developers a fee they specify in writing. + 2. **Usage Restriction**: + - The Software, or any derivative works, dependencies, or libraries + incorporating it, must not be used for censorship or to suppress freedom of + speech, expression, or creativity. Prohibited uses include, but are not + limited to: + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, + ideas, or objective reality. + - Tools or systems designed to restrict access to information or + artistic works. + 3. **Notice Preservation**: + - This license and the above copyright notice must remain intact in all copies + of the source code. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README-JP.md b/README-JP.md new file mode 100644 index 0000000..d96ab71 --- /dev/null +++ b/README-JP.md @@ -0,0 +1,188 @@ +# Little Beast +シンプル、実践的、アンチ・ブロート + +## Little Beast とは? +Little Beast は PHP 8.3 以上向けのフレームワークで、076.moe(ゲーム開発会社)と technicalsuwako.moe(社長のブログ)向けに作られました。\ +メイン考え方は「必要な物だけ拡張し、不要な物は削除する」です。\ +各コア機能はライブラリに分割されている為、必要な物だけを選び易い設計になっています。\ +全てのモジュールはゼロから書かれており、データベースは一切必要ありません。 + +Little Beast はテクニカル諏訪子がゲーム開発に完全復帰する前の最後の Web プロジェクトです。 + +## Little Beast が「ではない」物 +* 汎用フレームワーク +* 使い憎い +* Web 開発者向け +* 万人向け +* インストールが面倒 +* 教条的 +* 民主的に運営される + +## Little Beast が「持っていない」物 +* データベース(全てファイルベース) +* 依存関係 +* パッケージマネージャー +* Docker/Kubernetes/Vagrant/Nix 等のコンテナ +* JavaScript +* ブロートウェア +* ORM +* 認証/認可システム +* キューやバックグラウンドジョブ +* クラウド/サーバーレス統合 +* スキャフォールディング +* コード生成 +* 抽象化レイヤー +* 不要なファイル +* DEI、行動規範、その他の差別的慣行 + +## 独自機能 +* データベース不要 +* Composer や PEAR 不要 +* サーバーオーバーヘッドゼロ +* コンテナ不要 +* Maron テンプレートエンジン +* カスタム Markdown +* ActivityPub +* 多言語対応 +* マルチブログ対応 +* 100% 綺麗で正しい HTML5 と CSS3 +* 100% 正しい PHP +* SEO フレンドリー +* モジュラー CSS +* Atom フィード +* 組み込みテストスイート + +## インストール方法 +```sh +cd /var/www/htdocs +git clone https://github.com/TechnicalSuwako/LittleBeast.git . +mv config/config.sample.php config/config.php +``` + +HTTP サーバーを設定して `/public` をルートとして `php-fpm` で実行して下さい。\ +それだけです! + +### OpenBSD サーバー +```sh +pkg_add php-8.4.14 php-gmp-8.4.14 +rcctl enable php84_fpm httpd relayd +rcctl start php84_fpm httpd relayd +``` + +#### httpd +``` +server "technicalsuwako.moe" { + listen on * tls port 8443 + gzip-static + tls { + certificate "/etc/ssl/technicalsuwako.moe.crt" + key "/etc/ssl/private/technicalsuwako.moe.key" + } + root "/htdocs/technicalsuwako.moe/www/public" + directory index "index.php" + location "/.well-known/acme-challenge/*" { + root "/acme" + request strip 2 + } + + location "/*.php" { + fastcgi socket "/run/php-fpm.sock" + } + + location "/*.php[/?]*" { + fastcgi socket "/run/php-fpm.sock" + } + + location "/" { + directory index "index.php" + } + location match "/blog/" { + request rewrite "/index.php" + } + location match "/about" { + request rewrite "/index.php" + } + location match "/monero" { + request rewrite "/index.php" + } + location match "/secret" { + request rewrite "/index.php" + } + location match "/ap/" { + request rewrite "/index.php" + } + location "/.well-known/webfinger" { + request rewrite "/index.php" + } + location "/blog.atom" { + request rewrite "/index.php" + } +} + +server "www.technicalsuwako.moe" { + listen on * tls port 8443 + gzip-static + tls { + certificate "/etc/ssl/technicalsuwako.moe.crt" + key "/etc/ssl/private/technicalsuwako.moe.key" + } + block return 301 "https://technicalsuwako.moe$REQUEST_URI" +} +``` + +#### relayd +``` +relayd_addr="0.0.0.0" +router_addr="192.168.10.106" + +table { $router_addr } + +http protocol reverse { + tcp { nodelay, sack, socket buffer 65536, backlog 100 } + tls ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256" + tls keypair "technicalsuwako.moe" + + return error + + match request header append "X-Forwarded-For" value "$REMOTE_ADDR" + match request header append "X-Forwarded-Port" value "$REMOTE_PORT" + + #match response header set "Referrer-Policy" value "same-origin" + match response header set "X-Frame-Options" value "deny" + match response header set "X-Content-Type-Options" value "nosniff" + match response header set "Referrer-Policy" value "strict-origin-when-cross-origin" + match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload" + match response header set "Cross-Origin-Opener-Policy" value "same-origin" + match response header set "Content-Security-Policy" value "img-src 'self' https://*.076.moe http://*.076.moe https://*.technicalsuwako.moe http://*.technicalsuwako.moe; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; object-src 'none';" + match response header append "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=()" + + match request header "Accept-Encoding" value "gzip" tag "gzip" + match response header "Content-Type" value "text/*" tag "compress" + match response header "Content-Type" value "text/html" tag "charset=UTF-8" + match response header "Content-Type" value "application/javascript" tag "compress" + match response header "Content-Type" value "application/json" tag "compress" + + pass request quick header "Host" value "technicalsuwako.moe" forward to + + pass +} + +relay www_tls { + listen on $relayd_addr port 443 tls + protocol reverse + + # Default + forward to port 8443 check tcp +} + +relay www_www { + listen on $relayd_addr port 80 + protocol reverse + + # Default + forward to port 8080 check tcp +} +``` + +## 必要な PHP モジュール +* php_gmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..f37650b --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# Little Beast +Simple, Pragmatic, Anti-bloat + +## What is Little Beast? +Little Beast is a PHP 8.3 or above framework made for 076.moe (game developer company) and technicalsuwako.moe (CEO's blog).\ +The core mentality is to extend what you need, and remove what you don't need.\ +Each core feature is split into libraries, so it is easy to choose what you need.\ +All modules are written from scratch, nothing is to be require a database. + +Little Beast is TechnicalSuwako's final web project before moving back to game development completely. + +## What Little Beast is not? +* General purpose framework +* Hard to use +* Meant for web developers +* For everyone/wider audience +* Tedious to install +* Dogmatic +* Democratically run + +## What Little Beast has not? +* Database (everything is a file) +* Dependencies +* Package manager +* Docker/Kubernetes/Vagrant/Nix/etc containers +* Javascript +* Bloatware +* ORM +* Authentication/authorization system +* Queues or background job +* Cloud/Serverless integration +* Scaffolding +* Code generation +* Abstraction layers +* Unnecessary files +* DEI, Code of Conduct, or other discriminatory practises + +## Unique features +* No database +* No Composer or PEAR +* No server overhead +* No container +* Maron template engine +* Custom Markdown +* ActivityPub +* Multi-language support +* Multi-blog support +* 100% correct and beautiful HTML5 and CSS3 +* 100% correct PHP +* SEO friendly +* Modular CSS +* Atom feeds +* Built-in test suite + +## How to install +```sh +cd /var/www/htdocs +git clone https://github.com/TechnicalSuwako/LittleBeast.git . +mv config/config.sample.php config/config.php +``` + +Configure HTTP server to run `/public` as root via php-fpm.\ +That's all! + +### OpenBSD server +```sh +pkg_add php-8.4.14 php-gmp-8.4.14 +rcctl enable php84_fpm httpd relayd +rcctl start php84_fpm httpd relayd +``` + +#### httpd +``` +server "technicalsuwako.moe" { + listen on * tls port 8443 + gzip-static + tls { + certificate "/etc/ssl/technicalsuwako.moe.crt" + key "/etc/ssl/private/technicalsuwako.moe.key" + } + root "/htdocs/technicalsuwako.moe/www/public" + directory index "index.php" + location "/.well-known/acme-challenge/*" { + root "/acme" + request strip 2 + } + + location "/*.php" { + fastcgi socket "/run/php-fpm.sock" + } + + location "/*.php[/?]*" { + fastcgi socket "/run/php-fpm.sock" + } + + location "/" { + directory index "index.php" + } + location match "/blog/" { + request rewrite "/index.php" + } + location match "/about" { + request rewrite "/index.php" + } + location match "/monero" { + request rewrite "/index.php" + } + location match "/secret" { + request rewrite "/index.php" + } + location match "/ap/" { + request rewrite "/index.php" + } + location "/.well-known/webfinger" { + request rewrite "/index.php" + } + location "/blog.atom" { + request rewrite "/index.php" + } +} + +server "www.technicalsuwako.moe" { + listen on * tls port 8443 + gzip-static + tls { + certificate "/etc/ssl/technicalsuwako.moe.crt" + key "/etc/ssl/private/technicalsuwako.moe.key" + } + block return 301 "https://technicalsuwako.moe$REQUEST_URI" +} +``` + +#### relayd +``` +relayd_addr="0.0.0.0" +router_addr="192.168.10.106" + +table { $router_addr } + +http protocol reverse { + tcp { nodelay, sack, socket buffer 65536, backlog 100 } + tls ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256" + tls keypair "technicalsuwako.moe" + + return error + + match request header append "X-Forwarded-For" value "$REMOTE_ADDR" + match request header append "X-Forwarded-Port" value "$REMOTE_PORT" + + #match response header set "Referrer-Policy" value "same-origin" + match response header set "X-Frame-Options" value "deny" + match response header set "X-Content-Type-Options" value "nosniff" + match response header set "Referrer-Policy" value "strict-origin-when-cross-origin" + match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload" + match response header set "Cross-Origin-Opener-Policy" value "same-origin" + match response header set "Content-Security-Policy" value "img-src 'self' https://*.076.moe http://*.076.moe https://*.technicalsuwako.moe http://*.technicalsuwako.moe; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; object-src 'none';" + match response header append "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=()" + + match request header "Accept-Encoding" value "gzip" tag "gzip" + match response header "Content-Type" value "text/*" tag "compress" + match response header "Content-Type" value "text/html" tag "charset=UTF-8" + match response header "Content-Type" value "application/javascript" tag "compress" + match response header "Content-Type" value "application/json" tag "compress" + + pass request quick header "Host" value "technicalsuwako.moe" forward to + + pass +} + +relay www_tls { + listen on $relayd_addr port 443 tls + protocol reverse + + # Default + forward to port 8443 check tcp +} + +relay www_www { + listen on $relayd_addr port 80 + protocol reverse + + # Default + forward to port 8080 check tcp +} +``` + +## Required PHP module +* php_gmp diff --git a/autoload.php b/autoload.php new file mode 100644 index 0000000..646d26b --- /dev/null +++ b/autoload.php @@ -0,0 +1,11 @@ + ワイトもそう思います。\ +> も・・・亡者はそうは思いません。 + +## ケロケロ +| 名前 | キャラ | +|---------|---------| +| 諏訪子 | ロリ | +| チルノ | ⑨ | + +* 諏訪子 +* チルノ + +- ロリ +- ⑨ + +### ケロケロ +**ケロケロ**\ +*kerokero*\ +_ケロケロ_\ +~ケロケロ~ + +!:ケロケロ:!\ +!:(5)ケロケロ:!\ +!:(0.2)ケロケロ:! + +^(72)ケロケロ^\ +^(6)ケロケロ^ + +%(ff00ff)ケロケロ%\ +%(34FB56)ケロケロ% + +#### ケロケロ +![](https://ass.technicalsuwako.moe/banner.png)\ +![ケロケロ#max-width: 200px;](https://ass.technicalsuwako.moe/banner.png)\ +$[audio/ogg](https://ass.technicalsuwako.moe/砕月着信音.ogg)\ +#[video/mp4](https://ass.technicalsuwako.moe/フリーズ.mp4) + +##### ケロケロ +[076スタジオ合同会社](https://076.moe/)\ +<諏訪子>(すわこ)\ +`int i = 0;`\ + +###### ケロケロ +1. アセンブリ言語 +2. C言語 +3. その他 \ No newline at end of file diff --git a/blog/hajimemashite.md b/blog/hajimemashite.md new file mode 100644 index 0000000..677c661 --- /dev/null +++ b/blog/hajimemashite.md @@ -0,0 +1,9 @@ +title: はじめまして +uuid: b2308726-11a8-4d94-8e67-12babf4a9174 +author: Little-san +date: 2025-04-10 00:00:00 +category: test +---- +投稿テストです + +マークダウンで、「<漢字>(かんじ)」を<入力>(nyuuryoku)すると、<漢字>(かんじ)が表示する<!>(!) \ No newline at end of file diff --git a/blog/table-sample.md b/blog/table-sample.md new file mode 100644 index 0000000..9dbf114 --- /dev/null +++ b/blog/table-sample.md @@ -0,0 +1,22 @@ +title: テーブルの例 +uuid: 684d234e-f0d6-4da0-8f38-71c7105262d5 +author: Little-san +date: 2025-04-11 08:50:57 +category: test,css +css: table +---- +テーブルの例だ。 +複数CSSファイルを含むには、コンマで分けて下さい。 +例えば: `css: table,search` + +## プログラミング言語のランキング +| プログラミング言語 | ランキング | 理由 | +|-------|-----|----| +| C | ★★★★★ | どこでも使える | +| C++ | ★★★★★ | たーのし~ | +| PHP | ★★★★☆ | Little BeastをPHPで作ったから | +| Go | ★★★☆☆ | 言語は問題ないけど、会社は親DEI・・・ | +| Ruby | ★☆☆☆☆ | 遅過ぎる | +| Javascript | ★☆☆☆☆ | 🤡 | +| Rust | ★☆☆☆☆ | ゲイ!! | +| Zig | ★★★☆☆ | 良いけど、未だ開発中 | \ No newline at end of file diff --git a/config/config.sample.php b/config/config.sample.php new file mode 100644 index 0000000..2ab84b6 --- /dev/null +++ b/config/config.sample.php @@ -0,0 +1,40 @@ + 'Little Beast', + 'description' => 'PHP Framework', + 'tags' => 'game,tech,developer', +]); +define('MAILINFO', [ + 'from' => 'hogehoge@ho.ge', + 'host' => 'smtp.ho.ge', + 'port' => 587, + 'user' => 'hogehoge@ho.ge', + 'pass' => 'hogehogehoge', +]); +define('FEDIINFO', [ + 'actor' => 'loli', + 'actorNick' => 'ロリ', + 'desc' => '', + 'icon' => '/static/logo.png', + 'pubkey' => ROOT.'/public/static/pub.pem', + 'privkey' => ROOT.'/data/priv.pem', +]); +define('DBINFO', [ + 'host' => 'localhost', + 'username' => 'littlebeast', + 'password' => '', + 'dbname' => 'littlebeast', + 'port' => 3306, + 'debug' => false, +]); + +define('MAILER_ENABLED', false); +define('LOGGING_ENABLED', true); +define('ATOM_ENABLED', true); +define('RSS_ENABLED', false); +define('ACTIVITYPUB_ENABLED', false); +define('MYSQL_ENABLED', false); +define('CSV_ENABLED', false); +define('COPYRIGHT_YEAR', '2018-'.date('Y')); diff --git a/data/.kara b/data/.kara new file mode 100644 index 0000000..e69de29 diff --git a/doc/ja/about.md b/doc/ja/about.md new file mode 100644 index 0000000..6718c19 --- /dev/null +++ b/doc/ja/about.md @@ -0,0 +1,34 @@ +# Little Beast +Little Beast は、元々076.moeとtechnicalsuwako.moeの +為に作られたPHPフレームワークで、 +これらのウェブサイトが以前使用していた静的サイトジェネレーターを置き換える為に +開発されました。 +然し時間が経つに連れて、次々と機能を追加していき、 +最終的には完全なPHPフレームワークに近い物になった為、 +他の人々にも役立つかもしんと考えました。 + +然し、 Little Beast の開発者は、PHPフレームワークが知識とスキルを奪い、 +複雑で重いという理由で、その使用に反対しています。 +その為、 Little Beast は逆のアプローチを取り、 +ユーザーが裏側でどの様に動作しているかを理解出来る様に、 +出来るだけシンプルで軽量に保つ様にしています。 + +これは大量生産用に作られた物ではなく、 +寧ろ一からフレームワークを作る方法の参考として提供されています。 +実際には貴方が考えるよりもずっと簡単なのです。 + +この為に、以下の原則が確立されています: +* composerやその他のパッケージマネージャーは使用しません。 +* 全てのクラスと関数は一から書かれています。 +* 開発者がフレームワークのコードを探索する事を推奨している為、 + 全てのクラスは徹底的に文書化されています。 +* 全てのクラスは要点を押さえた物でなければならず、 + クラス間の移動は許可されていません。 +* コンテナ化の必要性を排除し、必要なのはPHPとウェブサーバーだけです。 +* 常に最新のPHPバージョンに対応し、警告や非推奨の通知は許容されません。 +* 不要と判断されたコードは全て削除します。 + +全てのライブラリは特に他の何にも依存しない様に設計されているので、 +使用する予定のないクラスを削除する事を恐れないで下さい。 +後で必要と判断された場合でも、シンプルなドラッグアンドドロップで追加し +直す事が出来ます。 diff --git a/doc/ja/how-it-works.md b/doc/ja/how-it-works.md new file mode 100644 index 0000000..6ea3f48 --- /dev/null +++ b/doc/ja/how-it-works.md @@ -0,0 +1,34 @@ +# 仕組み + +Little Beastには魔法の様に動作する5つの主要ファイルが含まれていますが、 +どれも複雑ではありません。 +Webサーバーの設定で、ルートディレクトリを `/public` に +、インデックスファイルを `index.php` 設定します。 +このindex.phpファイルがすることは、 `/public` ディレクトリの +下にある `route.php` ファイルをインクルードするだけです。 +この設定により、クライアントは `/public` ディレクトリ内のファイルにのみ +アクセス出来る様になります。 +これには画像、スタイルシート、JavaScript等が含まれます。 +従って、設定、ルート、コントローラー等の機密データは、プログラマーが明示的に +アクセス可能にしない限りアクセス出来ません。 +`/public/index.php` は編集しない様に設計されています。 + +`route.php` ファイルには、以下のファイルがインクルードされています: +* autoload.php +* config/config.php +* util.php + +`autoload.php` ファイルは、バックエンド操作を全て含むsrcディレクトリ内で +オブジェクト指向プログラミングを可能にする為の物です。 +このファイルは、何をしているか理解している場合を除き、 +編集すべきではありません。 + +`config/config.php` ファイルには、シンプルな `define` 文として全ての +設定が含まれています。 +設定可能な物は全てサンプルファイルに含まれています。 +プログラマーは必要に応じて他の変数を追加する事が出来ますが、 +事前定義された物を削除しない事をお勧めします。 + +そして、 `util.php` ファイルには、通常のPHPでは利用出来ない便利なカスタム +機能が全て含まれています。 +プログラマーは必要に応じて追加機能を加える事が出来ます。 diff --git a/doc/ja/maron.md b/doc/ja/maron.md new file mode 100644 index 0000000..bc9b43f --- /dev/null +++ b/doc/ja/maron.md @@ -0,0 +1,89 @@ +# Maron テンプレートの仕組み +Maron は Little Beast のカスタム HTML テンプレートエンジンです。 + +## 構文 + +構文は以下の通りです: + +``` +{@ include(common/header.maron) @} // これは別の Maron ファイルを含めます。 + // シングルクォートやダブルクォートを + // 追加しないで下さい! + +{@ kys($var) @} // これは変数を読み易い形式で出力し、 + // その後テンプレートの残りの実行を終了します。 + +{@ if (true) @} + // 何かを実行 +{@ elif (null) @} + // 別の事を実行 +{@ else @} + // その他を実行 +{@ endif @} + +{@ foreach ($array as $value) @} +{{ $value }}
+{@ endforeach @} + +{@ foreach ($array as $key => $value) @} +{{ $key }}: {{ $value }}
+{@ endforeach @} + +{@ for ($i = 0; $i < 10; $i++) @} +{{ $i }}
+{@ endfor @} + +{# This is a comment #} +{{ $var }} // これは特殊文字をエスケープして変数を出力します +{{{ $var }}} // これは変数をそのままの形で出力します +{$ $var = 200 $} // これは $var を 200 として定義します +{! echo "hello world"; !} // これは生の PHP コードをそのまま実行します +``` + +## コントローラー機能 +更に、Little Beast では Maron テンプレート内で使用出来る +コントローラー用の機能も提供しています。 +多言語ウェブサイトを使用する場合、 +`new Template()` の後にその言語のルートディレクトリを指定出来ます。 +但し、これには `/view` ディレクトリ内にそれらのルートディレクトリも +設定する必要があります。 +例えば、構造が `/view/en/index.maron` の場合、 +`new Template('/en')` と定義します。 +然し構造が `/view/index.maron` の場合は、 +単に `new Template()` とします。 + +### assign() +`->assign()` メソッドを使用すると、 +Maron テンプレートで使用したい変数を割り当てます。 + +例: +``` +$tmpl = new Template(); + +$sum = 1 + 1; +$tmpl->assign('total', $sum); +``` + +この場合、Maron テンプレートで `{{ $total }}` を +使用して `2` を出力します。 + +### addCss() +デフォルトでは、 `style.css` は常に含まれています。 +但し、全てのページ又は大部分のページで使用する +予定の物だけを `style.css` に入れ、 +特定のページでのみ使用する予定の全ての物は、 +`style-` の接頭辞を付けた独自のファイルに入れる事をお勧めします。 +例えば、お問い合わせページがあり、 +そのページでのみ使用する CSS がある場合は、 +`/public/static` に新しい `style-contact.css` ファイルを作成し、 +`render` メソッドの前に `$tmpl->addCss('contact');` を追加します。 + +### render() +`render()` メソッドは Maron テンプレートの内容を取得し +、ウェブブラウザが理解出来る有効な HTML と PHP に全てを変換します。 +Maron ファイルが `/view/index.maron` の場合、 +`$tmpl->render('index');` を実行しますが、 +`/view/en/index.maron` の場合、 +`$tmpl = new Template('/en');` に続いて `$tmpl->render('index');` か、 +`$tmpl = new Template();` に続いて `$tmpl->render('en/index');` の +いずれかを実行出来ます。 diff --git a/doc/ja/routes.md b/doc/ja/routes.md new file mode 100644 index 0000000..8a0e363 --- /dev/null +++ b/doc/ja/routes.md @@ -0,0 +1,16 @@ +# ルートの仕組み + +`route.php` では、 `use Site\Lib\Route; `というインクルードもある事に +気づくでしょう。 +これが全てのルートを処理します。 +ルートの基本的な流れは次の通りです: + +1. 配列を定義します。 +2. `Route::add(メソッドタイプ, クラス名 + メソッド, オプションのパラメータ);` で + 配列内に新しいルートを追加します。 +3. `Route::init(ルート配列)` でルートを初期化します。 +4. ルートが見つからない場合に備えて、 + `Route::setFallback(コントローラークラス, そのクラス内のメソッド)` で + フォールバックを設定します。 +5. これで `Route::dispatch()` はクライアントが行くルートをチェックし、 + 正しいページに送信する事が出来ます。 diff --git a/log/.kara b/log/.kara new file mode 100644 index 0000000..e69de29 diff --git a/newpost.php b/newpost.php new file mode 100644 index 0000000..d5c9798 --- /dev/null +++ b/newpost.php @@ -0,0 +1,16 @@ + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..5a66ba4 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..f10d21d --- /dev/null +++ b/public/index.php @@ -0,0 +1,3 @@ + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..12c4d54 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +User-agent: * Disallow: diff --git a/public/static/article/.kara b/public/static/article/.kara new file mode 100644 index 0000000..e69de29 diff --git a/public/static/article/o_53803618dc1691.28179609-orig.jpg b/public/static/article/o_53803618dc1691.28179609-orig.jpg new file mode 100644 index 0000000..c27bbce Binary files /dev/null and b/public/static/article/o_53803618dc1691.28179609-orig.jpg differ diff --git a/public/static/style-algebra.css b/public/static/style-algebra.css new file mode 100644 index 0000000..ee9ac2c --- /dev/null +++ b/public/static/style-algebra.css @@ -0,0 +1,20 @@ +.fraction { + display: inline-block; + vertical-align: middle; + text-align: center; + position: relative; + margin: 0 0.2em; +} + +.fraction .numerator, .fraction .denominator { + display: block; + font-size: 0.8em; +} + +.fraction .numerator { + border-bottom: 1px solid #fcfcfc; +} + +.algebraic { + font-family: 'Times New Roman', serif; +} \ No newline at end of file diff --git a/public/static/style-blink.css b/public/static/style-blink.css new file mode 100644 index 0000000..6dd902f --- /dev/null +++ b/public/static/style-blink.css @@ -0,0 +1,9 @@ +.blink { + animation: blinker 1s linear infinite; +} + +@keyframes blinker { + 50% { + opacity: 0; + } +} \ No newline at end of file diff --git a/public/static/style-blockquote.css b/public/static/style-blockquote.css new file mode 100644 index 0000000..0b8f70f --- /dev/null +++ b/public/static/style-blockquote.css @@ -0,0 +1,8 @@ +blockquote { + background: #121012; + border: 2px solid #f545f5; + border-radius: 2px; + border-left: 12px solid #c016c6; + margin: 1.5em 10px; + padding: 0.5em 10px; +} \ No newline at end of file diff --git a/public/static/style-blogtype.css b/public/static/style-blogtype.css new file mode 100644 index 0000000..953866f --- /dev/null +++ b/public/static/style-blogtype.css @@ -0,0 +1,36 @@ +.blog-type > p { + font-weight: bolder; +} + +a.blog-type-btn { + background-color: #550f75; + color: #120f12; + text-decoration: none; + border: 1px solid #fcfcfc; + border-radius: 4px; + padding: 4px; + margin: 4px; + transition: background-color 0.9s; +} + +a.blog-type-btn.active { + background-color: #c016c6; +} + +hr.blog-type-line { + border: 3px dotted #c016c6; +} + +a.blog-type-btn:hover { + background-color: #ea79d8; +} + +a.blog-type-btn.active:ae6bdb { + background-color: #ae6bdb; +} + +@media only screen and (max-width: 768px) { + a.blog-type-btn { + display: block; + } +} \ No newline at end of file diff --git a/public/static/style-code.css b/public/static/style-code.css new file mode 100644 index 0000000..b429522 --- /dev/null +++ b/public/static/style-code.css @@ -0,0 +1,18 @@ +pre { + background: #232023; + color: fcfcfc; + padding: 1rem; + border-radius: 6px; + overflow: auto; + font-family: Consolas,Monaco,monospace; + border: 1px solid #f545f5; +} + +/*pre > code { + color: #fcfcfc; +}*/ + +code { + color: #b421f8; + line-height: 1.45; +} \ No newline at end of file diff --git a/public/static/style-diffviewer.css b/public/static/style-diffviewer.css new file mode 100644 index 0000000..dd89060 --- /dev/null +++ b/public/static/style-diffviewer.css @@ -0,0 +1,40 @@ +.diff-table { + width: 100%; + border-collapse: collapse; + font-family: monospace; + color: #fcfcfc; +} + +.diff-table td { + border: 1px solid #bcb4bc; + padding: 5px; + vertical-align: top; +} + +.diff-header th { + border: 1px solid #bcb4bc; +} + +.line-number { + width: 50px; + text-align: right; + color: #c016c6; +} + +.removed { + background-color: #fa9faa; + color: #b61729; +} + +.added { + background-color: #88ecc1; + color: #2c980c; +} + +.context { + background-color: #232320; +} + +.empty { + background-color: #746c75; +} \ No newline at end of file diff --git a/public/static/style-news-article.css b/public/static/style-news-article.css new file mode 100644 index 0000000..4f43327 --- /dev/null +++ b/public/static/style-news-article.css @@ -0,0 +1,73 @@ +.thumbnail { + text-align: center; + width: 100%; + max-height: 300px; + overflow: hidden; + margin-bottom: 20px; + position: relative; +} + +.thumbnail > img { + width: 100%; + height: 300px; + object-fit: cover; + object-position: center; + display: block; +} + +.thumbnail.top > img { + object-position: top; +} + +.thumbnail.bottom > img { + object-position: bottom; +} + +.meta { + display: inline; +} + +.meta-author, .meta-date, .meta-category { + display: inline-block; + margin: 4px; + padding: 8px; + border: 1px solid #ea79d8; + border-radius: 10px; +} + +.meta-author, .meta-date { + font-weight: bolder; +} + +.meta-author { + background-color: #c016c6; +} + +.meta-date { + background-color: #550f75; + color: #fcfcfc; +} + +.meta-category { + background-color: #ae6bdb; +} + +h1, h2, h3, h4, h5, h6 { + border-bottom: 1px solid #ea79d8; +} + +/* 死ねー */ +@media screen and (max-width: 480px) { + .news-meta { + flex-direction: column; + align-items: flex-start; + gap: 0.3rem; + } + + .meta-date, .meta-author, .meta-category { + width: 90%; + margin: 4px auto; + text-align: center; + display: block; + } +} \ No newline at end of file diff --git a/public/static/style-news.css b/public/static/style-news.css new file mode 100644 index 0000000..7997e7f --- /dev/null +++ b/public/static/style-news.css @@ -0,0 +1,117 @@ +.news-grid { + display: block; + padding: 1rem; +} + +.news-card { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + margin: 12px 0px; + background: #232023; + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s; +} + +.news-card:hover { + transform: translateY(-5px); +} + +.news-image { + width: 20%; + position: relative; + padding-top: 100px; +} + +.news-image img { + position: absolute; + top: 0; + left: 10px; + width: 100%; + height: 100%; + object-fit: cover; +} + +.news-content { + width: 74%; + padding: 1rem; +} + +.news-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.news-date { + background: #443b44; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +.news-category { + background: #c016c6; + color: #232320; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +.news-title { + margin: 0.5rem 0; + font-size: 1.25rem; +} + +.news-title a { + text-decoration: none; +} + +.news-title a:hover { + color: #f545f5; +} + +.news-preview { + font-size: 0.875rem; + margin: 0; +} + +/* 糞フォンの方向け */ +@media screen and (max-width: 768px) { + .news-card { + flex-direction: column; + align-items: stretch; + } + + .news-image { + width: 100%; + padding-top: 60%; + margin-bottom: 0.5rem; + } + + .news-image img { + left: 0; + } + + .news-content { + width: 100%; + padding: 0.75rem; + } +} + +/* 死ねー */ +@media screen and (max-width: 480px) { + .news-meta { + flex-direction: column; + align-items: flex-start; + gap: 0.3rem; + } + + .news-date, .news-category { + width: 80%; + text-align: center; + } +} diff --git a/public/static/style-pagination.css b/public/static/style-pagination.css new file mode 100644 index 0000000..411f8dc --- /dev/null +++ b/public/static/style-pagination.css @@ -0,0 +1,66 @@ +.pagination { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 0.5rem; + margin: 2rem 0; +} + +.page-link, .page-current, .page-ellipsis { + display: flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + height: 2.5rem; + padding: 0 0.5rem; + border-radius: 4px; + text-align: center; +} + +.page-link { + background: #c016c6; + color: #232023; + text-decoration: none; + transition: background 0.4s, transform 0.2s; +} + +.page-link:hover { + background: #ea79d8; + transform: translateY(-2px); +} + +.page-current { + background: #443b44; + color: #fcfcfc; + font-weight: bold; +} + +.page-ellipsis { + color: #c016c6; +} + +@media screen and (max-width: 768px) { + .pagination { + gap: 0.4rem; + } + + .page-link, .page-current, .page-ellipsis { + min-width: 2.2rem; + height: 2.2rem; + font-size: 0.9rem; + } +} + +@media screen and (max-width: 480px) { + .pagination { + gap: 0.3rem; + } + + .page-link, .page-current, .page-ellipsis { + min-width: 1.8rem; + height: 1.8rem; + font-size: 0.8rem; + padding: 0 0.3rem; + } +} diff --git a/public/static/style-search.css b/public/static/style-search.css new file mode 100644 index 0000000..55262e4 --- /dev/null +++ b/public/static/style-search.css @@ -0,0 +1,63 @@ +.search-highlight { + background-color: #c016c6; + color: #000; + font-weight: bold; + padding: 1px 2px; + border-radius: 3px; +} + +.search-form { + text-align: center; + margin: 20px 0; +} + +.search-form form { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 10px; +} + +.search-form input[type="text"] { + width: 60%; + padding: 8px; + border: 1px solid #ea79d8; + background-color: #232320; + color: #ea79d8; + border-radius: 4px; +} + +.search-form input[type="submit"] { + padding: 8px 16px; + background-color: #c016c6; + color: #12120f; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.3s; +} + +.search-form input[type="submit"]:hover { + background-color: #ea79d8; +} + +@media screen and (max-width: 768px) { + .search-form input[type="text"] { + width: 70%; + } +} + +@media screen and (max-width: 480px) { + .search-form form { + flex-direction: column; + align-items: center; + } + + .search-form input[type="text"] { + width: 90%; + } + + .search-form input[type="submit"] { + width: 90%; + } +} diff --git a/public/static/style-table.css b/public/static/style-table.css new file mode 100644 index 0000000..40ab1b3 --- /dev/null +++ b/public/static/style-table.css @@ -0,0 +1,23 @@ +table { + border-collapse: separate; + border-spacing: 0; + border: 1px solid #c016c6; + background-color: #232023; + margin: auto; + width: 95%; + max-width: 1100px; + overflow: hidden; +} + +th, td { + border-bottom: 1px solid #ea79d8; + text-align: left; + padding: 10px; + vertical-align: top; +} + +th { + background-color: #550f75; + color: #fcfcfc; + width: 300px; +} \ No newline at end of file diff --git a/public/static/style-tutorial.css b/public/static/style-tutorial.css new file mode 100644 index 0000000..3ab25f2 --- /dev/null +++ b/public/static/style-tutorial.css @@ -0,0 +1,16 @@ +hr { + border: 1px solid #ea79d8; +} + +.pager { + display: grid; + grid-template-columns: repeat(2, 1fr); +} + +.prev { + text-align: left; +} + +.next { + text-align: right; +} \ No newline at end of file diff --git a/public/static/style.css b/public/static/style.css new file mode 100644 index 0000000..9dac7eb --- /dev/null +++ b/public/static/style.css @@ -0,0 +1,97 @@ +html, body { + height: 100%; + margin: 0; + background-color: #232023; + color: #fcfcfc; +} + +a { + color: #ea79d8; +} + +header, main, footer { + border: 1px solid #c016c6; + margin: 20px; + padding: 8px; +} + +.logo, nav { + text-align: center; +} + +.logo { + font-size: 32px; + font-weight: bold; +} + +.menu-item { + padding: 0 4px; + transition: font-size 0.3s ease-in-out; +} + +.menu-item:hover { + font-size: 140%; +} + +.nodeco { + text-decoration: none; +} + +.menu-item.active { + border: 1px solid #ea79d8; + border-radius: 10px; + background-color: #550f75; + color: #ea79d8; + transition: background-color 0.2s ease-in-out, font-size 0.3s ease-in-out; +} + +.menu-item.active:hover { + background-color: #b421f8; +} + +.container { + max-width: 1280px; + margin: auto; + padding-bottom: 10px; +} + +h1.paragraph, p.paragraph { + text-align: left; +} + +footer { + text-align: center; +} + +header, main, footer, .sns:hover { + border-radius: 2px; +} + +@media screen and (max-width: 768px) { + header, main, footer { + margin: 10px; + padding: 5px; + } + + .logo { + font-size: 28px; + } + + nav { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 5px; + } +} + +@media screen and (max-width: 480px) { + header, main, footer { + margin: 5px; + padding: 5px; + } + + .logo { + font-size: 24px; + } +} diff --git a/route.php b/route.php new file mode 100644 index 0000000..a871fcf --- /dev/null +++ b/route.php @@ -0,0 +1,66 @@ +getPosts('/blog/'); + // 最新の5件に制限 + $posts = array_slice($posts, 0, 5); + + // サイトのドメインを取得 + $domain = $_SERVER['HTTP_HOST']; + $baseUrl = 'https://'.$domain; + + // 現在の日時(RFC3339形式) + $published = date('c'); + + // XMLヘッダーとコンテンツタイプを設定 + header('Content-Type: application/atom+xml; charset=utf-8'); + + // Atomフィードの開始部分 + echo ''."\n"; + echo ''."\n"; + + // フィードの基本情報 + echo ' '.SITEINFO['title'].''."\n"; + echo ' '."\n"; + echo ' '."\n"; + echo ' '.$baseUrl.'/'."\n"; + echo ' '.$published.''."\n"; + echo ' '.$published.''."\n"; + echo ' '."\n"; + echo ' '.SITEINFO['title'].''."\n"; + echo ' '."\n"; + + // 各エントリー(記事) + foreach ($posts as $post) { + // 記事の本文を取得(プレーンテキスト) + $path = ROOT.'/blog/'.$post['slug'].'.md'; + $content = ''; + $postPublished = date('c', strtotime($post['date'])); + + if (file_exists($path)) { + $fileContent = file_get_contents($path); + $parts = explode('----', $fileContent, 2); + if (count($parts) > 1) { + // 本文をHTMLとして準備 + $md = new Markdown($post['slug'], '/blog/'); + $content = $md->parse(); + // HTMLタグを取り除かないようにCDATAで囲む + $content = ''; + } + } + + echo ' '."\n"; + echo ' '.htmlspecialchars($post['title']).''."\n"; + echo ' '."\n"; + echo ' '.$baseUrl.'/blog/'.$post['slug'].''."\n"; + echo ' '.$postPublished.''."\n"; + + // カテゴリ(タグ) + if (isset($post['category']) && is_array($post['category'])) { + foreach ($post['category'] as $category) { + echo ' '."\n"; + } + } + + // 本文(要約または全文) + echo ' '.$content.''."\n"; + echo ' '."\n"; + } + + // フィードの終了 + echo ''; + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フィードの作成に失敗: '.$e->getMessage(); + exit; + } + } +} diff --git a/src/Site/Controller/BlogPost.php b/src/Site/Controller/BlogPost.php new file mode 100644 index 0000000..4f96af1 --- /dev/null +++ b/src/Site/Controller/BlogPost.php @@ -0,0 +1,66 @@ + $metadata['title'] ?? '', + 'date' => $metadata['date'] ?? '', + 'thumbnail' => $metadata['thumbnail'] ?? '', + 'thumborient' => $metadata['thumborient'] ?? '', + 'category' => $metadata['category'] ?? [], + 'uuid' => $metadata['uuid'] ?? '', + 'preview' => $preview, + 'slug' => $slug, + ]; + } + + // 日付でソート(新しい順) + usort($posts, function($a, $b) { + return strtotime($b['date']) - strtotime($a['date']); + }); + + return $posts; + } +} diff --git a/src/Site/Controller/Fediverse.php b/src/Site/Controller/Fediverse.php new file mode 100644 index 0000000..d8e7108 --- /dev/null +++ b/src/Site/Controller/Fediverse.php @@ -0,0 +1,151 @@ +getWebfinger(); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apactor(array $params): void { + try { + header('Content-Type: application/activity+json'); + $ap = new Activitypub(); + echo $ap->getActor(); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apinbox(array $params): void { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('HTTP/1.1 405 Method Not Allowed'); + header('Allow: POST'); + exit; + } + + $input = file_get_contents('php://input'); + $activity = json_decode($input, true); + if (!$activity || !isset($activity['type'])) { + header('HTTP/1.1 400 Bad Request'); + header('Content-Type: application/activity+json'); + echo json_encode(['error' => '不正なアクティビティ']); + exit; + } + + logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity)); + + try { + header('Content-Type: application/activity+json'); + $ap = new Activitypub(); + $ap->postInbox($activity); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apactivity(array $params): void { + $uuid = ''; + if (isset($params['uuid'])) $uuid = $params['uuid']; + + try { + header('Content-Type: application/activity+json'); + $posts = $this->getPosts('/blog/'); + $ap = new Activitypub($posts); + echo $ap->getActivity($uuid); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apoutbox(array $params): void { + try { + header('Content-Type: application/activity+json'); + $posts = $this->getPosts('/blog/'); + $ap = new Activitypub($posts); + echo $ap->getOutbox(); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apfollowers(array $params): void { + try { + header('Content-Type: application/activity+json'); + $ap = new Activitypub(); + echo $ap->getFollowers(); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } + + /** + * @param array $params パラメータ配列 + * @return void + */ + public function apfollowing(array $params): void { + try { + header('Content-Type: application/activity+json'); + $ap = new Activitypub(); + echo $ap->getFollowing(); + exit; + } catch (\Exception $e) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'フェディバースの作成に失敗: '.$e->getMessage(); + exit; + } + } +} diff --git a/src/Site/Controller/Home.php b/src/Site/Controller/Home.php new file mode 100644 index 0000000..4d58b93 --- /dev/null +++ b/src/Site/Controller/Home.php @@ -0,0 +1,252 @@ +getPosts('/blog/'); + if (!is_array($posts)) $posts = []; + + // 検索機能が使用されている場合 + if (isset($_GET['q']) && !empty($_GET['q'])) { + $this->searchKeywords = array_map('trim', explode(',', $_GET['q'])); + $posts = $this->searchPosts($this->searchKeywords, $posts, '/blog/'); + $pagetit = '検索結果: '.htmlspecialchars($_GET['q']); + + // 検索結果にキーワードをハイライト + $posts = $this->highlightKeywords($posts); + } + + // ページネーション + $totalPosts = count($posts); + $totalPages = ceil($totalPosts / $postsPerPage); + $page = min($page, $totalPages); + $currentPosts = array_slice( + $posts, + ($page - 1) * $postsPerPage, + $postsPerPage + ); + + $tmpl->assign('currentPage', $page); + $tmpl->assign('totalPages', $totalPages); + $tmpl->assign('posts', $currentPosts); + $tmpl->assign('pagetit', $pagetit); + $tmpl->assign('curblog', 'gd'); + $tmpl->assign('section', 'blog'); + $tmpl->assign('curPage', 'blog'); + $tmpl->assign('custCss', false); + $tmpl->assign('menu', $this->getMenu()); + $tmpl->assign('description', $description); + $tmpl->assign('searchActive', !empty($this->searchKeywords)); + + $tmpl->addCss('news'); + $tmpl->addCss('search'); + $tmpl->addCss('blogtype'); + $tmpl->addCss('pagination'); + $tmpl->render('home'); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + } + + /** + * ブログ投稿ページ + * + * @param array $params マークダウンファイル等 + * @return void + */ + public function article(array $params): void { + $page = ''; + if (isset($params['page'])) $page = $params['page']; + + try { + $tmpl = new Template('/'); + $md = new Markdown($page, '/blog/'); + + $meta = $md->getMetadata(); + $pagetit = $meta->title; + $article = $md->parse(); + $description = 'テクニカル諏訪子ちゃんの個人ブログ'; + + // 検索からの遷移の場合、記事内のキーワードをハイライト + if (isset($_GET['q']) && !empty($_GET['q'])) { + $keywords = array_map('trim', explode(',', $_GET['q'])); + $article = $this->highlightTextContent($article, $keywords); + $meta->title = $this->highlightTextContent($meta->title, $keywords); + } + + $tmpl->assign('pagetit', $pagetit); + $tmpl->assign('curPage', 'blog'); + $tmpl->assign('custCss', false); + $tmpl->assign('curblog', 'gd'); + $tmpl->assign('menu', $this->getMenu()); + $tmpl->assign('article', $article); + $tmpl->assign('meta', $meta); + $tmpl->assign('description', $description); + + if (isset($meta->css) && !empty($meta->css)) { + foreach ($meta->css as $v) { + $tmpl->addCss($v); + } + } + + $tmpl->addCss('news-article'); + $tmpl->addCss('search'); + $tmpl->addCss('blogtype'); + $tmpl->render('article'); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + } + + //------------------------------------------ + // 機能性 + //------------------------------------------ + + /** + * キーワードに基づいて投稿を検索する + * + * @param array $keywords 検索キーワードの配列 + * @param array $posts 検索対象の投稿記事の配列 + * @return array 検索条件に一致する投稿記事の配列 + */ + private function searchPosts(array $keywords, array $posts, string $section): array { + if (empty($keywords) || empty($posts)) { + return $posts; + } + + $foundPosts = []; + $path = ROOT.$section; + + foreach ($posts as $post) { + $matched = false; + + // タイトルで検索 + foreach ($keywords as $keyword) { + $keyword = trim($keyword); + if (empty($keyword)) continue; + + // タイトル内でキーワードが見つかった場合 + if (mb_stripos($post['title'], $keyword) !== false) { + $foundPosts[] = $post; + $matched = true; + break; + } + } + + // すでにマッチしていれば次の記事へ + if ($matched) continue; + + // 記事の本文をチェック + $slug = $post['slug']; + $filePath = $path.$slug.'.md'; + + if (file_exists($filePath)) { + $content = file_get_contents($filePath); + $parts = explode('----', $content, 2); + if (count($parts) > 1) { + $articleBody = trim($parts[1]); + + foreach ($keywords as $keyword) { + $keyword = trim($keyword); + if (empty($keyword)) continue; + + // 本文内でキーワードが見つかった場合 + if (mb_stripos($articleBody, $keyword) !== false) { + $foundPosts[] = $post; + break; + } + } + } + } + } + + return $foundPosts; + } + + /** + * 検索結果の投稿内のキーワードをハイライトする + * + * @param array $posts 検索結果の投稿配列 + * @return array ハイライト処理後の投稿配列 + */ + private function highlightKeywords(array $posts): array { + if (empty($this->searchKeywords) || empty($posts)) { + return $posts; + } + + foreach ($posts as &$post) { + // タイトルのハイライト + if (!empty($post['title'])) { + $post['title'] = + $this->highlightTextContent($post['title'], $this->searchKeywords); + } + + // プレビューのハイライト + if (!empty($post['preview'])) { + $post['preview'] = + $this->highlightTextContent($post['preview'], $this->searchKeywords); + } + } + + return $posts; + } + + /** + * テキスト内のキーワードをハイライトする + * + * @param string $text ハイライト対象のテキスト + * @param array $keywords ハイライトするキーワード配列 + * @return string ハイライト処理後のテキスト + */ + private function highlightTextContent(string $text, array $keywords): string { + if (empty($keywords) || empty($text)) { + return $text; + } + + $highlightedText = $text; + + foreach ($keywords as $keyword) { + $keyword = trim($keyword); + if (empty($keyword)) continue; + + // キーワードを大文字小文字を区別せずに置換 + $highlightedText = preg_replace( + '/('.preg_quote($keyword, '/').')/iu', + '$1', + $highlightedText + ); + } + + return $highlightedText; + } +} diff --git a/src/Site/Controller/Mods.php b/src/Site/Controller/Mods.php new file mode 100644 index 0000000..7b177dd --- /dev/null +++ b/src/Site/Controller/Mods.php @@ -0,0 +1,38 @@ + 'menu-item', + 'href' => '/', + 'page' => 'blog', + 'text' => 'トップ', + 'show' => true, + ], + [ + 'class' => 'menu-item', + 'href' => '/about', + 'page' => 'about', + 'text' => '自己紹介', + 'show' => true, + ], + [ + 'class' => 'menu-item', + 'href' => '/monero', + 'page' => 'monero', + 'text' => '支援♡', + 'show' => true, + ], + [ + 'class' => 'menu-item', + 'href' => '/secret', + 'page' => 'secret', + 'text' => '秘密のページ', + 'show' => false, + ], + ]; + } +} +?> diff --git a/src/Site/Controller/Notfound.php b/src/Site/Controller/Notfound.php new file mode 100644 index 0000000..ade65db --- /dev/null +++ b/src/Site/Controller/Notfound.php @@ -0,0 +1,26 @@ +assign('pagetit', $pagetit); + $tmpl->assign('curPage', '404'); + $tmpl->assign('menu', $this->getMenu()); + $tmpl->assign('description', ''); + + $tmpl->render('404'); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + } +} +?> diff --git a/src/Site/Controller/Page.php b/src/Site/Controller/Page.php new file mode 100644 index 0000000..a785922 --- /dev/null +++ b/src/Site/Controller/Page.php @@ -0,0 +1,65 @@ +assign('pagetit', $pagetit); + $tmpl->assign('curPage', 'about'); + $tmpl->assign('custCss', false); + $tmpl->assign('menu', $this->getMenu()); + $tmpl->assign('description', $description); + + $tmpl->render('about'); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + } + + public function monero(array $params): void { + try { + $tmpl = new Template('/'); + $pagetit = 'モネロ(XMR)で支援♡'; + $description = 'テクニカル諏訪子ちゃんをモネロで支援♡'; + + $tmpl->assign('pagetit', $pagetit); + $tmpl->assign('curPage', 'support'); + $tmpl->assign('custCss', true); + $tmpl->assign('menu', $this->getMenu()); + $tmpl->assign('description', $description); + + $tmpl->addCss('code'); + $tmpl->render('monero'); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + } + + public function secret(array $params): void { + try { + $tmpl = new Template('/'); + $pagetit = '秘密のページ'; + $description = 'ケロ'; + + $tmpl->assign('pagetit', $pagetit); + $tmpl->assign('curPage', 'support'); + $tmpl->assign('custCss', false); + $tmpl->assign('menu', $this->getMenu()); + $tmpl->assign('description', $description); + + $tmpl->render('secret'); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + } +} +?> diff --git a/src/Site/Lib/Activitypub.php b/src/Site/Lib/Activitypub.php new file mode 100644 index 0000000..5d69897 --- /dev/null +++ b/src/Site/Lib/Activitypub.php @@ -0,0 +1,558 @@ +domain = $_SERVER['SERVER_NAME']; + $this->actor = FEDIINFO['actor']; + $this->actorNick = FEDIINFO['actorNick']; + $this->desc = FEDIINFO['desc']; + $this->icon = "https://{$this->domain}".FEDIINFO['icon']; + $this->posts = $posts; + } + + /** + * ActivityPubアクタープロフィールを受け取る + * + * @return string アクターオブジェクト + * @throws \Exception 公開鍵の読み込みに失敗した場合 + */ + public function getActor(): string { + $pubkey = file_get_contents(FEDIINFO['pubkey']); + if ($pubkey === false) { + throw new \Exception('公開鍵の受取に失敗。パス:'.FEDIINFO['pubkey']); + } + + $actor = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + 'id' => "https://{$this->domain}/ap/actor", + 'name' => $this->actorNick, + 'summary' => $this->desc, + 'manuallyApprovesFollowers' => false, + 'icon' => [ + 'type' => 'Image', + 'mediaType' => 'image/png', + 'url' => $this->icon, + ], + 'image' => [ + 'type' => 'Image', + 'url' => + "https://{$this->domain}/static/article/o_53803618dc1691.28179609.jpg", + 'mediaType' => 'image/jpeg', + ], + 'type' => 'Person', + 'url' => "https://{$this->domain}", + 'preferredUsername' => $this->actor, + 'inbox' => "https://{$this->domain}/ap/inbox", + 'outbox' => "https://{$this->domain}/ap/outbox", + 'followers' => "https://{$this->domain}/ap/followers", + 'following' => "https://{$this->domain}/ap/following", + 'published' => '2025-03-28T18:00:00Z', + 'updated' => gmdate('c'), + 'publicKey' => [ + 'id' => "https://{$this->domain}/ap/actor#main-key", + 'owner' => "https://{$this->domain}/ap/actor", + 'publicKeyPem' => $pubkey, + ], + ]; + + return json_encode($actor, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * 特定のUUIDに対応するActivityを取得する + * + * @param string $uuid 取得するアクティビティのUUID + * @return string JSONエンコードされたアクティビティデータ + */ + public function getActivity(string $uuid): string { + $items = []; + + foreach ($this->posts as $post) { + if ($post['uuid'] != $uuid) continue; + + $items = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + [ + 'Emoji' => 'toot:Emoji', + 'EmojiReact' => 'litepub:EmojiReact', + 'Hashtag' => 'as:Hashtag', + 'litepub' => 'http://litepub.social/ns#', + 'sensitive' => 'as:sensitive', + 'toot' => 'http://joinmastodon.org/ns#', + ], + ], + 'id' => "https://{$this->domain}/ap/activities/create/{$post['uuid']}", + 'type' => 'Create', + 'actor' => "https://{$this->domain}/ap/actor", + 'cc' => [ + "https://{$this->domain}/ap/followers", + ], + 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])), + 'to' => ['https://www.w3.org/ns/activitystreams#Public'], + 'object' => [ + 'id' => "https://{$this->domain}/ap/objects/{$post['uuid']}", + 'type' => 'Note', + 'name' => $post['title'], + 'attributedTo' => "https://{$this->domain}/ap/actor", + 'cc' => [ + "https://{$this->domain}/ap/followers", + ], + 'to' => ['https://www.w3.org/ns/activitystreams#Public'], + 'content' => + $post['preview']."

domain}/blog/{$post['slug']}\">読み続き", + 'url' => "https://{$this->domain}/blog/{$post['slug']}", + 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])), + 'replies' => "https://{$this->domain}/ap/objects/{$uuid}/replies", + 'sensitive' => false, + ], + ]; + + if (isset($post['category']) && !empty($post['category'])) { + $item['tag'] = []; + foreach ($post['category'] as $cat) { + $items['tag'][] = $cat; + } + } + + if (isset($post['thumbnail']) && $post['thumbnail'] != '') { + $imgurl = "https://technicalsuwako.moe/static/article/{$post['thumbnail']}"; + $imgpath = ROOT."/public/static/article/{$post['thumbnail']}"; + $imgraw = file_get_contents($imgpath); + + $items['attachment'] = [ + [ + 'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)), + 'mediaType' => mime_content_type($imgpath), + 'type' => "Image", + 'url' => $imgurl, + ], + ]; + } + + break; + } + + return json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * アウトボックスデータを取得する + * + * @return string JSONエンコードされたアウトボックスデータ + */ + public function getOutbox(): string { + $items = []; + $counter = 0; + + foreach ($this->posts as $post) { + $uid = $post['uuid']; + + $items[] = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + [ + 'Emoji' => 'toot:Emoji', + 'EmojiReact' => 'litepub:EmojiReact', + 'Hashtag' => 'as:Hashtag', + 'litepub' => 'http://litepub.social/ns#', + 'sensitive' => 'as:sensitive', + 'toot' => 'http://joinmastodon.org/ns#', + ], + ], + 'id' => "https://{$this->domain}/ap/activities/create/{$uid}", + 'type' => 'Create', + 'actor' => "https://{$this->domain}/ap/actor", + 'cc' => [ + "https://{$this->domain}/ap/followers", + ], + 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])), + 'to' => ['https://www.w3.org/ns/activitystreams#Public'], + 'object' => [ + 'id' => "https://{$this->domain}/ap/objects/{$uid}", + 'type' => 'Note', + 'name' => $post['title'], + 'attributedTo' => "https://{$this->domain}/ap/actor", + 'cc' => [ + "https://{$this->domain}/ap/followers", + ], + 'to' => ['https://www.w3.org/ns/activitystreams#Public'], + 'content' => + $post['preview']."

domain}/blog/{$post['slug']}\">読み続き", + 'url' => "https://{$this->domain}/blog/{$post['slug']}", + 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])), + 'replies' => "https://{$this->domain}/ap/objects/{$uid}/replies", + 'sensitive' => false, + ], + ]; + + if (isset($post['category']) && !empty($post['category'])) { + $items[$counter]['tag'] = []; + foreach ($post['category'] as $cat) { + $items[$counter]['tag'][] = $cat; + } + } + + if (isset($post['thumbnail']) && $post['thumbnail'] != '') { + $imgurl = "https://technicalsuwako.moe/static/article/{$post['thumbnail']}"; + $imgpath = ROOT."/public/static/article/{$post['thumbnail']}"; + $imgraw = file_get_contents($imgpath); + + $items[$counter]['attachment'] = [ + [ + 'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)), + 'mediaType' => mime_content_type($imgpath), + 'type' => "Image", + 'url' => $imgurl, + ], + ]; + } + + $counter++; + } + + $outbox = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + 'id' => "https://{$this->domain}/ap/outbox", + 'type' => 'OrderedCollection', + 'totalItems' => count($items), + 'orderedItems' => $items, + ]; + + return json_encode($outbox, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * WebFingerデータを取得する + * + * @return string JSONエンコードされたWebFingerデータ + */ + public function getWebfinger(): string { + $webfinger = [ + 'subject' => "acct:{$this->actor}@{$this->domain}", + 'links' => [ + [ + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => "https://{$this->domain}/ap/actor", + ], + ], + ]; + + return json_encode($webfinger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * フォロワーのリストを取得する + * + * @return string JSONエンコードされたフォロワーのリスト + */ + public function getFollowers(): string { + $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt'))); + + $followers = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + 'id' => "https://{$this->domain}/ap/followers", + 'type' => 'OrderredCollection', + 'totalItems' => count($f), + 'orderedItems' => $f, + ]; + + return json_encode($followers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * フォローしているアカウントのリストを取得する + * + * @return string JSONエンコードされたフォローリスト + */ + public function getFollowing(): string { + $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/following.txt'))); + + $following = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + 'id' => "https://{$this->domain}/ap/following", + 'type' => 'OrderredCollection', + 'totalItems' => count($f), + 'orderedItems' => $f, + ]; + + return json_encode($following, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * インボックスにアクティビティを投稿する + * + * @param array $activity 処理するアクティビティデータ + * @return void + */ + public function postInbox(array $activity): void { + switch ($activity['type']) { + case 'Follow': + $this->acceptFollower($activity); + break; + default: + header('HTTP/1.1 501 Not Implemented'); + header('Content-Type: application/activity+json'); + echo json_encode(['error' => + '未対応なアクティビティタイプ: '.$activity['type']]); + exit; + } + + header('HTTP/1.1 200 OK'); + header('Content-Type: application/activity+json'); + echo json_encode(['status' => 'OK'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + exit; + } + + /** + * アクタープロフィールの更新アクティビティを作成する + * + * @return string JSONエンコードされた更新アクティビティ + */ + public function update(): string { + $update = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + 'id' => "https://{$this->domain}/ap/activities/update/".uuid(), + 'type' => 'Update', + 'actor' => "https://{$this->domain}/ap/actor", + 'to' => ['https://www.w3.org/ns/activitystreams#Public'], + 'object' => json_decode($this->getActor(), true), + ]; + + return json_encode($update, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * アクター更新をフォロワーに送信する + * + * @param array $params パラメータ配列 + * @return void + */ + public function sendActorUpdate(array $params): void { + $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt'))); + $ap = new Activitypub(); + $inboxes = implode("\n", $f); + $update = json_decode($ap->update(), true); + + foreach ($f as $inbox) { + $this->sendActivity($inbox, $update); + } + } + + // 機能性メソッド + + /** + * 指定されたインボックスURLにアクティビティを送信する + * + * @param string $inboxUrl 送信先のインボックスURL + * @param array $activity 送信するアクティビティデータ + * @return void + */ + private function sendActivity(string $inboxUrl, array $activity): void { + $privFile = FEDIINFO['privkey']; + $priv = file_get_contents($privFile); + if ($priv === false) { + logger(\LogType::ActivityPub, "エラー:秘密鍵「{$privFile}」の読込に失敗"); + header('HTTP/1.1 500 Internal Server Error'); + header('Content-Type: application/activity+json'); + echo json_encode(['error' => '秘密鍵の読込に失敗']); + exit; + } + + $body = json_encode($activity, JSON_UNESCAPED_SLASHES); + $digest = base64_encode(hash('sha256', $body, true)); + $date = gmdate('D, d M Y H:i:s \G\M\T'); + $host = parse_url($inboxUrl, PHP_URL_HOST); + + $headers = [ + 'Host' => $host, + 'Date' => $date, + 'Content-Type' => 'application/activity+json', + 'Digest' => "SHA-256=$digest", + ]; + + $stringToSign = "host: {$headers['Host']}\n" + ."date: {$headers['Date']}\n" + ."digest: {$headers['Digest']}"; + logger(\LogType::ActivityPub, "署名対象: {$stringToSign}"); + + if (!openssl_sign($stringToSign, $signature, $priv, OPENSSL_ALGO_SHA256)) { + $error = openssl_error_string(); + logger(\LogType::ActivityPub, "エラー:署名に失敗: {$error}"); + header('HTTP/1.1 500 Internal Server Error'); + header('Content-Type: application/activity+json'); + echo json_encode(['error' => '署名に失敗']); + exit; + } + + $sigValue = base64_encode($signature); + $headers['Signature'] = "keyId=\"https://{$this->domain}/ap/actor#main-key\","; + $headers['Signature'] .= 'algorithm="rsa-sha256",'; + $headers['Signature'] .= 'headers="host date digest",'; + $headers['Signature'] .= 'signature="'.$sigValue.'"'; + logger(\LogType::ActivityPub, + "署名: {$headers['Signature']}\n送信データ: {$body}"); + + $curl = new Curl($inboxUrl); + $curl->setMethod('POST') + ->setPostRaw($body) + ->setHeaders(array_map(fn($k, $v) => "$k: $v", + array_keys($headers), $headers)) + ->setCaInfo('/etc/ssl/cert.pem') + ->setVerbose(true) + ->setStderr(fopen(ROOT.'/log/ap_log.txt', 'a')); + + $success = $curl->execute(); + $res = $curl->getResponseBody(); + $code = $curl->getResponseCode(); + $err = $curl->getError(); + + var_dump(print_r($res)); + logger(\LogType::ActivityPub, + "アクティビティは「{$inboxUrl}」に送信しました: HTTP {$code}"); + logger(\LogType::ActivityPub, "エラー: {$err}"); + logger(\LogType::ActivityPub, "レスポンス: {$res}"); + } + + /** + * フォロワーを受け入れる + * + * @param array $activity フォローアクティビティデータ + * @return void + */ + private function acceptFollower(array $activity): void { + $followerActor = $activity['actor'] ?? null; + if (!$followerActor) { + header('HTTP/1.1 400 Bad Request'); + header('Content-Type: application/activity+json'); + echo json_encode(['error' => 'アクターがない']); + exit; + } + + $this->storeFollower($followerActor); + + $inbox = $this->getInboxFromActor($followerActor); + if (!$inbox) { + header('HTTP/1.1 500 Internal Server Error'); + header('Content-Type: application/activity+json'); + echo json_encode(['error' => 'フォロワーの受付ボックスの受取に失敗']); + exit; + } + + $accept = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + 'id' => "https://{$this->domain}/ap/activities/".uniqid(), + 'type' => 'Accept', + 'actor' => "https://{$this->domain}/ap/actor", + 'object' => $activity, + ]; + + $this->sendActivity($inbox, $accept); + } + + /** + * フォロワーをストレージに保存する + * + * @param string $followerActor フォロワーのアクターURL + * @return void + */ + private function storeFollower(string $followerActor): void { + $file = ROOT.'/data/followers.txt'; + if (!file_exists($file)) { + touch($file); + chmod($file, 0644); + } + + $followers = $this->getFollowersList(); + if (!in_array($followerActor, $followers)) { + file_put_contents($file, "$followerActor\n", FILE_APPEND); + } + } + + /** + * フォロワーのリストを配列として取得する + * + * @return array フォロワーのURLの配列 + */ + private function getFollowersList(): array { + $file = ROOT.'/data/followers.txt'; + $f = array_filter(explode("\n", file_get_contents($file))); + return file_exists($file) + ? array_filter(explode("\n", file_get_contents($file))) : []; + } + + /** + * アクターのインボックスURLを取得する + * + * @param string $actor アクターのURL + * @return string|null インボックスURL、取得に失敗した場合はnull + */ + private function getInboxFromActor(string $actor): ?string { + $curl = new Curl($actor); + $curl->setHeaders(['Accept: application/activity+json']) + ->setFollowRedirects(true) + ->setMaxRedirects(5) + ->setCaInfo('/etc/ssl/cert.pem'); + + logger(\LogType::ActivityPub, "アクターURLにリクエスト: {$actor}"); + $success = $curl->execute(); + if (!$success) { + logger(\LogType::ActivityPub, "アクターリクエストに失敗: ".$curl->getError()); + return null; + } + + $res = $curl->getResponseBody(); + $code = $curl->getResponseCode(); + $err = $curl->getError(); + + if ($code !== 200) { + logger(\LogType::ActivityPub, "アクター取得に失敗: HTTP {$code}, エラー: {$err}"); + return null; + } + + $data = json_decode($res, true); + return $data['inbox'] ?? null; + } +} \ No newline at end of file diff --git a/src/Site/Lib/Csv.php b/src/Site/Lib/Csv.php new file mode 100644 index 0000000..0f8b78e --- /dev/null +++ b/src/Site/Lib/Csv.php @@ -0,0 +1,85 @@ + ",", + self::SEMICOLON => ";", + self::TAB => "\t", + self::PIPE => "|", + }; + } +} + +/** + * CSVパーシングクラス + */ +class Csv { + // リクエスト関連のプロパティ + private bool $isHeader = false; + private int $length = 8192; + private Delimiter $delimiter; + private string $filename; + private $fp; + + public function __construct(string $filename, int $length = 8192) { + $this->length = $length; + $this->filename = $filename; + $this->delimiter = Delimiter::default(); + + $this->fp = fopen($this->filename, 'r'); + if ($this->fp === false) { + $msg = "ファイルを開けられません。"; + logger(\LogType::Csv, $msg); + throw new \Exception($msg); + } + } + + public function __destruct() { + if ($this->fp !== false) { + fclose($this->fp); + } + } + + public function parse(?Delimiter $delimiter = null, bool $isHeader = false): array { + $res = []; + $this->isHeader = $isHeader; + $this->delimiter = $delimiter ?? $this->delimiter; + + $delimChar = $this->delimiter->getChar(); + + rewind($this->fp); + + if ($this->isHeader) { + $res = ['header' => [], 'body' => []]; + $head = fgets($this->fp, $this->length); + if ($head !== false) { + $res['header'] = str_getcsv($head, $delimChar); + } + } + + while (($buffer = fgets($this->fp, $this->length)) !== false) { + $row = str_getcsv($buffer, $delimChar); + if ($this->isHeader) $res['body'][] = $row; + else $res[] = $row; + } + + if (!feof($this->fp)) { + $msg = "エラー:fgets()の失敗"; + logger(\LogType::Csv, $msg); + throw new \Exception($msg); + } + + return $res; + } +} diff --git a/src/Site/Lib/Curl.php b/src/Site/Lib/Curl.php new file mode 100644 index 0000000..893e31b --- /dev/null +++ b/src/Site/Lib/Curl.php @@ -0,0 +1,691 @@ +url = $url; + } + } + + /** + * リクエスト先のURLを設定する + * + * @param string $url リクエスト先のURL + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setUrl(string $url): Curl { + $this->url = $url; + return $this; + } + + /** + * リクエストメソッドを設定する + * + * @param string $method GE又はPOST等のHTTPメソッド + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setMethod(string $method): Curl { + $this->method = strtoupper($method); + return $this; + } + + /** + * リクエストのタイムアウト秒数を設定する + * + * @param int $seconds タイムアウト秒数 + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setTimeout(int $seconds): Curl { + $this->timeout = (int)$seconds; + return $this; + } + + /** + * リクエストヘッダーを設定する + * + * @param array $headers リクエストヘッダーの配列 + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setHeaders(array $headers): Curl { + $this->headers = $headers; + return $this; + } + + /** + * 単一のヘッダーを追加する + * + * @param string $name ヘッダー名 + * @param mixed $value ヘッダー値 + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function addHeader(string $name, mixed $value): Curl { + $this->headers[$name] = $value; + return $this; + } + + /** + * リクエストのクッキーを設定する + * + * @param array $cookies クッキーの配列 + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setCookies(array $cookies): Curl { + $this->cookies = $cookies; + return $this; + } + + /** + * 単一のクッキーを追加する + * + * @param string $name クッキー名 + * @param mixed $value クッキー値 + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function addCookie(string $name, mixed $value): Curl { + $this->cookies[$name] = $value; + return $this; + } + + /** + * POSTフィールドを設定する + * + * @param array $fields POSTデータの配列 + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setPostFields(array $fields): Curl { + $this->postFields = $fields; + return $this; + } + + /** + * 生のPOSTデータを設定する + * + * @param string $data 生のPOSTデータ + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setPostRaw(string $data): Curl { + $this->postRaw = $data; + return $this; + } + + /** + * ユーザーエージェントを設定する + * + * @param string $userAgent カスタムユーザーエージェント + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setUserAgent(string $userAgent): Curl { + $this->userAgent = $userAgent; + return $this; + } + + /** + * リダイレクトを追跡するかどうかを設定する + * + * @param bool $follow 追跡するかどうか(デフォルトはtrue) + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setFollowRedirects(bool $follow): Curl { + $this->followRedirects = (bool)$follow; + return $this; + } + + /** + * 追跡するリダイレクトの最大数を設定する + * + * @param int $max リダイレクトの最大数 + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setMaxRedirects(int $max): Curl { + $this->maxRedirects = (int)$max; + return $this; + } + + /** + * SSL証明書を検証するかどうかを設定する + * + * @param bool $verify SSL検証を行うかどうか(デフォルトはtrue) + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setVerifySSL(bool $verify): Curl { + $this->verifySSL = (bool)$verify; + return $this; + } + + /** + * 基本認証の資格情報を設定する + * + * @param string $username ユーザー名 + * @param string $password パスワード + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setBasicAuth(string $username, string $password): Curl { + $this->username = $username; + $this->password = $password; + return $this; + } + + /** + * リファラーURLを設定する + * + * @param string $referer リファラーURL + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setReferer(string $referer): Curl { + $this->referer = $referer; + return $this; + } + + /** + * 詳細ログを有効にする + * + * @param bool $verbose 詳細ログを有効にするかどうか + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setVerbose(bool $verbose): Curl { + $this->verbose = (bool)$verbose; + return $this; + } + + /** + * エラー出力先を設定する + * + * @param resource $handle エラー出力先のファイルハンドル + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setStderr($handle): Curl { + $this->stderr = $handle; + return $this; + } + + /** + * SSL証明書のファイルパスを設定する + * + * @param string $path 証明書ファイルのパス + * @return Curl このインスタンス(メソッドチェーン用) + */ + public function setCaInfo(string $path): Curl { + $this->caInfoPath = $path; + return $this; + } + + /** + * リクエストを実行する + * + * @return bool 成功または失敗 + */ + public function execute(): bool { + if (empty($this->url)) { + $this->responseError = 'URLがありません'; + return false; + } + + // レスポンスデータのリセット + $this->responseHeaders = []; + $this->responseBody = ''; + $this->responseCode = 0; + $this->responseError = ''; + $this->info = [ + 'url' => $this->url, + 'content_type' => '', + 'http_code' => 0, + 'header_size' => 0, + 'request_size' => 0, + 'total_time' => 0, + 'redirect_count' => 0, + 'redirect_url' => '', + ]; + + $startTime = microtime(true); + + // ソケットベースの実装を使用する + $redirectCount = 0; + $currentUrl = $this->url; + $originalMethod = $this->method; + + do { + if ($this->verbose && $this->stderr) { + fwrite($this->stderr, "* 接続中: {$currentUrl}\n"); + } + + $parsed = parse_url($currentUrl); + if (!$parsed) { + $this->responseError = "無効なURL: {$currentUrl}"; + return false; + } + + $scheme = isset($parsed['scheme']) ? strtolower($parsed['scheme']) : 'http'; + $host = $parsed['host']; + $port = isset($parsed['port']) + ? $parsed['port'] : ($scheme === 'https' ? 443 : 80); + $path = isset($parsed['path']) ? $parsed['path'] : '/'; + if (isset($parsed['query'])) { + $path .= '?'.$parsed['query']; + } + + // Basic認証 + $authHeader = ''; + if (!empty($this->username) && !empty($this->password)) { + $authHeader = "Authorization: Basic " + .base64_encode($this->username.':'.$this->password)."\r\n"; + } elseif (isset($parsed['user']) && isset($parsed['pass'])) { + $authHeader = "Authorization: Basic " + .base64_encode($parsed['user'].':'.$parsed['pass'])."\r\n"; + } + + // 送信するHTTPリクエストを構築 + $method = $this->method; + $httpData = ''; + + if ($method === 'POST' || $method === 'PUT') { + if (!empty($this->postRaw)) { + $httpData = $this->postRaw; + } elseif (!empty($this->postFields)) { + $httpData = http_build_query($this->postFields); + if (!isset($this->headers['Content-Type'])) { + $this->headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + } + $this->headers['Content-Length'] = strlen($httpData); + } + + // HTTPリクエストを構築 + $accept = 'Accept: */*'; + foreach ($this->headers as $h) { + if (str_contains($h, 'Accept:')) $accept = $h; + } + + $request = "{$method} {$path} HTTP/1.1\r\n"; + $request .= "Host: {$host}\r\n"; + $request .= "User-Agent: {$this->userAgent}\r\n"; + $request .= "{$accept}\r\n"; + $request .= "Connection: close\r\n"; + + if (!empty($authHeader)) { + $request .= $authHeader; + } + + // ヘッダーを追加 + foreach ($this->headers as $name => $value) { + $request .= "{$name}: {$value}\r\n"; + } + + // リファラーが設定されていれば追加 + if (!empty($this->referer) && !isset($this->headers['Referer'])) { + $request .= "Referer: {$this->referer}\r\n"; + } + + // クッキーヘッダーを追加 + if (!empty($this->cookies) && !isset($this->headers['Cookie'])) { + $cookieStrings = []; + foreach ($this->cookies as $name => $value) { + $cookieStrings[] = $name.'='.urlencode($value); + } + $request .= 'Cookie: '.implode('; ', $cookieStrings)."\r\n"; + } + + $request .= "\r\n"; + + // POSTデータを追加 + if ($method === 'POST' || $method === 'PUT') { + $request .= $httpData; + } + + if ($this->verbose && $this->stderr) { + fwrite($this->stderr, "* リクエストヘッダー:\n{$request}\n"); + } + + // ソケット接続を確立 + $errno = 0; + $errstr = ''; + + if ($scheme === 'https') { + $sslOptions = [ + 'verify_peer' => $this->verifySSL, + 'verify_peer_name' => $this->verifySSL + ]; + + if (!empty($this->caInfoPath) && file_exists($this->caInfoPath)) { + $sslOptions['cafile'] = $this->caInfoPath; + } + + $context = stream_context_create(['ssl' => $sslOptions]); + + $socket = @stream_socket_client( + "tls://{$host}:{$port}", + $errno, + $errstr, + $this->timeout, + STREAM_CLIENT_CONNECT, + $context + ); + } else { + $socket = @fsockopen($host, $port, $errno, $errstr, $this->timeout); + } + + if (!$socket) { + $this->responseError = "接続出来ません: {$errstr} ({$errno})"; + if ($this->verbose && $this->stderr) { + fwrite($this->stderr, "* エラー: {$this->responseError}\n"); + } + return false; + } + + // タイムアウトを設定 + stream_set_timeout($socket, $this->timeout); + + // リクエストを送信 + fwrite($socket, $request); + + // レスポンスを読み込む + $rawResponse = ''; + $headers = ''; + $body = ''; + $headersComplete = false; + + // ヘッダーとボディを分けて読み込む + while (!feof($socket)) { + $line = fgets($socket); + if ($line === false) { + break; + } + + $rawResponse .= $line; + + if (!$headersComplete) { + if (trim($line) === '') { + $headersComplete = true; + } else { + $headers .= $line; + } + } else { + $body .= $line; + } + } + + fclose($socket); + + // レスポンスヘッダーを解析 + $headerLines = explode("\r\n", $headers); + + // ステータスコードを取得 + $statusLine = isset($headerLines[0]) ? $headerLines[0] : ''; + $statusParts = explode(' ', $statusLine, 3); + $this->responseCode = isset($statusParts[1]) ? (int)$statusParts[1] : 0; + $this->info['http_code'] = $this->responseCode; + + // ヘッダーを解析 + $this->responseHeaders = []; + $redirectUrl = ''; + + foreach ($headerLines as $index => $header) { + if ($index === 0) continue; + + if (strpos($header, ':') !== false) { + list($name, $value) = explode(':', $header, 2); + $name = trim($name); + $value = trim($value); + $this->responseHeaders[$name] = $value; + + if (strtolower($name) === 'content-type') { + $this->info['content_type'] = $value; + } + + // リダイレクトをチェック + if ($this->followRedirects && + strtolower($name) === 'location' && + $this->responseCode >= 300 && + $this->responseCode < 400) { + + $redirectUrl = $value; + + // 相対URLを絶対URLに変換 + if (strpos($redirectUrl, 'http') !== 0) { + if ($redirectUrl[0] === '/') { + $redirectUrl = "{$scheme}://{$host}" + .($port != 80 && $port != 443 ? ":{$port}" : '').$redirectUrl; + } else { + $redirectUrl = "{$scheme}://{$host}" + .($port != 80 && $port != 443 ? ":{$port}" : '') + .dirname($path).'/'.$redirectUrl; + } + } + + $this->info['redirect_url'] = $redirectUrl; + } + } + } + + $this->info['header_size'] += strlen($headers); + $this->responseBody .= $body; + + if ($this->verbose && $this->stderr) { + fwrite($this->stderr, "* レスポンスコード: {$this->responseCode}\n"); + fwrite($this->stderr, "* レスポンスヘッダー:\n{$headers}\n"); + if (!empty($redirectUrl)) { + fwrite($this->stderr, "* リダイレクト先: {$redirectUrl}\n"); + } + } + + // リダイレクトが必要な場合 + if (!empty($redirectUrl) && $redirectCount < $this->maxRedirects) { + $currentUrl = $redirectUrl; + $redirectCount++; + $this->info['redirect_count'] = $redirectCount; + + // 302や303リダイレクトはGETにメソッドを変更 + if ($this->responseCode == 302 || $this->responseCode == 303) { + $this->method = 'GET'; + $this->postRaw = ''; + $this->postFields = []; + } + } else { + break; + } + } while (true); + + // リクエスト完了後、元のメソッドに戻す + $this->method = $originalMethod; + $this->info['total_time'] = microtime(true) - $startTime; + + return true; + } + + /** + * レスポンスボディを取得する + * + * @return string レスポンスボディ + */ + public function getResponseBody(): string { + return $this->responseBody; + } + + /** + * レスポンスヘッダーを取得する + * + * @return array レスポンスヘッダーの配列 + */ + public function getResponseHeaders(): array { + return $this->responseHeaders; + } + + /** + * HTTPレスポンスコードを取得する + * + * @return int HTTPレスポンスコード + */ + public function getResponseCode(): int { + return $this->responseCode; + } + + /** + * エラーメッセージがあれば取得する + * + * @return string エラーメッセージ + */ + public function getError(): string { + return $this->responseError; + } + + /** + * リクエスト/レスポンス情報を取得する + * + * @return array 情報の配列 + */ + public function getInfo(): array { + return $this->info; + } + + // 機能性メソッド + + /** + * リダイレクトURLを確認する + * + * @param string $name ヘッダー名 + * @param string $value ヘッダー値 + * @param string $currentUrl 現在のURL + * @return string リダイレクトURL、リダイレクトがない場合は空文字 + */ + private function checkReds(string $name, string $value, string $currentUrl): string { + $redirectUrl = ''; + + if ($this->followRedirects && (strtolower($name) === 'location' + && $this->responseCode >= 300 && $this->responseCode < 400)) { + $redirectUrl = $value; + + if (strpos($redirectUrl, 'http') !== 0) { + if ($redirectUrl[0] === '/') { + $parsed = parse_url($currentUrl); + $redirectUrl = $parsed['scheme'].'://'.$parsed['host'] + .(isset($parsed['port']) ? ':'.$parsed['port'] : '') + .$redirectUrl; + } else { + $redirectUrl = dirname($currentUrl).'/'.$redirectUrl; + } + } + } + + return $redirectUrl; + } + + /** + * ヘッダー文字列を構築する + * + * @return string 構築されたヘッダー文字列 + */ + private function buildHeaderString(): string { + $headers = []; + + // ユーザー指定のヘッダーを追加 + foreach ($this->headers as $name => $value) { + $headers[] = "{$name}: {$value}"; + } + + // リファラーが設定されていれば追加 + if (!empty($this->referer) && !isset($this->headers['Referer'])) { + $headers[] = "Referer: {$this->referer}"; + } + + // 必要に応じてクッキーヘッダーを追加 + if (!empty($this->cookies) && !isset($this->headers['Cookie'])) { + $cookieStrings = []; + foreach ($this->cookies as $name => $value) { + $cookieStrings[] = $name.'='.urlencode($value); + } + + $headers[] = 'Cookie: '.implode('; ', $cookieStrings); + } + + return implode("\r\n", $headers)."\r\n"; + } + + /** + * レスポンスを解析してヘッダーとボディに分割する + * + * @param string $response 完全なHTTPレスポンス + * @return array [ヘッダー配列, ボディ文字列] + */ + private function parseResponse(string $response): array { + $parts = explode("\r\n\r\n", $response, 2); + + if (count($parts) < 2) { + return [[], '']; + } + + $headers = explode("\r\n", $parts[0]); + $body = $parts[1]; + + // チャンク転送エンコーディングを処理 + if (isset($this->responseHeaders['Transfer-Encoding']) && + strtolower($this->responseHeaders['Transfer-Encoding']) === 'chunked') { + $body = $this->decodeChunkedBody($body); + } + + return [$headers, $body]; + } + + /** + * チャンク転送エンコーディングされたボディをデコードする + * + * @param string $body チャンクエンコードされたボディ + * @return string デコードされたボディ + */ + private function decodeChunkedBody(string $body): string { + $decodedBody = ''; + $position = 0; + + while ($position < strlen($body)) { + $lineEnd = strpos($body, "\r\n", $position); + if ($lineEnd === false) { + break; + } + + $chunkSize = hexdec(substr($body, $position, $lineEnd - $position)); + + if ($chunkSize === 0) { + break; + } + + $position = $lineEnd + 2; + $decodedBody .= substr($body, $position, $chunkSize); + $position += $chunkSize + 2; // チャンクサイズ + CRLF + } + + return $decodedBody; + } +} \ No newline at end of file diff --git a/src/Site/Lib/DiffViewer.php b/src/Site/Lib/DiffViewer.php new file mode 100644 index 0000000..fad3110 --- /dev/null +++ b/src/Site/Lib/DiffViewer.php @@ -0,0 +1,130 @@ +diffContent = file_get_contents($filePath); + } + + public function displaySideBySide(): string { + $lines = explode("\n", $this->diffContent); + $fileDiffs = []; + $currentFile = null; + $hunk = []; + $lineNumbers = ['left' => 0, 'right' => 0]; + $currentLeftLines = []; + $currentRightLines = []; + + foreach ($lines as $line) { + // ファイルヘッダーの処理 + if (preg_match('/^---\s+(.+)/', $line, $matches)) { + // ファイルを処理する場合、データを保存する + if ($currentFile !== null) { + $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers); + $fileDiffs[$currentFile] = [ + 'leftLines' => $currentLeftLines, + 'rightLines' => $currentRightLines + ]; + $hunk = []; + $currentLeftLines = []; + $currentRightLines = []; + $lineNumbers = ['left' => 0, 'right' => 0]; + } + $currentFile = $matches[1]; + continue; + } + if (preg_match('/^\+\+\+\s+(.+)/', $line)) { + continue; + } + + // ハンクヘッダーの処理 (例:@@ -10,6 +10,7 @@) + if (preg_match('/^@@\s+-(\d+),\d+\s+\+(\d+),\d+\s+@@/', $line, $matches)) { + $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers); + $hunk = []; + $lineNumbers['left'] = (int)$matches[1]; + $lineNumbers['right'] = (int)$matches[2]; + continue; + } + + // ハンクでの行列の集まり + if (substr($line, 0, 1) === '-' || substr($line, 0, 1) === '+' || substr($line, 0, 1) === ' ') { + $hunk[] = $line; + } + } + + // 最後のハンク・ファイルの処理 + if ($currentFile !== null) { + $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers); + $fileDiffs[$currentFile] = [ + 'leftLines' => $currentLeftLines, + 'rightLines' => $currentRightLines + ]; + } + + // 各ファイルにHTMLの出力の作成 + $html = ''; + foreach ($fileDiffs as $fileName => $diff) { + $html .= "

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

\n"; + $html .= $this->generateHtml($diff['leftLines'], $diff['rightLines']); + } + + return $html; + } + + private function processHunk(array $hunk, array &$leftLines, array &$rightLines, array &$lineNumbers): void { + foreach ($hunk as $line) { + $prefix = substr($line, 0, 1); + $content = substr($line, 1); + + if ($prefix === '-') { + $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'removed', 'line' => $lineNumbers['left']]; + $lineNumbers['left']++; + } elseif ($prefix === '+') { + $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'added', 'line' => $lineNumbers['right']]; + $lineNumbers['right']++; + } elseif ($prefix === ' ') { + // 両側のコンテキストは同じ行列があるかの確認 + while ($lineNumbers['left'] < $lineNumbers['right']) { + $leftLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['left']]; + $lineNumbers['left']++; + } + while ($lineNumbers['right'] < $lineNumbers['left']) { + $rightLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['right']]; + $lineNumbers['right']++; + } + $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['left']]; + $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['right']]; + $lineNumbers['left']++; + $lineNumbers['right']++; + } + } + } + + private function generateHtml(array $leftLines, array $rightLines): string { + $html = ''; + $html .= ''; + + $maxLines = max(count($leftLines), count($rightLines)); + for ($i = 0; $i < $maxLines; $i++) { + $left = isset($leftLines[$i]) ? $leftLines[$i] : ['content' => '', 'type' => 'empty', 'line' => '']; + $right = isset($rightLines[$i]) ? $rightLines[$i] : ['content' => '', 'type' => 'empty', 'line' => '']; + + $html .= ''; + // 左(変更前) + $html .= ''; + $html .= ''; + // 右(変更後) + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
' . ($left['line'] ?: ' ') . '' . ($left['content'] ?: ' ') . '' . ($right['line'] ?: ' ') . '' . ($right['content'] ?: ' ') . '
'; + return $html; + } +} \ No newline at end of file diff --git a/src/Site/Lib/Mailer.php b/src/Site/Lib/Mailer.php new file mode 100644 index 0000000..11e128b --- /dev/null +++ b/src/Site/Lib/Mailer.php @@ -0,0 +1,238 @@ +host = MAILINFO['host']; + $this->port = MAILINFO['port']; + $this->user = MAILINFO['user']; + $this->pass = MAILINFO['pass']; + } + + /** + * ソケット接続を開く。 + * + * @return void + */ + public function connect(): void { + $this->socket = fsockopen($this->host, $this->port, $errno, $err, 30); + if (!$this->socket) { + $msg = "接続に失敗: {$err} ({$errno})"; + logger(\LogType::Mailer, $msg); + throw new \Exception($msg); + } + + $this->readResponse(); + } + + /** + * ソケット接続を切断する。 + * + * @return void + */ + public function disconnect(): void { + $this->sendCommand('QUIT', 221); + fclose($this->socket); + } + + /** + * サーバーで認証する。 + * ユーザー名とパスワードが指定されている場合はAUTH LOGINを使用する。 + * + * @return void + */ + public function authenticate(): void { + $ehloRes = $this->sendCommand('EHLO '.$this->host, 250); + + // STARTTLSは対応するかどうか確認 + if (strpos($ehloRes, 'STARTTLS') !== false) { + $this->sendCommand('STARTTLS', 220); + + // TLS暗号化 + if (!stream_socket_enable_crypto( + $this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + $msg = "TLSハンドシェイクに失敗"; + logger(\LogType::Mailer, $msg); + throw new \Exception($msg); + } + + $this->sendCommand('EHLO '.gethostname(), 250); + } + + if ($this->user && $this->pass) { + $this->sendCommand('AUTH LOGIN', 334); + $this->sendCommand(base64_encode($this->user), 334); + $this->sendCommand(base64_encode($this->pass), 235); + } + } + + /** + * TLSハンドシェイクに失敗する。 + * + * @param string $to 受信者のメールアドレス。 + * @param string $subject メールの件名。 + * @param string $body メールの本文。 + * @param string|null $toName 受信者の名前。 + * @param string|null $replyTo 返信先メールアドレス。 + * @param string|null $replyToName 返信先の名前。 + * @param bool $pgpSign PGPで署名するかどうか(現在は正常に動作していません)。 + * + * @return void + */ + public function send( + string $to, + string $subject, + string $body, + ?string $toName = null, + ?string $replyTo = null, + ?string $replyToName = null, + bool $pgpSign = false + ): void { + $from = MAILINFO['from']; + + $this->sendCommand("MAIL FROM: <{$from}>", 250); + $this->sendCommand("RCPT TO: <{$to}>", 250); + $this->sendCommand('DATA', 354); + + $headers = "Date: ".date('r')."\r\n"; // RFC 2822 + + $encSubject = mb_encode_mimeheader($subject, 'UTF-8', 'Q'); + $headers .= "Subject: {$encSubject}\r\n"; + + $fromHeader = mb_encode_mimeheader(SITEINFO['title'], 'UTF-8', 'Q')." <{$from}>"; + $headers .= "From: {$fromHeader}\r\n"; + + $toHeader = $toName + ? mb_encode_mimeheader($toName, 'UTF-8', 'Q')." <{$to}>" : $to; + $headers .= "To: {$toHeader}\r\n"; + + if ($replyTo) { + $replyToHeader = $replyToName + ? mb_encode_mimeheader($replyToName, 'UTF-8', 'Q')." <{$replyTo}>" : $replyTo; + $headers .= "Reply-To: {$replyToHeader}\r\n"; + } + + $headers .= "MIME-Version: 1.0\r\n"; + + if ($pgpSign) { + $boundary = uniqid('BOUNDARY_'); + $headers .= "Content-Type: multipart/signed;\r\n"; + $headers .= " protocol=\"application/pgp-signature\";\r\n"; + $headers .= " micalg=php-sha512;\r\n"; + $headers .= " boundary=\"{$boundary}\"\r\n"; + } else { + $headers .= "Content-Type: text/plain; charset=utf-8\r\n"; + $headers .= "Content-Transfer-Encoding: quoted-printable\r\n"; + } + + $headers .= "X-Mailer: 076 Little Beast\r\n"; + + $encBody = quoted_printable_encode($body); + $data = $headers."\r\n".$encBody; + + if ($pgpSign) { + $signature = $this->signMessage($data); + $data = "--{$boundary}\r\n" + .$data."\r\n" + ."Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n" + ."Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n" + .$signature."\r\n" + ."--{$boundary}--\r\n"; + } + + $data .= "\r\n.\r\n"; + fwrite($this->socket, $data); + + $response = $this->readResponse(); + if (substr($response, 0, 3) != '250') { + $msg = "メール送信に失敗: {$response}"; + logger(\LogType::Mailer, $msg); + throw new \Exception($msg); + } + } + + /** + * お好みのタイムゾーンを設定する。 + * 設定しない場合、php.iniで設定されたタイムゾーンがデフォルトになる。 + * それも設定されていない場合、GMTタイムゾーンがデフォルトになる。 + * + * @param string $zone IANAタイムゾーンデータベース標準のタイムゾーン(例:Asia/Tokyo) + * @return void + */ + public function setTimezone(string $zone): void { + date_default_timezone_set($zone); + } + + /** + * PGPキーとオプションでパスフレーズを設定する。 + * + * @param string $keypath PGP署名に使用する秘密鍵へのパス。 + * @param string|null $passphrase 設定されている場合、署名用のパスフレーズ。 + * + * @return void + */ + public function enablePGP(string $keypath, ?string $passphrase = null): void { + $this->pgpKey = file_get_contents($keypath); + $this->pgpPass = $passphrase; + } + + // 機能性メソッド + + private function sendCommand(string $command, int $retcode): string { + fwrite($this->socket, $command."\r\n"); + $res = $this->readResponse(); + if (substr($res, 0, 3) != $retcode) { + $msg = "「{$command}」に対する予期しないレスポンス: {$res}"; + logger(\LogType::Mailer, $msg); + throw new \Exception($msg); + } + + return $res; + } + + private function readResponse(): string { + $res = ''; + + while ($line = fgets($this->socket, 515)) { + $res .= $line; + if (substr($line, 3, 1) == ' ') break; // レスポンスの終了だ + } + + return $res; + } + + private function signMessage(string $message): string { + if (extension_loaded('gnupg')) {// gnupg延長は有効の場合 + $gpg = new \gnupg(); + $gpg->addsignkey($this->pgpKey, $this->pgpPass); + $gpg->setsignmode(\gnupg::SIG_MODE_DETACH); + return $gpg->sign($message); + } else { // なければ、CLIツールを使う(gnupgをインストールは必須) + $tmp = tempnam(sys_get_temp_dir(), 'pgpmsg'); + file_put_contents($tmp, $message); + $sig = shell_exec( + "gpg --batch " + .($this->pgpPass ? "--passphrase {$this->pgpPass} " : '') + ."--detach-sign --armor {$tmp} 2>&1" + ); + unlink($tmp); + return $sig; + } + } +} \ No newline at end of file diff --git a/src/Site/Lib/Markdown.php b/src/Site/Lib/Markdown.php new file mode 100644 index 0000000..aa8f7fd --- /dev/null +++ b/src/Site/Lib/Markdown.php @@ -0,0 +1,646 @@ +html = []; + if ($lang) $this->path = ROOT.$section.$lang.'/'.$path.'.md'; + else $this->path = ROOT.$section.$path.'.md'; + + if (!file_exists($this->path)) { + header('Location: /404'); + exit(); + } + } + + /** + * メタデータを取得する + * + * @return \stdClass メタデータオブジェクト + */ + public function getMetadata(): \stdClass { + $content = file_get_contents($this->path); + $metadata = new \stdClass(); + + $parts = explode(self::METADATA_LINE, $content, 2); + if (count($parts) < 2) return $metadata; + + $lines = explode("\n", trim($parts[0])); + foreach ($lines as $line) { + $line = trim($line); + if (empty($line)) continue; + + $colonPos = strpos($line, ':'); + if ($colonPos === false) continue; + + $key = trim(substr($line, 0, $colonPos)); + $value = trim(substr($line, $colonPos + 1)); + $value = trim($value, '"\''); + + if ($key == 'category' || $key == 'css') { + $cat = explode(',', $value); + $value = $cat; + } + + $metadata->$key = $value; + } + + return $metadata; + } + + /** + * Markdownをパースする + * + * @return string HTMLとしてレンダリングされたコンテンツ + */ + public function parse(): string { + $content = file_get_contents($this->path); + $parts = explode(self::METADATA_LINE, $content, 2); + $this->content = count($parts) > 1 ? trim($parts[1]): trim($content); + $this->html = []; + + $lines = explode("\n", $this->content); + $currentParagraph = []; + + $inList = false; + $listItems = []; + $listLevel = 0; + $inBlockquote = false; + $blockquoteContent = []; + $tableHeaders = []; + $tableRows = []; + $inTable = false; + $i = 0; + + foreach ($lines as $line) { + $i++; + $hasBR = substr($line, -1) === '\\'; + $line = rtrim($line, " \t\r\n\\"); + + // コメント + // if (str_starts_with($line, '//')) { + // if ($hasBR && !empty($currentParagraph)) { + // $currentParagraph[] = ''; + // } + // continue; + // } + + // コードブロック + if (preg_match('/^```(\w*)$/', $line, $matches)) { + if (!$this->inCodeBlock) { + if (!empty($currentParagraph)) { + $this->html[] = "

".implode("", $currentParagraph)."

"; + $currentParagraph = []; + } + $this->inCodeBlock = true; + $this->codeBlockLanguage = $matches[1]; + continue; + } else { + $this->html[] = $this->createCodeBlock(); + $this->inCodeBlock = false; + $this->codeBlockLanguage = ''; + $this->codeBlockContent = []; + continue; + } + } + + if ($this->inCodeBlock) { + $this->codeBlockContent[] = $line; + continue; + } + + // テーブルの処理 + if (preg_match('/^\|(.+)\|$/', $line)) { + if (!empty($currentParagraph)) { + $this->html[] = "

".implode("", $currentParagraph)."

"; + $currentParagraph = []; + } + $cells = array_map('trim', explode('|', trim($line, '|'))); + + if (!$inTable) { + $tableHeaders = $cells; + $inTable = true; + } elseif (preg_match('/^\|(\s*:?-+:?\s*\|)+$/', $line)) { + continue; + } else { + $tableRows[] = $cells; + } + continue; + } elseif ($inTable) { + $this->html[] = $this->createTable($tableHeaders, $tableRows); + $tableHeaders = []; + $tableRows = []; + $inTable = false; + } + + // 水平線の処理 + if (preg_match('/^([\-\*\_])\1{2,}$/', $line)) { + if (!empty($currentParagraph)) { + $this->html[] = "

".implode("", $currentParagraph)."

"; + $currentParagraph = []; + } + $this->html[] = "
"; + continue; + } + + // 引用ブロックの処理 + if (preg_match('/^>\s(.+)/', $line, $matches)) { + if (!empty($currentParagraph)) { + $this->html[] = "

".implode("", $currentParagraph)."

"; + $currentParagraph = []; + } + $inBlockquote = true; + $blockquoteContent[] = $this->parseInline($matches[1]); + continue; + } elseif ($inBlockquote && empty($line)) { + $this->html[] = $this->createBlockquote($blockquoteContent); + $blockquoteContent = []; + $inBlockquote = false; + continue; + } + + // 空行をスキップ + if (empty($line)) { + if ($inList) { + $this->html[] = $this->createList($listItems); + $listItems = []; + $inList = false; + } + if (!empty($currentParagraph)) { + $this->html[] = "

".implode("", $currentParagraph)."

"; + $currentParagraph = []; + } + + continue; + } + + // ヘッダー + if (preg_match('/^(#{1,6})\s(.+)/', $line, $m)) { + if ($inList) { + $this->html[] = $this->createList($listItems); + $listItems = []; + $inList = false; + } + if (!empty($currentParagraph)) { + $this->html[] = "

".implode("", $currentParagraph)."

"; + $currentParagraph = []; + } + + $level = strlen($m[1]); + $this->html[] = "".$this->parseInline($m[2]).""; + continue; + } + + // 箇条書きリスト + if (preg_match('/^(\s*)([\*\-])\s(.+)/', $line, $m)) { + if (!empty($currentParagraph)) { + $this->html[] = "

".implode("", $currentParagraph)."

"; + $currentParagraph = []; + } + $inList = true; + $currentLevel = strlen($m[1]) / 2; + $listLevel = max($listLevel, $currentLevel); + $listItems[] = [ + 'content' => $this->parseInline($m[3]), + 'level' => $currentLevel, + 'type' => 'ul', + ]; + + continue; + } + + // 番号付きリスト + if (preg_match('/^(\s*)\d+\.\s(.+)/', $line, $m)) { + if (!empty($currentParagraph)) { + $this->html[] = "

".implode("", $currentParagraph)."

"; + $currentParagraph = []; + } + $inList = true; + $currentLevel = strlen($m[1]) / 2; + $listLevel = max($listLevel, $currentLevel); + $listItems[] = [ + 'content' => $this->parseInline($m[2]), + 'level' => $currentLevel, + 'type' => 'ol', + ]; + + continue; + } + + if ($inList) { + $this->html[] = $this->createList($listItems); + $listItems = []; + $inList = false; + $listLevel = 0; + } + + $parsedLine = $this->parseInline($line); + $currentParagraph[] = $parsedLine; + if ($hasBR) { + $currentParagraph[] = "
"; + } + } + + if ($inList) $this->html[] = $this->createList($listItems); + if ($inBlockquote) $this->html[] = $this->createBlockquote($blockquoteContent); + if ($inTable) $this->html[] = $this->createTable($tableHeaders, $tableRows); + if (!empty($currentParagraph)) + $this->html[] = "

".implode("", $currentParagraph)."

"; + + return implode("\n", $this->html); + } + + // 機能性メソッド + + /** + * インラインのMarkdown記法をパースする + * + * @param string $text パースするテキスト + * @return string HTMLとしてレンダリングされたテキスト + */ + private function parseInline(string $text): string { + $this->algebraicPlaceholder = []; + $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); + + $patterns = [ + // 数式 + '/\$\$([^$]+)\$\$/u' => function($matches): string { + $placeholder = "{{ALG".count($this->algebraicPlaceholder).'ALG}}'; + $this->algebraicPlaceholder[$placeholder] = $matches[1]; + return $placeholder; + }, + // 太字 + '/\*\*(.+?)\*\*/u' => '$1', + // 斜体 + '/\*(.+?)\*/u' => '$1', + // 下線 + '/\_(.+?)\_/u' => '$1', + // 取り消し線 + '/\~(.+?)\~/u' => '$1', + // Blink (with speed) + '/!:\((.+?)\)(.+?):!/u' => '$2', + // Blink + '/!:(.+?):!/u' => '$1', + // フォントの大きさ + '/\^\((.+?)\)(.+?)\^/u' => '$2', + // フォントカラー + '/\%\((.+?)\)(.+?)\%/u' => '$2', + // 画像 + '/\!\[(.*?)(?:#([^\]]*))?\]\((.+?)\)/u' => '$1', + // 音楽 + '/\$\[([^\]]+)\]\(([^\)]+)\)/u' => '', + // 動画 + '/\#\[([^\]]+)\]\(([^\)]+)\)/u' => '', + // リンク + '/\[(.+?)\]\((.+?)\)/u' => '$1', + // 振り仮名 + '/\<(.+?)\>\((.+?)\)/u' => '$1($2)', + // インラインコード + '/`(.+?)`/u' => '$1', + ]; + + foreach ($patterns as $pattern => $replacement) { + if (is_callable($replacement)) { + $text = preg_replace_callback($pattern, $replacement, $text); + } else { + $result = preg_replace($pattern, $replacement, $text); + if ($result === null) continue; + $text = $result; + } + } + + // プレースホルダーの数式に交換 + foreach ($this->algebraicPlaceholder as $placeholder => $expr) { + $text = str_replace($placeholder, $this->parseAlgebraic($expr), $text); + } + + return $text; + } + + /** + * 数式記法をパースする + * + * @param string $expression パースする数式 + * @return string MathJax用にフォーマットされた数式 + */ + private function parseAlgebraic(string $expression): string { + // HTMLエスケープ + $expression = htmlspecialchars_decode($expression, ENT_QUOTES); + + // ふぃりしゃもじのマッピング + $greekLetters = [ + 'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ', 'delta' => 'δ', + 'epsilon' => 'ε', 'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ', + 'iota' => 'ι', 'kappa' => 'κ', 'lambda' => 'λ', 'mu' => 'μ', + 'nu' => 'ν', 'xi' => 'ξ', 'omicron' => 'ο', 'pi' => 'π', + 'rho' => 'ρ', 'sigma' => 'σ', 'tau' => 'τ', 'upsilon' => 'υ', + 'phi' => 'φ', 'chi' => 'χ', 'psi' => 'ψ', 'omega' => 'ω', + 'Alpha' => 'Α', 'Beta' => 'Β', 'Gamma' => 'Γ', 'Delta' => 'Δ', + 'Epsilon' => 'Ε', 'Zeta' => 'Ζ', 'Eta' => 'Η', 'Theta' => 'Θ', + 'Iota' => 'Ι', 'Kappa' => 'Κ', 'Lambda' => 'Λ', 'Mu' => 'Μ', + 'Nu' => 'Ν', 'Xi' => 'Ξ', 'Omicron' => 'Ο', 'Pi' => 'Π', + 'Rho' => 'Ρ', 'Sigma' => 'Σ', 'Tau' => 'Τ', 'Upsilon' => 'Υ', + 'Phi' => 'Φ', 'Chi' => 'Χ', 'Psi' => 'Ψ', 'Omega' => 'Ω', + ]; + + // ギリシャ文字を置換 + foreach ($greekLetters as $text => $symbol) { + $expression = preg_replace("/\b$text\b/", $symbol, $expression); + } + + // 上付き文字(例: x^2) + $expression = preg_replace_callback('/(\w+)\^(\{?(\d+|\w+)\}?)/', function($matches) { + $base = $matches[1]; + $exponent = $matches[2]; + if ($exponent[0] === '{') $exponent = substr($exponent, 1, -1); + + $superscripts = str_split($exponent); + $supMap = [ + '0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴', + '5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹', + 'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ', + 'i' => 'ⁱ', 'n' => 'ⁿ', + ]; + $converted = ''; + + foreach ($superscripts as $char) $converted .= $supMap[$char] ?? $char; + + return $base.''.$converted.''; + }, $expression); + + // 下付き文字(例: x_1) + $expression = preg_replace_callback('/(\w+)_((\d+|\w+)\}?)/', function($matches) { + $base = $matches[1]; + $subscript = $matches[2]; + + if ($subscript[0] === '{') $subscript = substr($subscript, 1, -1); + + $subscripts = str_split($subscript); + $subMap = [ + '0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄', + '5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉', + ]; + $converted = ''; + + foreach ($subscripts as $char) { + $converted .= $subMap[$char] ?? $char; + } + + return $base.''.$converted.''; + }, $expression); + + // 分数(例: 4/5x, a/b) + $expression = preg_replace('/(\d+|\w+)\/(\d*\w+)/', + '$1$2', + $expression); + + // 演算子 + $operators = [ + '\*' => '・', + '!=' => '≠', + '>=' => '≥', + '<=' => '≤', + 'sqrt' => '√', + ]; + + foreach ($operators as $op => $symbol) $expression = preg_replace("/\b$op\b/", $symbol, $expression); + + // 数式全体をspanで囲む + return ''.$expression.''; + } + + /** + * リストを作成する + * + * @param array $items リストアイテムの配列 + * @param int $maxLevel 最大ネストレベル + * @return string HTMLのリスト + */ + private function createList(array $items, int $maxLevel = 1): string { + if ($items === []) return ''; + + $html = ''; + $currentLevel = 0; + $listStack = []; + $currentType = ''; + + foreach ($items as $item) { + $level = isset($item['level']) ? $item['level'] : $currentLevel; + + while ($currentLevel > $level) + $html .= str_repeat(' ', $currentLevel)."\n"; + + while ($currentLevel < $level) { + $currentLevel++; + $listStack[] = $item['type']; + $html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n"; + } + + if ($currentType != $item['type'] && $currentLevel == $item['level']) { + if (!empty($listStack)) { + $html .= str_repeat(' ', $currentLevel)."\n"; + $listStack[] = $item['type']; + $html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n"; + } + } + + $currentType = $item['type']; + } + + $html .= "<{$currentType}>\n"; + foreach ($items as $item) { + $html .= str_repeat(' ', $currentLevel)."
  • ".$item['content']."
  • \n"; + } + $html .= ""; + + while (!empty($listStack)) { + $html .= str_repeat(' ', $currentLevel)."\n"; + $currentLevel--; + } + + return rtrim($html); + } + + /** + * コードブロックを作成する + * + * @return string HTMLのコードブロック + */ + private function createCodeBlock(): string { + $code = htmlspecialchars(implode("\n", $this->codeBlockContent)); + $class = $this->codeBlockLanguage ? " class=\"language-{$this->codeBlockLanguage}\"" : ''; + return "
    {$code}
    "; + // $raw = implode("\n", $this->codeBlockContent); + // $lang = $this->codeBlockLanguage ?: 'txt'; + // $class = $lang === 'txt' ? '' : " class=\"language-{$lang}\""; + // $highlighted = $this->highlightCode($raw, $lang); + // return "
    {$highlighted}
    "; + } + + private function createBlockquote(array $content): string { + return "
    \n

    ".implode("

    \n

    ", $content)."

    \n
    "; + } + + /** + * テーブルを作成する + * + * @param array $headers ヘッダー配列 + * @param array $rows 行データの配列 + * @return string HTMLのテーブル + */ + private function createTable(array $headers, array $rows): string { + $html = "\n"; + + // ヘッダーを追加 + if (!empty($headers)) { + $html .= " \n \n"; + foreach ($headers as $header) { + $html .= " \n"; + } + $html .= " \n \n"; + } + + // 行を追加 + if (!empty($rows)) { + $html .= " \n"; + foreach ($rows as $row) { + $html .= " \n"; + foreach ($row as $cell) { + $html .= " \n"; + } + $html .= " \n"; + } + $html .= " \n"; + } + + $html .= "
    ".$this->parseInline($header)."
    ".$this->parseInline($cell)."
    "; + return $html; + } + + /** + * Pure-PHP syntax highlighter + * + * @param string $code Raw source code + * @param string $lang Language identifier (html, css, js, php, …) + * @return string HTML with tokens + */ + private function highlightCode(string $code, string $lang): string + { + $lang = strtolower(trim($lang)); + $rules = []; + + // ---- COMMON ------------------------------------------------------- + $rules['comment'] = [ + // /* … */ (multi-line) + ['/\/\*(?:.|\n)*?\*\//', 'hl-comment'], + // // …\n + ['/\/\/.*(?=\n|$)/', 'hl-comment'], + ]; + + $rules['string'] = [ + // "…" and '…' (allow escaped quotes) + ['/"(?:\\.|[^"\\\n])*"/', 'hl-string'], + ["/'(?:\\.|[^'\\\n])*'/", 'hl-string'], + ]; + + $rules['number'] = [ + ['/\b(?:0x[0-9a-fA-F]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/', 'hl-number'], + ]; + + // ---- HTML --------------------------------------------------------- + if ($lang === 'html') { + $rules['tag'] = [['/<\/?[a-zA-Z0-9\-]+/', 'hl-tag']]; + $rules['attr'] = [['/\s[a-zA-Z0-9\-]+(?==)/', 'hl-attr']]; + $rules['value'] = [['/(?<==)"(?:\\.|[^"\\\n])*"(?=[>\s])/', 'hl-value']]; + } + + // ---- CSS ---------------------------------------------------------- + if ($lang === 'css') { + $rules['selector'] = [['/^[\t ]*[a-zA-Z0-9#\.\-\:$$ $$\=\*\>\+\~\^]+(?=\s*\{)/m', 'hl-selector']]; + $rules['property'] = [['/\b[a-z\-]+(?=\s*:)/', 'hl-property']]; + $rules['value'] = [['/(?<=\:)\s*[^;]+(?=;)/', 'hl-value']]; + $rules['unit'] = [['/\b\d+(px|em|rem|%|vh|vw|pt|pc|cm|mm|in)\b/i', 'hl-unit']]; + } + + // ---- PHP ---------------------------------------------------------- + if ($lang === 'php') { + $keywords = 'abstract|and|array|as|break|callable|case|catch|class|clone|' + . 'const|continue|declare|default|die|do|echo|else|elseif|empty|' + . 'enddeclare|endfor|endforeach|endif|endswitch|endwhile|eval|exit|' + . 'extends|final|finally|for|foreach|function|global|goto|if|' + . 'implements|include|include_once|instanceof|insteadof|interface|' + . 'isset|list|namespace|new|or|print|private|protected|public|' + . 'require|require_once|return|static|switch|throw|trait|try|unset|' + . 'use|var|while|xor|yield'; + $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']]; + $rules['function']= [['/\b([a-zA-Z_\x7f-\xff][\w\x7f-\xff]*)(?=\s*\()/', 'hl-function']]; + $rules['variable']= [['/\$[a-zA-Z_\x7f-\xff][\w\x7f-\xff]*/', 'hl-variable']]; + } + + // ---- Shell -------------------------------------------------------- + if ($lang === 'sh') { + $keywords = 'if|then|else|elif|fi|case|esac|for|select|while|until|do|done|in'; + $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']]; + $rules['variable']= [['/\$\{?[a-zA-Z_][\w]*\}?/', 'hl-variable']]; + } + + // ---- JSON --------------------------------------------------------- + if ($lang === 'json') { + $rules['property'] = [['/"([^"]+)":/', '$1:']]; + } + + // ---- C / C++ ----------------------------------------------- + if (in_array($lang, ['c', 'cpp', 'cs'])) { + $keywords = 'abstract|assert|boolean|break|byte|case|catch|char|class|const|' + . 'continue|default|do|double|else|enum|extends|false|final|finally|' + . 'float|for|goto|if|implements|import|instanceof|int|interface|long|' + . 'native|new|null|package|private|protected|public|return|short|static|' + . 'strictfp|super|switch|synchronized|this|throw|throws|transient|true|' + . 'try|void|volatile|while'; + $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']]; + $rules['type'] = [['/\b(?:int|float|double|char|bool|void|string)\b/', 'hl-type']]; + $rules['define'] = [['/(#include)|(#define)/', 'hl-define']]; + } + + if ($lang === 'markdown') { + $rules['header'] = [['/^(#{1,6})\s+(.+)$/', '$1 $2']]; + $rules['link'] = [['/\[([^\]]+)\]\(([^)]+) $$/', '[$1]($2)']]; + } + + $html = htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8'); + + $order = ['comment', 'string', 'number']; + foreach (['tag','attr','value','selector','property','unit','keyword','function','variable','type','header','link'] as $k) { + if (isset($rules[$k])) $order[] = $k; + } + + foreach ($order as $type) { + if (!isset($rules[$type])) continue; + foreach ($rules[$type] as $rule) { + [$pattern, $class] = $rule; + $html = preg_replace_callback( + $pattern, + function ($m) use ($class) { + // For JSON property we already built the + if (strpos($m[0], ''.$inner.''; + }, + $html + ); + } + } + + return $html; + } +} \ No newline at end of file diff --git a/src/Site/Lib/Mysql.php b/src/Site/Lib/Mysql.php new file mode 100644 index 0000000..686ab8e --- /dev/null +++ b/src/Site/Lib/Mysql.php @@ -0,0 +1,1559 @@ +host = DBINFO['host']; + $this->username = DBINFO['username']; + $this->password = DBINFO['password']; + $this->dbname = DBINFO['dbname']; + $this->port = DBINFO['port']; + $this->debug = DBINFO['debug']; + } + + /** + * デストラクタ + * + * オブジェクト破棄時に接続を閉じます。 + */ + public function __destruct() { + $this->close(); + } + + /** + * デバッギングの有無 + * + * @param bool $debug デバッグモードを有効にするかどうか + * @return Mysql 自身を返す(メソッドチェーン用) + */ + public function setDebug(bool $debug): Mysql { + $this->debug = (bool)$debug; + return $this; + } + + /** + * パケットログの取得 + * + * @return array 送信および受信したパケットのログ + */ + public function getPacketLog(): array { + return $this->packetLog; + } + + /** + * MySQLサーバーに接続 + * + * ソケットを作成し、サーバーに接続後、認証とデータベース選択を行います。 + * + * @return bool 接続成功時はtrue + * @throws \Exception 接続または認証に失敗した場合 + */ + public function connect(): bool { + if (!MYSQL_ENABLED) return false; + + $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + if ($this->socket === false) { + $msg = 'ソケットの作成に失敗: '.socket_strerror(socket_last_error()); + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $res = socket_connect($this->socket, $this->host, $this->port); + if ($res === false) { + $msg = 'ソケットに接続に失敗: ' + .socket_strerror(socket_last_error($this->socket)); + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $greeting = $this->readPacket(); + $this->parseServerGreeting($greeting); + $this->authenticate(); + $response = $this->readPacket(); + + if (ord($response[0]) !== 0x00) { + $code = unpack('v', substr($response, 1, 2))[1]; + $mes = substr($response, 3); + $msg = "認証応答に失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + if (!empty($this->dbname)) { + $this->selectDatabase($this->dbname); + } + + $this->connected = true; + return true; + } + + /** + * 接続を閉じる + * + * COM_QUITコマンドを送信し、ソケットを閉じます。 + * + * @return void + */ + public function close(): void { + if (!MYSQL_ENABLED) return; + + if ($this->socket) { + $this->sendCommand(0x01); // COM_QUIT + socket_close($this->socket); + $this->socket = null; + $this->connected = false; + } + } + + /** + * 利用するデータベースを選択する + * + * COM_INIT_DBコマンドを使用してデータベースを選択します。 + * + * @param string $database データベース名 + * @return bool 成功時はtrue + * @throws \Exception データベース選択に失敗した場合 + */ + public function selectDatabase(string $database): bool { + if (!MYSQL_ENABLED) return false; + + $this->sendCommand(0x02, $database); // COM_INIT_DB + $res = $this->readPacket(); + + if (ord($res[0]) === 0xFF) { + $code = unpack('v', substr($res, 1, 2))[1]; + $mes = substr($res, 3); + $msg = "データベースの選択に失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $this->dbname = $database; + + return true; + } + + /** + * プリペアドステートメントの準備 + * + * SQLクエリをプリペアドステートメントとして準備し、ステートメントIDを返します。 + * + * @param string $query プレースホルダ付きSQLクエリ(例: "SELECT * FROM users WHERE id = ?") + * @return int 成功時はステートメントID + * @throws \Exception 準備に失敗した場合 + */ + public function prepare(string $query): int { + if (!$this->connected || !MYSQL_ENABLED) return false; + + $this->sendCommand(0x16, $query); // COM_STMT_PREPARE + $res = $this->readPacket(); + + if (ord($res[0]) === 0xFF) { + $code = unpack('v', substr($res, 1, 2))[1]; + $mes = substr($res, 3); + $msg = "準備に失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $pos = 0; + $statementId = unpack('V', substr($res, $pos + 1, 4))[1]; // ステートメントID + $pos += 5; + $numCols = unpack('v', substr($res, $pos, 2))[1]; // 列数 + $pos += 2; + $numParam = unpack('v', substr($res, $pos, 2))[1]; // パラメートル数 + $pos += 4; + + $this->prepared[$statementId] = [ + 'num_params' => $numParam, + 'num_columns' => $numCols, + 'params' => [], + 'columns' => [], + ]; + + if ($numParam > 0) { + for ($i = 0; $i < $numParam; $i++) { + $paramPacket = $this->readPacket(); + $this->prepared[$statementId]['params'][] = + $this->parseFieldPacket($paramPacket); + } + + $this->readPacket(); + } + + if ($numCols > 0) { + for ($i = 0; $i < $numCols; $i++) { + $columnPacket = $this->readPacket(); + $this->prepared[$statementId]['columns'][] = + $this->parseFieldPacket($columnPacket); + } + + $this->readPacket(); + } + + return $statementId; + } + + /** + * プリペアドステートメントの実行 + * + * 指定されたステートメントIDとパラメータを使用してクエリを実行します。 + * + * @param int $statementId プリペアドステートメントID + * @param array $params パラメータ値の配列 + * @return array 結果セットまたはOKパケットデータ + * @throws \Exception 実行に失敗した場合 + */ + public function execute(int $statementId, array $params = []): array { + if (!MYSQL_ENABLED) return []; + + if (!isset($this->prepared[$statementId])) { + $msg = "不正なステートメントID: {$statementId}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $stmtInfo = $this->prepared[$statementId]; + if (count($params) != $stmtInfo['num_params']) { + $msg = "パラメータ数が一致しません: 期待 {$stmtInfo['num_params']}, 取得 ".count($params); + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $data = chr(0x17); // COM_STMT_EXECUTE + $data .= pack('V', $statementId); + $data .= chr(0); // 0 = カーソルなし + $data .= pack('V', 1); // 繰り返し数(常に1) + + if ($stmtInfo['num_params'] > 0) { + // NULLビットマップ + $nullBitmap = str_repeat("\0", ceil($stmtInfo['num_params'] / 8)); + foreach ($params as $k => $v) { + if ($v === NULL) { + $nullBitmap[$k >> 3] = chr(ord($nullBitmap[$k >> 3]) | (1 << ($k & 7))); + } + } + + $data .= $nullBitmap; + + $data .= chr(1); // 新パラメートルフラグ(1=はい) + + $paramTypes = ''; + $paramValues = ''; + foreach ($params as $param) { + /** + * MYSQL_TYPE_DECIMAL 0x00 + * MYSQL_TYPE_TINY 0x01 + * MYSQL_TYPE_SHORT 0x02 + * MYSQL_TYPE_LONG 0x03 + * MYSQL_TYPE_FLOAT 0x04 + * MYSQL_TYPE_DOUBLE 0x05 + * MYSQL_TYPE_NULL 0x06 + * MYSQL_TYPE_TIMESTAMP 0x07 + * MYSQL_TYPE_LONGLONG 0x08 + * MYSQL_TYPE_INT24 0x09 + * MYSQL_TYPE_DATE 0x0A + * MYSQL_TYPE_TIME 0x0B + * MYSQL_TYPE_DATETIME 0x0C + * MYSQL_TYPE_YEAR 0x0D + * MYSQL_TYPE_NEWDATE 0x0E + * MYSQL_TYPE_VARCHAR 0x0F + * MYSQL_TYPE_BIT 0x10 + * + * MYSQL_TYPE_NEWDECIMAL 0xF6 + * MYSQL_TYPE_ENUM 0xF7 + * MYSQL_TYPE_SET 0xF8 + * MYSQL_TYPE_TINY_BLOB 0xF9 + * MYSQL_TYPE_MEDIUM_BLOB 0xFA + * MYSQL_TYPE_LONG_BLOB 0xFB + * MYSQL_TYPE_BLOB 0xFC + * MYSQL_TYPE_VAR_STRING 0xFD + * MYSQL_TYPE_STRING 0xFE + * MYSQL_TYPE_GEOMETRY 0xFF + */ + if ($param === null) { + $paramType .= pack('v', 0x06); // MYSQL_TYPE_NULL + } else if (is_int($param)) { + $intLen = strlen((string)$param); + if ($intLen == 10) { + $paramTypes .= pack('v', 0x07); // MYSQL_TYPE_TIMESTAMP + } else if ($intLen >= -128 && $intLen < 127) { + $paramTypes .= pack('v', 0x01); // MYSQL_TYPE_TINY + } else if ($intLen >= -32768 && $intLen < 32767) { + $paramTypes .= pack('v', 0x02); // MYSQL_TYPE_SHORT + } else if ($intLen >= -8388608 && $intLen < 8388607) { + $paramTypes .= pack('v', 0x09); // MYSQL_TYPE_INT24 + } else if ($intLen >= -2147483648 && $intLen < 2147483647) { + $paramTypes .= pack('v', 0x03); // MYSQL_TYPE_LONG + } else if ($intLen >= -9223372036854775808 && $intLen < 9223372036854775807) { + $paramTypes .= pack('v', 0x08); // MYSQL_TYPE_LONGLONG + } + $paramValues .= pack('V', $param); + } else if (is_float($param)) { + $decLen = strpos(strrev((string)$param), '.'); + if ($decLen !== FALSE && $decLen < 25) { + $paramTypes .= pack('v', 0x04); // MYSQL_TYPE_FLOAT + } else if ($decLen !== FALSE && $decLen >= 25 && $decLen < 60) { + $paramTypes .= pack('v', 0x05); // MYSQL_TYPE_DOUBLE + } + $paramValues .= pack('d', $param); + } else { + $paramTypes .= pack('v', 0x0F); // MYSQL_TYPE_STRING + $len = strlen($param); + $paramValues .= $this->encodeLengthEncodedInteger($len).$param; + } + } + + $data .= $paramTypes.$paramValues; + } + + $this->sendPacket($data); + $res = $this->readPacket(); + + if (ord($res[0]) === 0xFF) { + $code = unpack('v', substr($res, 1, 2))[1]; + $mes = substr($res, 3); + $msg = "実行に失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + if (ord($res[0]) === 0x00) { + return $this->parseOkPacket($res); + } + + return $this->parseResultSet($res); + } + + /** + * プリペアドステートメントの解放 + * + * 指定されたステートメントIDを解放し、リソースをクリーンアップします。 + * + * @param int $statementId プリペアドステートメントID + * @return bool 成功時はtrue + */ + public function demolish(int $statementId): bool { + if (!MYSQL_ENABLED || !isset($this->prepared[$statementId])) return false; + + $data = chr(0x19).pack('V', $statementId); // COM_STMT_CLOSE + $this->sendPacket($data); + + unset($this->prepared[$statementId]); + return true; + } + + /** + * SQLクエリの実行 + * + * COM_QUERYを使用してSQLクエリを実行し、結果を返します。 + * + * @param string $query 実行するSQLクエリ + * @return array 結果セットまたはOKパケットデータ + * @throws \Exception クエリ実行に失敗した場合 + */ + public function query(string $query): array { + if (!MYSQL_ENABLED) return []; + + $this->sendCommand(0x03, $query); // COM_QUERY + $res = $this->readPacket(); + + if (ord($res[0]) === 0xFF) { + $code = unpack('v', substr($res, 1, 2))[1]; + $mes = substr($res, 3); + $msg = "クエリに失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + // レスポンスは0x00で始まったら、OKパケットだ + if (ord($res[0]) === 0x00) { + return $this->parseOkPacket($res); + } + + // レスポンスは0xFBで始まったら、、 LOCAL INFILEリクエストだ + // @todo LOCAL INFILEリクエストの処理を実装 + if (ord($res[0]) === 0xFB) { + $msg = "LOCAL INFOリクエストは未対応です"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + return $this->parseResultSet($res); + } + + /** + * パケットログをファイルに保存する + * + * デバッグ用に収集したパケットログを指定されたファイルに保存します。 + * + * @param string $filename 保存先ファイル名 + * @return bool|int 成功時は書き込んだバイト数、失敗時はfalse + */ + public function savePacketLogToFile(string $filename): bool|int { + if (!MYSQL_ENABLED) return 0; + + $output = ''; + + foreach ($this->packetLog as $index => $packetInfo) { + $direction = $packetInfo['direction']; + $length = $packetInfo['length']; + $seqNum = $packetInfo['seqNum']; + $data = $packetInfo['data']; + $timestamp = date('Y-m-d H:i:s', (int)$packetInfo['timestamp']); + + $output .= "=== パケット #{$index} ({$timestamp}) {$direction} + $output .= (長さ: {$length}, シーケンス: {$seqNum}) ===\n"; + $output .= "16進数: ".$this->hexDump($data)."\n"; + $output .= "ASCII: ".$this->asciiDump($data)."\n"; + $output .= "==========================================\n\n"; + } + + return file_put_contents(ROOT.'/log/'.$filename, $output); + } + + // 機能性メソッド + + /** + * MySQLサーバーで認証する + * + * クライアント機能フラグと認証情報を送信してサーバー認証を行います。 + * + * @return bool 認証成功時はtrue + * @throws \Exception 認証に失敗した場合 + */ + private function authenticate(): bool { + /** + * CLIENT_LONG_PASSWORD 0x00000001 + * CLIENT_PROTOCOL_41 0x00000200 + * CLIENT_SECURE_CONNECTION 0x00008000 + * CLIENT_CONNECT_WITH_DB 0x00000800 + * + * 0x00020D05 = CLIENT_LONG_PASSWORD | CLIENT_PROTOCOL_41 | + * CLIENT_SECURE_CONNECTION | CONNECT_WITH_DB + */ + $data = ''; + $data .= pack('L', 0x00020D05); // クライアント機能フラグ + $data .= pack('L', 16777216); // パケットサイズの大きさ + $data .= chr(33); // チャーセット(33 = utf8_general_ci) + $data .= str_repeat("\0", 23); // 予約バイト + $data .= $this->username."\0"; // ユーザー名 + + // パスワード + if (empty($this->password)) { + $data .= "\0"; // 空 + } else { + $pw = $this->scramblePassword($this->password, $this->serverInfo['scramble']); + $data .= chr(strlen($pw)).$pw; + } + + // データベース名 + if (!empty($this->dbname)) { + $data .= $this->dbname."\0"; + } + + // 認証パケットを送信する + $this->sendPacket($data, 1); + + // サーバー返事を送る + $res = $this->readPacket(); + + if (ord($res[0]) === 0xFF) { + $code = unpack('v', substr($res, 1, 2))[1]; + $mes = substr($res, 3); + $this->close(); + $msg = "認証に失敗: {$code} - {$mes}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + return true; + } + + /** + * サーバーから結果セットを解析する + * + * クエリ結果のフィールドと行データを解析して返します。 + * + * @param string $firstPacket 最初の結果セットパケット + * @return array フィールドと行データの配列 + * @throws \Exception EOFパケットが期待通りに受信できない場合 + */ + private function parseResultSet(string $firstPacket): array { + $fieldCnt = ord($firstPacket[0]); + + $fields = []; + for ($i = 0; $i < $fieldCnt; $i++) { + $fieldPacket = $this->readPacket(); + $fields[] = $this->parseFieldPacket($fieldPacket); + } + + $eofPacket = $this->readPacket(); + if (ord($eofPacket[0]) !== 0xFE) { + $msg = "フィールド説明の後にEOFパケットが期待されます"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $rows = []; + while (true) { + $rowPacket = $this->readPacket(); + + // 行データの終了を示すEOFパケットを確認 + if (ord($rowPacket[0]) === 0xFE && strlen($rowPacket) < 9) break; + $rows[] = $this->parseRowPacket($rowPacket, $fields); + } + + return [ + 'fields' => $fields, + 'rows' => $rows, + ]; + } + + /** + * フィールドパケットを解析する + * + * フィールドのメタデータを解析して返します。 + * + * @param string $packet フィールドパケット + * @return array フィールドのメタデータ + */ + private function parseFieldPacket(string $packet): array { + $pos = 0; + $field = []; + + // カタログのスキップ(def等) + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['catalog'] = substr($packet, $pos, $len); + $pos += 1 + $len; + + // データベース名 + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['db'] = substr($packet, $pos, $len); + $pos += $len; + + // テーブル名 + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['table'] = substr($packet, $pos, $len); + $pos += $len; + + // 元のテーブル名 + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['org_table'] = substr($packet, $pos, $len); + $pos += 1 + $len; + + // フィールド名 + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['name'] = substr($packet, $pos, $len); + $pos += $len; + + // 元のフィールド名 + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['org_name'] = substr($packet, $pos, $len); + $pos += $len; + + // フィルターバイトをスキップ(通常は0x0C) + $pos += 1; + + // 文字セット + $field['charset'] = unpack('v', substr($packet, $pos, 2))[1]; + $pos += 2; + + // 列の長さ + $field['length'] = unpack('V', substr($packet, $pos, 4))[1]; + $pos += 4; + + // フィールド種類 + $field['type'] = ord($packet[$pos]); + $pos += 1; + + // フラグ + $field['flags'] = unpack('v', substr($packet, $pos, 2))[1]; + $pos += 2; + + // 小数点以下の桁数 + $field['decimals'] = ord($packet[$pos]); + $pos += 1; + + // フィルターバイトをスキップ + $pos += 2; + + // デフォルト値(存在する場合、長さエンコード文字列) + if ($pos < strlen($packet)) { + $len = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $field['default'] = substr($packet, $pos, $len); + } + + return $field; + } + + /** + * 行パケットを解析する + * + * 結果セットの行データを解析して返します。 + * + * @param string $packet 行パケット + * @param array $fields フィールドメタデータの配列 + * @return array 行データの連想配列 + */ + private function parseRowPacket(string $packet, array $fields): array { + $pos = 0; + $row = []; + + foreach ($fields as $field) { + // 0xFB = NULL + if (ord($packet[$pos]) === 0xFB) { + $row[$field['name']] = null; + $pos++; + continue; + } + + // 長さ + $len = ord($packet[$pos]); + $pos++; + $row[$field['name']] = substr($packet, $pos, $len); + $pos += $len; + } + + return $row; + } + + /** + * OKパケットを解析する + * + * OKパケットの内容を解析して影響を受けた行数や挿入IDなどを返します。 + * + * @param string $packet OKパケット + * @return array OKパケットのデータ + * @throws \Exception パケットが不完全な場合 + */ + private function parseOkPacket(string $packet): array { + if (strlen($packet) < 2) { + $msg = "OKパケットが短すぎます: ".strlen($packet)."バイト"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + $pos = 1; // ヘッダーバイト(0x00)をスキップする + + $affectedRows = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($affectedRows); + + $insertId = $this->getLengthEncodedIntegerValue($packet, $pos); + $pos += $this->getLengthEncodedIntegerSize($insertId); + + if (strlen($packet) < $pos + 2) { + $msg = "OKパケットにサーバーステータス用のデータが不足しています"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + $serverStatus = unpack('v', substr($packet, $pos, 2))[1]; + $pos += 2; + + if (strlen($packet) < $pos + 2) { + $msg = "OKパケットに警告カウント用のデータが不足しています"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + $warningCount = unpack('v', substr($packet, $pos, 2))[1]; + + return [ + 'affectedRows' => $affectedRows, + 'insertId' => $insertId, + 'serverStatus' => $serverStatus, + 'warningCount' => $warningCount, + ]; + } + + /** + * MySQLサーバーからパケットを読み込む + * + * ソケットからパケットを読み込み、完全なデータを受信するまで待機します。 + * + * @return string 受信したパケットデータ + * @throws \Exception 読み込みに失敗した場合 + */ + private function readPacket(): string { + $header = ''; // パケットのヘッダー=4バイト + $bytesRead = socket_recv($this->socket, $header, 4, MSG_WAITALL); + if ($bytesRead !== 4) { + $msg = "パケットヘッダーの読み込みに失敗: 期待 4 バイト, 取得 {$bytesRead}"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + // パケットの長さを最初3バイトからパーシングする + $len = ord($header[0]) + (ord($header[1]) << 8) + (ord($header[2]) << 16); + + // パケットの順序番号は第4目のバイト + $seqNum = ord($header[3]); + + // パケットの内容を読み込む + $data = ''; + $remaining = $len; + $timeout = 5; + socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, [ + 'sec' => $timeout, + 'usec' => 0, + ]); + + while ($remaining > 0) { + $buffer = ''; + $bytesRead = socket_recv($this->socket, $buffer, $remaining, 0); + + if ($bytesRead === false) { + $msg = "パケット内容の読み込みに失敗: エラー " + .socket_strerror(socket_last_error($this->socket)); + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + if ($bytesRead === 0) { + usleep(10000); + continue; + } + + $data .= $buffer; + $remaining -= $bytesRead; + } + + if (ord($data[0]) === 0x00 && strlen($data) < 7) { + $extra = ''; + $extraBytes = socket_recv($this->socket, $extra, 7 - strlen($data), 0); + if ($extraBytes !== false && $extraBytes > 0) { + $data .= $extra; + } + } + + // デバッグ + if ($this->debug) { + $packetInfo = [ + 'direction' => 'RECV', + 'length' => $len, + 'seqNum' => $seqNum, + 'data' => $data, + 'timestamp' => microtime(true), + ]; + + $this->logPacket($packetInfo); + } + + return $data; + } + + /** + * MySQLサーバーにパケットを送信する + * + * 指定されたデータとシーケンス番号でパケットを送信します。 + * + * @param string $data 送信するデータ + * @param int $seqNum シーケンス番号(デフォルトは0) + * @return bool 成功時はtrue + * @throws \Exception 送信に失敗した場合 + */ + private function sendPacket(string $data, $seqNum = 0): bool { + $len = strlen($data); + + // パケットヘッダー:長さ=3バイト、順序番号=1バイト + $header = chr($len & 0xFF) + .chr(($len >> 8) & 0xFF) + .chr(($len >> 16) & 0xFF) + .chr($seqNum); + + // デバッグ + if ($this->debug) { + $packetInfo = [ + 'direction' => 'SEND', + 'length' => $len, + 'seqNum' => $seqNum, + 'data' => $data, + 'timestamp' => microtime(true), + ]; + + $this->logPacket($packetInfo); + } + + // ヘッダーの送信 + $sent = socket_write($this->socket, $header, 4); + if ($sent !== 4) { + $msg = "パケットヘッダーの送信に失敗"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + // データの送信 + $sent = socket_write($this->socket, $data, $len); + if ($sent !== $len) { + $msg = "パケットデータの送信に失敗"; + logger(\LogType::MySQL, $msg); + throw new \Exception($msg); + } + + return true; + } + + /** + * MySQLサーバーにコマンドを送信する + * + * 指定されたコマンドとデータを送信します。 + * + * @param string $command コマンド(例: 0x03 = COM_QUERY) + * @param string $data 付加データ(デフォルトは空) + * @return bool 成功時はtrue + * @throws \Exception 送信に失敗した場合 + */ + private function sendCommand(string $command, string $data = ''): bool { + $packet = chr($command).$data; + return $this->sendPacket($packet); + } + + /** + * サーバーの挨拶パケットを解析する + * + * サーバーからの初期挨拶パケットを解析し、サーバー情報を保存します。 + * + * @param string $packet 挨拶パケット + * @return void + */ + private function parseServerGreeting(string $packet): void { + $pos = 0; + + // プロトコールバージョン(1バイト) + $this->serverInfo['protocol'] = ord($packet[$pos]); + $pos++; + + // サーバーバージョン + $end = strpos($packet, "\0", $pos); + $this->serverInfo['version'] = substr($packet, $pos, $end - $pos); + $pos = $end + 1; + + // スレッドID(4バイト) + $this->serverInfo['threadId'] = unpack('V', substr($packet, $pos, 4))[1]; + $pos += 4; + + // スクランブルバッファの最初の部分(8バイト) + $this->serverInfo['scramble'] = substr($packet, $pos, 8); + $pos += 8; + + // フィルターバイトをスキップする + $pos++; + + // サーバー機能(2バイト) + $this->serverInfo['capabilities'] = unpack('v', substr($packet, $pos, 2))[1]; + $pos += 2; + + // サーバー言語(1バイト) + $this->serverInfo['language'] = ord($packet[$pos]); + $pos++; + + // サーバー状況(2バイト) + $this->serverInfo['status'] = unpack('v', substr($packet, $pos, 2))[1]; + $pos += 2; + + // 13バイトのスキップ + $pos += 13; + + // その他(12バイト) + $this->serverInfo['scramble'] .= substr($packet, $pos, 12); + } + + /** + * パスワードをスクランブルする + * + * MySQL認証用のパスワードをスクランブルします。 + * + * @param string $password プレーンテキストのパスワード + * @param string $scramble サーバーから提供されたスクランブル文字列 + * @return string スクランブルされたパスワード(20バイト) + */ + private function scramblePassword(string $password, string $scramble): string { + $stage1 = sha1($password, true); + $stage2 = sha1($stage1, true); + $stage3 = sha1($scramble.$stage2, true); + + // $stage1 XOR $stage3 + $res = ''; + for ($i = 0; $i < 20; $i++) { + $res .= chr(ord($stage1[$i]) ^ ord($stage3[$i])); + } + + return $res; + } + + /** + * デバッグのためにパケットをログする + * + * パケット情報をログに追加し、デバッグ出力を表示します。 + * + * @param array $packetInfo パケット情報(方向、長さ、シーケンス番号、データ、タイムスタンプ) + * @return void + */ + private function logPacket(array $packetInfo): void { + $this->packetLog[] = $packetInfo; + + $direction = $packetInfo['direction']; + $length = $packetInfo['length']; + $seqNum = $packetInfo['seqNum']; + $data = $packetInfo['data']; + + echo "=== {$direction} パケット (長さ: {$length}, シーケンス: $seqNum) ===\n"; + echo $this->hexDumpWithAscii($data)."\n"; + + $this->interpretPacket($data); + + echo "==========================================\n\n"; + } + + /** + * バイナリデータを16進数で出力する + * + * デバッグ用にデータを16進数形式で表示します。 + * + * @param string $data バイナリデータ + * @return string 16進数文字列 + */ + private function hexDump(string $data): string { + $res = ''; + $len = strlen($data); + + for ($i = 0; $i < $len; $i++) { + $res .= sprintf('%02X ', ord($data[$i])); + + // 読み易さの為、各16バイトで新行列を入る + if (($i + 1) % 16 === 0 && $i !== $len - 1) { + $res .= "\n"; + } + } + + return $res; + } + + /** + * バイナリデータをASCIIで出力する + * + * デバッグ用にデータをASCII形式で表示します。 + * + * @param string $data バイナリデータ + * @return string ASCII文字列 + */ + private function asciiDump(string $data): string { + $res = ''; + $len = strlen($data); + + for ($i = 0; $i < $len; $i++) { + $char = ord($data[$i]); + + // 表示出来るASCII文字だけを書き出す + if ($char >= 32 && $char <= 126) { + $res .= $data[$i]; + } else { + $res .= '.'; + } + + // 読み易さの為、各16バイトで新行列を入る + if (($i + 1) % 16 === 0 && $i !== $len - 1) { + $res .= "\n"; + } + } + + return $res; + } + + /** + * バイナリデータを2進数で出力する + * + * デバッグ用にデータを2進数形式で表示します。 + * + * @param string $data バイナリデータ + * @return string 2進数文字列 + */ + private function binaryDump(string $data): string { + $res = ''; + $len = strlen($data); + + for ($i = 0; $i < $len; $i++) { + $res .= sprintf('%08b ', ord($data[$i])); + + // 読み易さの為、各88バイトで新行列を入る + if (($i + 1) % 8 === 0 && $i !== $len - 1) { + $res .= "\n"; + } + } + + return $res; + } + + /** + * パケットを最初のバイトに基づいて解釈する + * + * パケットの種類を特定し、デバッグ情報を出力します。 + * + * @param string $data パケットデータ + * @return void + */ + private function interpretPacket(string $data): void { + if (empty($data)) { + echo "解釈: 空パケット\n"; + return; + } + + $firstByte = ord($data[0]); + + switch ($firstByte) { + case 0x00: + echo "解釈: OKパケット\n"; + $this->debugOkPacket($data); + break; + case 0x17: + echo "解釈: COM_STMT_EXECUTEパケット\n"; + $this->debugStmtExecutePacket($data); + break; + case 0xFF: + echo "解釈: エラーパケット\n"; + $this->debugErrorPacket($data); + break; + case 0xFE: + echo "解釈: EOFパケット\n"; + break; + case 0xFB: + echo "解釈: LOCAL INFILEリクエスト\n"; + break; + default: + if ($firstByte === 3 + && $data[1] === 'd' + && $data[2] === 'e' + && $data[3] === 'f') { + // フィールドパケットかどうかの確認 + echo "解釈: フィールド説明パケット\n"; + $this->debugFieldPacket($data); + } else if ($firstByte > 0 && $firstByte < 251) { + // 結果セットパケットかどうかの確認 + echo "解釈: 結果セットヘッダーパケット(フィールド数: {$firstByte})\n"; + } else { + // 以上じゃないと、列データパケットでかもしん + echo "解釈: 列データパケット又はその他のパケット種類\n"; + $this->debugLengthEncodedStrings($data); + } + break; + } + } + + /** + * OKパケット構造をデバッグする + * + * OKパケットの内容を解析し、デバッグ情報を出力します。 + * + * @param string $data OKパケットデータ + * @return void + */ + private function debugOkPacket(string $data): void { + if (strlen($data) < 2) { + echo ' Error: OK packet too short ('.strlen($data)." bytes)\n"; + return; + } + + $pos = 1; // ヘッダーバイトをスキップ + + // 影響を受けた行数を取得 + $affectedRows = $this->getLengthEncodedIntegerValue($data, $pos); + echo " 影響を受けた行数: {$affectedRows}\n"; + $pos += $this->getLengthEncodedIntegerSize($affectedRows); + + // 最後の挿入IDを取得 + $insertId = $this->getLengthEncodedIntegerValue($data, $pos); + echo " 最後の挿入ID: {$insertId}\n"; + $pos += $this->getLengthEncodedIntegerSize($insertId); + + // サーバーステータス + if (strlen($data) >= $pos + 2) { + $serverStatus = unpack('v', substr($data, $pos, 2))[1]; + echo " サーバーステータス: ".sprintf('0x%04X', $serverStatus)."\n"; + $pos += 2; + } else { + echo " サーバーステータス: 利用不可\n"; + } + + // 警告カウント + if (strlen($data) >= $pos + 2) { + $warningCount = unpack('v', substr($data, $pos, 2))[1]; + echo " 警告カウント: {$warningCount}\n"; + $pos += 2; + } else { + echo " 警告カウント: 利用不可\n"; + } + + // サーバーメッセージ(存在する場合) + if (strlen($data) > $pos) { + $message = substr($data, $pos); + echo " メッセージ: ".$this->safeString($message)."\n"; + } + } + + /** + * エラーパケット構造をデバッグする + * + * エラーパケットの内容を解析し、デバッグ情報を出力します。 + * + * @param string $data エラーパケットデータ + * @return void + */ + private function debugErrorPacket(string $data): void { + $errorCode = unpack('v', substr($data, 1, 2))[1]; + echo " エラーコード: {$errorCode}\n"; + + // SQLステートマーカーをスキップ(#) + $sqlState = substr($data, 4, 5); + echo " SQLステート: {$sqlState}\n"; + + $errorMessage = substr($data, 9); + echo " エラーメッセージ: ".$this->safeString($errorMessage)."\n"; + } + + /** + * フィールドパケット構造をデバッグする + * + * フィールドパケットの内容を解析し、デバッグ情報を出力します。 + * + * @param string $data フィールドパケットデータ + * @return void + */ + private function debugFieldPacket(string $data): void { + $pos = 0; + + // パケットから長さエンコード文字列を抽出 + echo " フィールドパケット構造:\n"; + + // カタログを抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $catalog = substr($data, $pos, $len); + echo " カタログ: ".$this->safeString($catalog)." (長さ: $len)\n"; + $pos += $len; + + // データベースを抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $database = substr($data, $pos, $len); + echo " データベース: ".$this->safeString($database)." (長さ: $len)\n"; + $pos += $len; + + // テーブルを抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $table = substr($data, $pos, $len); + echo " テーブル: ".$this->safeString($table)." (長さ: $len)\n"; + $pos += $len; + + // 元のテーブルを抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $orgTable = substr($data, $pos, $len); + echo " 元のテーブル: ".$this->safeString($orgTable)." (長さ: $len)\n"; + $pos += $len; + + // 名前を抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $name = substr($data, $pos, $len); + echo " 名前: ".$this->safeString($name)." (長さ: $len)\n"; + $pos += $len; + + // 元の名前を抽出 + $len = $this->getLengthEncodedIntegerValue($data, $pos); + $pos += $this->getLengthEncodedIntegerSize($len); + $orgName = substr($data, $pos, $len); + echo " 元の名前: ".$this->safeString($orgName)." (長さ: $len)\n"; + $pos += $len; + + // 次の長さエンコード整数を抽出(固定フィールドの長さ、通常は0x0C) + $fixedLength = $this->getLengthEncodedIntegerValue($data, $pos); + echo " 固定フィールドの長さ: {$fixedLength}\n"; + $pos += $this->getLengthEncodedIntegerSize($fixedLength); + + // 文字セット + $charSet = unpack('v', substr($data, $pos, 2))[1]; + echo " 文字セット: ".sprintf('0x%04X', $charSet)."\n"; + $pos += 2; + + // 列の長さ + $columnLength = unpack('V', substr($data, $pos, 4))[1]; + echo " 列の長さ: {$columnLength}\n"; + $pos += 4; + + // 列の種類 + $columnType = ord($data[$pos]); + echo " 列の種類: ".sprintf('0x%02X', $columnType)."\n"; + $pos++; + + // フラグ + $flags = unpack('v', substr($data, $pos, 2))[1]; + echo " フラグ: ".sprintf('0x%04X', $flags)."\n"; + $pos += 2; + + // 小数点以下の桁数 + $decimals = ord($data[$pos]); + echo " 小数点以下の桁数: {$decimals}\n"; + $pos++; + + // フィルター + echo " フィルター: ".sprintf('0x%04X', unpack('v', substr($data, $pos, 2))[1])."\n"; + } + + /** + * パケット内の長さエンコード文字列をデバッグする + * + * パケットからすべての長さエンコード文字列を抽出し、デバッグ情報を出力します。 + * + * @param string $data パケットデータ + * @return void + */ + private function debugLengthEncodedStrings(string $data): void { + $pos = 0; + $length = strlen($data); + $stringCount = 0; + + echo " 長さエンコード文字列:\n"; + + while ($pos < $length) { + // 長さエンコード文字列を特定 + if ($pos >= $length) break; + + $firstByte = ord($data[$pos]); + + // MySQLプロトコルに基づく長さエンコーディング + if ($firstByte < 251) { + // 1バイト長 + $len = $firstByte; + $pos++; + + if ($pos + $len <= $length) { + $value = substr($data, $pos, $len); + echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) + ." (長さ: {$len})\n"; + $pos += $len; + } else { + echo " 位置 {$pos} での無効な長さエンコーディング\n"; + break; + } + } else if ($firstByte == 251) { + // NULL値 + echo " 文字列 ".(++$stringCount).": NULL\n"; + $pos++; + } else if ($firstByte == 252) { + // 2バイト長 + if ($pos + 3 <= $length) { + $len = unpack('v', substr($data, $pos + 1, 2))[1]; + $pos += 3; + + if ($pos + $len <= $length) { + $value = substr($data, $pos, $len); + echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) + ." (長さ: {$len})\n"; + $pos += $len; + } else { + echo " 位置 {$pos} での無効な長さエンコーディング\n"; + break; + } + } else { + echo " 位置 {$pos} での不完全な2バイト長\n"; + break; + } + } else if ($firstByte == 253) { + // 3バイト長 + if ($pos + 4 <= $length) { + $len = unpack('V', substr($data, $pos + 1, 3)."\0")[1]; + $pos += 4; + + if ($pos + $len <= $length) { + $value = substr($data, $pos, $len); + echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) + ." (長さ: {$len})\n"; + $pos += $len; + } else { + echo " 位置 {$pos} での無効な長さエンコーディング\n"; + break; + } + } else { + echo " 位置 {$pos} での不完全な3バイト長\n"; + break; + } + } else if ($firstByte == 254) { + // 8バイト長 + if ($pos + 9 <= $length) { + // PHPでは8バイト整数を完全に扱えないため、最初の4バイトのみ読み取る + $len = unpack('V', substr($data, $pos + 1, 4))[1]; + $pos += 9; + + if ($pos + $len <= $length) { + $value = substr($data, $pos, $len); + echo " 文字列 ".(++$stringCount).": ".$this->safeString($value) + ." (長さ: {$len})\n"; + $pos += $len; + } else { + echo " 位置 {$pos} での無効な長さエンコーディング\n"; + break; + } + } else { + echo " 位置 {$pos} での不完全な8バイト長\n"; + break; + } + } else { + // 長さエンコード文字列でない場合、次のバイトへ + $pos++; + } + } + + if ($stringCount === 0) { + echo " No length-encoded strings found\n"; + } + } + + /** + * 文字列を安全に出力用に変換する + * + * 表示可能な文字のみを含み、非表示文字は16進数で表現します。 + * + * @param string $str 変換する文字列 + * @return string 安全な文字列 + */ + private function safeString(string $str): string { + $result = ''; + $length = strlen($str); + + for ($i = 0; $i < $length; $i++) { + $char = ord($str[$i]); + if ($char >= 32 && $char <= 126) { + $result .= $str[$i]; + } else { + $result .= '\\x'.sprintf('%02X', $char); + } + } + + return $result; + } + + /** + * 指定位置の長さエンコード整数値を取得する + * + * パケット内の長さエンコード整数を読み取ります。 + * + * @param string $data パケットデータ + * @param int $pos 開始位置 + * @return mixed 整数値または0(不明な場合) + */ + private function getLengthEncodedIntegerValue(string $data, int $pos): mixed { + $firstByte = ord($data[$pos]); + + if ($firstByte < 251) { + return $firstByte; + } else if ($firstByte == 252) { + return unpack('v', substr($data, $pos + 1, 2))[1]; + } else if ($firstByte == 253) { + return unpack('V', substr($data, $pos + 1, 3)."\0")[1]; + } else if ($firstByte == 254) { + // 簡略化のため、8バイト整数の最初の4バイトのみ読み取る + return unpack('V', substr($data, $pos + 1, 4))[1]; + } + + return 0; + } + + /** + * 長さエンコード整数のサイズを取得する + * + * 値に基づいて長さエンコードに必要なバイト数を返します。 + * + * @param int $value 整数値 + * @return int バイト数 + */ + private function getLengthEncodedIntegerSize(int $value): int { + if ($value < 251) { + return 1; + } else if ($value < 65536) { + return 3; // 0xFCマーカー1バイト + 値2バイト + } else if ($value < 16777216) { + return 4; // 0xFDマーカー1バイト + 値3バイト + } else { + return 9; // 0xFEマーカー1バイト + 値8バイト + } + } + + /** + * 整数を長さエンコード形式に変換する + * + * MySQLプロトコルに基づいて整数を長さエンコードします。 + * + * @param int $value 変換する整数 + * @return string 長さエンコードされた文字列 + */ + private function encodeLengthEncodedInteger(int $value): string { + if ($value < 251) { + return chr($value); + } else if ($value < 65536) { + return chr(0xFC).pack('v', $value); + } else if ($value < 16777216) { + return chr(0xFD).pack('V', $value & 0xFFFFFF); + } else { + return chr(0xFE).pack('P', $value); + } + } + + /** + * STMTパケットをデバッグする + * + * COM_STMT_EXECUTEパケットの内容を解析し、デバッグ情報を出力します。 + * + * @param string $data パケットデータ + * @return void + */ + private function debugStmtExecutePacket(string $data): void { + $pos = 1; // コマンドバイトをスキップ + $statementId = unpack('V', substr($data, $pos, 4))[1]; + echo " ステートメントID: {$statementId}\n"; + $pos += 5; // ステートメントIDとカーソルフラグをスキップ + $iterationCount = unpack('V', substr($data, $pos, 4))[1]; + echo " 繰り返し数: {$iterationCount}\n"; + $pos += 4; + + $numParams = $this->prepared[$statementId]['num_params'] ?? 0; + if ($numParams > 0) { + $nullBitmapLen = ceil($numParams / 8); + $nullBitmap = substr($data, $pos, $nullBitmapLen); + echo " NULLビットマップ: ".$this->hexDump($nullBitmap)."\n"; + $pos += $nullBitmapLen; + + $newParamsFlag = ord($data[$pos]); + echo " 新パラメータフラグ: {$newParamsFlag}\n"; + $pos++; + + if ($newParamsFlag) { + echo " パラメータ種類:\n"; + + for ($i = 0; $i < $numParams; $i++) { + $type = unpack('v', substr($data, $pos, 2))[1]; + $typeName = $this->getMysqlTypeName($type); + echo " パラメータ {$i}: ".sprintf("0x%02X", $type)." ({$typeName})\n"; + $pos += 2; + } + + echo " パラメータ値: (生バイナリが続く)\n"; + } + } + } + + /** + * 16進値をMySQLタイプ名に変換する + * + * MySQLのデータ型コードを対応する型名に変換します。 + * + * @param int $type 16進数の型コード + * @return string 型名(不明な場合は'UNKNOWN') + */ + private function getMysqlTypeName(int $type): string { + $types = [ + 0x00 => 'MYSQL_TYPE_DECIMAL', + 0x01 => 'MYSQL_TYPE_TINY', + 0x02 => 'MYSQL_TYPE_SHORT', + 0x03 => 'MYSQL_TYPE_LONG', + 0x04 => 'MYSQL_TYPE_FLOAT', + 0x05 => 'MYSQL_TYPE_DOUBLE', + 0x06 => 'MYSQL_TYPE_NULL', + 0x07 => 'MYSQL_TYPE_TIMESTAMP', + 0x08 => 'MYSQL_TYPE_LONGLONG', + 0x09 => 'MYSQL_TYPE_INT24', + 0x0A => 'MYSQL_TYPE_DATE', + 0x0B => 'MYSQL_TYPE_TIME', + 0x0C => 'MYSQL_TYPE_DATETIME', + 0x0D => 'MYSQL_TYPE_YEAR', + 0x0E => 'MYSQL_TYPE_NEWDATE', + 0x0F => 'MYSQL_TYPE_VARCHAR', + 0x10 => 'MYSQL_TYPE_BIT', + 0xF6 => 'MYSQL_TYPE_NEWDECIMAL', + 0xF7 => 'MYSQL_TYPE_ENUM', + 0xF8 => 'MYSQL_TYPE_SET', + 0xF9 => 'MYSQL_TYPE_TINY_BLOB', + 0xFA => 'MYSQL_TYPE_MEDIUM_BLOB', + 0xFB => 'MYSQL_TYPE_LONG_BLOB', + 0xFC => 'MYSQL_TYPE_BLOB', + 0xFD => 'MYSQL_TYPE_VAR_STRING', + 0xFE => 'MYSQL_TYPE_STRING', + 0xFF => 'MYSQL_TYPE_GEOMETRY', + ]; + + return $types[$type] ?? '不明'; + } + + /** + * 16進数とASCII値を横に並べて出力する + * + * 16進エディタのように、16進数とASCII値を並べて表示します。 + * + * @param string $data バイナリデータ + * @return string フォーマットされた文字列 + */ + private function hexDumpWithAscii(string $data): string { + $output = ''; + $len = strlen($data); + $offset = 0; + + while ($offset < $len) { + $hex = ''; + $ascii = ''; + $bytesInLine = min(16, $len - $offset); // 1行あたり16バイト + + // 16進数部分 + for ($i = 0; $i < 16; $i++) { + if ($i < $bytesInLine) { + $hex .= sprintf('%02X ', ord($data[$offset + $i])); + } else { + $hex .= ' '; // 揃えのためにスペースを埋める + } + } + + // ASCII部分 + for ($i = 0; $i < $bytesInLine; $i++) { + $char = ord($data[$offset + $i]); + $ascii .= ($char >= 32 && $char <= 126) ? chr($char) : '.'; + } + + $output .= sprintf("%08X %s |%s|\n", $offset, $hex, $ascii); + $offset += 16; + } + + return $output; + } +} \ No newline at end of file diff --git a/src/Site/Lib/Route.php b/src/Site/Lib/Route.php new file mode 100644 index 0000000..92e6b54 --- /dev/null +++ b/src/Site/Lib/Route.php @@ -0,0 +1,163 @@ + $method, + 'path' => $path, + 'class' => $class, + 'params' => $params, + ]; + + self::$routes[] = $route; + return $route; + } + + /** + * 404処理用のフォールバックルートを設定する + * + * @param array|string|callable $class + * @return void + */ + public static function setFallback(array|string|callable $class): void { + self::$fallback = [ + 'class' => $class, + 'params' => [], + ]; + } + + /** + * 適切なルートをマッチさせて実行する + * + * @param string $uri リクエストURI + * @return void + */ + public static function dispatch(string $uri): void { + // URIをパスとクエリ文字列に分割 + $uriParts = explode('?', $uri, 2); + $path = trim($uriParts[0], " \t\n\r\0\x0B/"); + + // ルートパスの処理(/?page=2のようなクエリパラメータを含む場合も処理) + if ($path === '') { + self::executeClass([ + 'class' => [new \Site\Controller\Home(), 'show'], + 'params' => ['lang' => 'ja'], + ]); + return; + } + + if ($path === 'en') { + self::executeClass([ + 'class' => [new \Site\Controller\Home(), 'show'], + 'params' => ['lang' => 'en'], + ]); + return; + } + + // パスに対してルートをマッチングする + foreach (self::$routes as $route) { + $matches = []; + + if (self::matchRoute($route['path'], $path, $matches)) { + $params = self::extractParams($route['path'], $path); + $params = array_merge($route['params'], $params); + + if (is_string($route['class'])) { + [ $class, $method ] = explode('@', $route['class']); + $controller = new $class(); + self::executeClass([ + 'class' => [ $controller, $method ], + 'params' => $params, + ]); + + return; + } elseif (is_callable($route['class'])) { + self::executeClass([ + 'class' => $route['class'], + 'params' => $params, + ]); + + return; + } + } + } + + // マッチするルートがない場合、フォールバックを実行 + self::executeClass(self::$fallback); + } + + /** + * ルートパターンとパスをマッチングする + * + * @param string $pattern ルートパターン + * @param string $path 現在のパス + * @param array $matches マッチを格納する参照 + * @return bool + */ + protected static function matchRoute(string $pattern, string $path, + array &$matches = []): bool { + // ルートパターンを正規表現パターンに変換 + $pattern = preg_replace('/\{([^:}]+)(?::([^}]+))?\}/', '(?P<$1>[^/]+)', $pattern); + $pattern = str_replace('/', '\/', $pattern); + return (bool)preg_match('/^'.$pattern.'$/', $path, $matches); + } + + /** + * パターンに基づいてパスから名前付きパラメータを抽出する + * + * @param string $pattern ルートパターン + * @param string $path 現在のパス + * @return array + */ + protected static function extractParams(string $pattern, string $path): array { + $params = []; + $patternParts = explode('/', $pattern); + $pathParts = explode('/', $path); + + foreach ($patternParts as $k => $v) { + if (preg_match('/\{([^:}]+)(?::([^}]+))?\}/', $v, $matches)) { + if (isset($pathParts[$k])) { + $params[$matches[1]] = $pathParts[$k]; + } + } + } + + return $params; + } + + /** + * ルートクラスを実行する + * + * @param array $route ルート設定 + * @return void + */ + protected static function executeClass(array $route): void { + if (is_callable($route['class'])) { + call_user_func($route['class'], $route['params'] ?? []); + } + } +} \ No newline at end of file diff --git a/src/Site/Lib/Template.php b/src/Site/Lib/Template.php new file mode 100644 index 0000000..5abfebc --- /dev/null +++ b/src/Site/Lib/Template.php @@ -0,0 +1,243 @@ +tmplPath = rtrim($tmplPath, '/'); + + if (substr($this->tmplPath, 0, 1) !== '/') { + $this->tmplPath = '/'.$this->tmplPath; + } + } + + /** + * テンプレート変数に値を割り当てる + * + * @param string $name 変数名 + * @param mixed $value 値 + * @return void + */ + public function assign(string $name, mixed $value): void { + $this->vars[$name] = $value; + } + + /** + * カスタムCSSファイルを追加する + * + * @param string $name CSSファイル名 + * @return void + */ + public function addCss(string $name): void { + $this->custCss[] = + ''; + $this->assign('custCss', $this->custCss); + } + + /** + * カスタム関数を登録する + * + * @param string $name 関数名 + * @param callable $callback コールバック関数 + * @return void + */ + public function registerFunction(string $name, callable $callback): void { + $this->custFunc[$name] = $callback; + } + + /** + * テンプレートブロックを定義する + * + * @param string $name ブロック名 + * @param string $content ブロック内容 + * @return void + */ + public function defineBlock(string $name, string $content): void { + if (!isset($this->blocks[$name])) + $this->blocks[$name] = $content; + } + + /** + * テンプレートをレンダリングする + * + * @param string $tmplName テンプレート名 + * @return void + */ + public function render(string $tmplName): void { + $tmplPath = ROOT.'/view'.$this->tmplPath.'/'.$tmplName.$this->tmplExt; + if (!file_exists($tmplPath)) + throw new \RuntimeException("テンプレートファイルを見つけません:{$tmplPath}"); + + extract($this->vars); + + $content = file_get_contents($tmplPath); + + // インクルードディレクティブを処理 + while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) { + $content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string { + $inclPath = ROOT.'/view/'.trim($m[1], "'\" ").$this->tmplExt; + if (!file_exists($inclPath)) + throw new \RuntimeException("ファイルを見つけません: {$inclPath}"); + return file_get_contents($inclPath); + }, $content); + } + + $content = $this->procDirs($content); + $content = $this->procVars($content); + $content = $this->procFuncs($content); + + $tmpFile = tempnam(sys_get_temp_dir(), 'tmpl_'); + file_put_contents($tmpFile, $content); + + include $tmpFile; + unlink($tmpFile); + } + + // 機能性メソッド + + /** + * テンプレートディレクティブを処理する + * + * @param string $content テンプレート内容 + * @return string|null 処理後の内容 + */ + private function procDirs(string $content): string|null { + // includeディレクティブの処理 + while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) { + $content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string { + $inclPath = ROOT.'/view/'.trim($m[1], "'\" ").'.php'; + if (!file_exists($inclPath)) + throw new \RuntimeException("ファイルを見つけません: {$inclPath}"); + return file_get_contents($inclPath); + }, $content); + } + + $content = preg_replace('/\{@\s*if\s*\((.*?)\):\s*@\}/', '{@ if ($1) @}', $content); + $content = preg_replace('/\{@\s*endif;\s*@\}/', '{@ endif @}', $content); + + $processDirectives = function($c): array|string|null { + // kysディレクティブの処理 + $c = preg_replace_callback('/\{@\s*kys\((.*?)\)\s*@\}/s', function($m): string { + return "'; print_r({$m[1]}); echo ''; die(); ?>"; + }, $c); + + // foreachループとネストした内容の処理 + $c = preg_replace_callback('/\{@\s*foreach\s*\((.*?)\)\s*@\}/s', function($m): string { + return ""; + }, $c); + + $c = preg_replace_callback('/\{@\s*endforeach\s*@\}/s', function($m): string { + return ""; + }, $c); + + // forループの処理 + $c = preg_replace_callback('/\{@\s*for\s*\((.*?)\)\s*@\}/s', function($m): string { + return ""; + }, $c); + + $c = preg_replace_callback('/\{@\s*endfor\s*@\}/s', function($m): string { + return ""; + }, $c); + + // if-elif-else-endifの処理 + $c = preg_replace_callback('/\{@\s*if\s*\((.*?)\)\s*@\}/s', function($m): string { + return ""; + }, $c); + + $c = preg_replace_callback('/\{@\s*elif\s*\((.*?)\)\s*@\}/s', function($m): string { + return ""; + }, $c); + + $c = preg_replace_callback('/\{@\s*else\s*@\}/s', function($m): string { + return ""; + }, $c); + + $c = preg_replace_callback('/\{@\s*endif\s*@\}/s', function($m): string { + return ""; + }, $c); + + return $c; + }; + + $previousContent = ''; + $maxIterations = 10; + $iterations = 0; + + while ($previousContent !== $content && $iterations < $maxIterations) { + $previousContent = $content; + $content = $processDirectives($content); + $iterations++; + } + + return $content; + } + + /** + * テンプレート変数を処理する + * + * @param string $content テンプレート内容 + * @return string 処理後の内容 + */ + private function procVars(string $content): string { + // 変数の出力(エスケープ処理なし) + $content = preg_replace_callback('/\{\{\{s*(.*?)\s*\}\}\}/', function($m): string { + return ''; + }, $content); + + // 変数の出力(エスケープ処理あり) + $content = preg_replace_callback('/\{\{\s*(.*?)\s*\}\}/', function($m): string { + return ''; + }, $content); + + // 変数の代入 + $content = preg_replace_callback('/\{\$\s*(.*?)\s*\$\}/', function($m): string { + $parts = explode('=', $m[1], 2); + if (count($parts) !== 2) + throw new \RuntimeException("不正な値の形式"); + return ''; + }, $content); + + // コメント + $content = preg_replace_callback('/\{#\s*(.*?)\s*#\}/', function($m): string { + return ''; + }, $content); + + // PHPコードの実行 + $content = preg_replace_callback('/\{\!\s*(.*?)\s*\!\}/', function($m): string { + return ''; + }, $content); + + return $content; + } + + /** + * カスタム関数を処理する + * + * @param string $content テンプレート内容 + * @return string 処理後の内容 + */ + private function procFuncs(string $content): string { + foreach ($this->custFunc as $name => $cb) { + $pattern = "/\{@\s*{$name}\((.*?)\)\s*@\}/"; + $content = preg_replace_callback($pattern, function($m) use ($cb): mixed { + $args = explode(',', $m[1]); + $args = array_map('trim', $args); + return call_user_func_array($cb, $args); + }, $content); + } + + return $content; + } +} \ No newline at end of file diff --git a/src/Site/Lib/Tester.php b/src/Site/Lib/Tester.php new file mode 100644 index 0000000..41222ad --- /dev/null +++ b/src/Site/Lib/Tester.php @@ -0,0 +1,587 @@ + "\033[0m", + 'red' => "\033[31m", + 'green' => "\033[32m", + 'yellow' => "\033[33m", + 'blue' => "\033[34m", + 'magenta' => "\033[35m", + 'cyan' => "\033[36m", + 'white' => "\033[37m", + 'bold' => "\033[1m", + ]; + + private array $failures = []; + private array $errors = []; + + /** + * コンストラクタ + * + * @param array $options 設定オプション + */ + public function __construct(array $options = []) { + // オプションを設定 + if (isset($options['colorOutput'])) { + $this->colorOutput = (bool)$options['colorOutput']; + } + + if (isset($options['verboseOutput'])) { + $this->verboseOutput = (bool)$options['verboseOutput']; + } + + if (isset($options['stopOnFailure'])) { + $this->stopOnFailure = (bool)$options['stopOnFailure']; + } + + // サポートされていない場合は色を無効にする + if (PHP_SAPI !== 'cli' || strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' + && !getenv('ANSICON')) { + $this->colorOutput = false; + } + } + + /** + * 各テストの前に実行する関数を登録する + * + * @param callable $callback コールバック関数 + * @return Tester このインスタンス + */ + public function beforeEach(callable $callback): Tester { + $this->beforeEachCallbacks[] = $callback; + return $this; + } + + /** + * 各テストの後に実行する関数を登録する + * + * @param callable $callback コールバック関数 + * @return Tester このインスタンス + */ + public function afterEach(callable $callback): Tester { + $this->afterEachCallbacks[] = $callback; + return $this; + } + + /** + * すべてのテストの前に実行する関数を登録する + * + * @param callable $callback コールバック関数 + * @return Tester このインスタンス + */ + public function beforeAll(callable $callback): Tester { + $this->beforeAllCallbacks[] = $callback; + return $this; + } + + /** + * すべてのテストの後に実行する関数を登録する + * + * @param callable $callback コールバック関数 + * @return Tester このインスタンス + */ + public function afterAll(callable $callback): Tester { + $this->afterAllCallbacks[] = $callback; + return $this; + } + + /** + * テストケースを定義する + * + * @param string $description テストケースの説明 + * @param callable $callback テストケース関数 + * @return Tester このインスタンス + */ + public function describe(string $description, callable $callback): Tester { + $this->currentTestCase = $description; + $this->output($this->colorize('bold', "テストケース: {$description}")); + + try { + foreach ($this->beforeAllCallbacks as $before) { + call_user_func($before); + } + + call_user_func($callback, $this); + + foreach ($this->afterAllCallbacks as $after) { + call_user_func($after); + } + } catch (\Throwable $e) { + $this->recordError( + "テストケースのセットアップ/ティアダウンでエラー: ".$e->getMessage(), + $e->getTraceAsString()); + } + + $this->output(''); + return $this; + } + + /** + * 単一のテストを実行する + * + * @param string $description テストの説明 + * @param callable $callback テスト関数 + * @return Tester このインスタンス + */ + public function it(string $description, callable $callback): Tester { + $this->currentTest = $description; + $this->testCount++; + + if ($this->verboseOutput) { + $this->output(" ⋄ テスト中: {$description}... ", false); + } + + try { + foreach ($this->beforeEachCallbacks as $before) { + call_user_func($before); + } + + call_user_func($callback, $this); + + foreach ($this->afterEachCallbacks as $after) { + call_user_func($after); + } + + // Test has passed. + $this->passCount++; + + if ($this->verboseOutput) { + $this->output($this->colorize('green', "合格")); + } + } catch (AssertionFailedException $e) { + $this->failCount++; + + if ($this->verboseOutput) { + $this->output($this->colorize('red', "失敗")); + $this->output($this->colorize('red', " → ".$e->getMessage())); + } + + $this->recordFailure($e->getMessage()); + + if ($this->stopOnFailure) { + $this->printSummary(); + exit(1); + } + } catch (\Throwable $e) { + $this->errorCount++; + + if ($this->verboseOutput) { + $this->output($this->colorize('yellow', "エラー")); + $this->output($this->colorize('yellow', " → ".$e->getMessage())); + } + + $this->recordError($e->getMessage(), $e->getTraceAsString()); + + if ($this->stopOnFailure) { + $this->printSummary(); + exit(1); + } + } + + return $this; + } + + /** + * テストをスキップする + * + * @param string $description テストの説明 + * @param string $reason スキップする理由。デフォルト: "まだ実装されていません" + * @return Tester このインスタンス + */ + public function skip(string $description, + string $reason = 'まだ実装されていません'): Tester { + if ($this->verboseOutput) { + $this->output(" ⋄ スキップ: {$description}... " + .$this->colorize('cyan', "スキップ")); + $this->output($this->colorize('cyan', " → {$reason}")); + } + + return $this; + } + + /** + * 条件がtrueである事をアサートする + * + * @param bool $condition チェックする条件 + * @param string $message 失敗時のオプションメッセージ + * @throws AssertionFailedException アサーションが失敗した場合 + * @return Tester このインスタンス + */ + public function assertTrue(bool $condition, + string $message = '条件がtrueであることを期待しました'): Tester { + if ($condition !== true) { + throw new AssertionFailedException($message); + } + + return $this; + } + + /** + * 条件がfalseである事をアサートする + * + * @param bool $condition チェックする条件 + * @param string $message 失敗時のオプションメッセージ + * @throws AssertionFailedException アサーションが失敗した場合 + * @return Tester このインスタンス + */ + public function assertFalse(bool $condition, + string $message = '条件がfalseであることを期待しました'): Tester { + if ($condition !== false) { + throw new AssertionFailedException($message); + } + + return $this; + } + + /** + * 二つの値が等しい事をアサートする + * + * @param mixed $expected 期待値 + * @param mixed $actual 実際の値 + * @param string|null $message 失敗時のオプションメッセージ + * @throws AssertionFailedException アサーションが失敗した場合 + * @return Tester このインスタンス + */ + public function assertEquals(mixed $expected, mixed $actual, + ?string $message = null): Tester { + if ($expected != $actual) { + if ($message === null) { + $expected = $this->exportValue($expected); + $actual = $this->exportValue($actual); + $message = "{$expected}を期待しましたが、{$actual}が得られました"; + } + + throw new AssertionFailedException($message); + } + + return $this; + } + + /** + * 二つの値が同一である事をアサートする + * + * @param mixed $expected 期待値 + * @param mixed $actual 実際の値 + * @param string|null $message 失敗時のオプションメッセージ + * @throws AssertionFailedException アサーションが失敗した場合 + * @return Tester このインスタンス + */ + public function assertSame(mixed $expected, mixed $actual, + ?string $message = null): Tester { + if ($expected !== $actual) { + if ($message === null) { + $expected = $this->exportValue($expected); + $actual = $this->exportValue($actual); + $message = + "{$expected}を期待しましたが、{$actual}が得られました(厳密な比較)"; + } + + throw new AssertionFailedException($message); + } + + return $this; + } + + /** + * 値がnullである事をアサートする + * + * @param mixed $actual チェックする値 + * @param string|null $message 失敗時のオプションメッセージ + * @throws AssertionFailedException アサーションが失敗した場合 + * @return Tester このインスタンス + */ + public function assertNull(mixed $actual, ?string $message = null): Tester { + if ($actual !== null) { + if ($message === null) { + $actual = $this->exportValue($actual); + $message = "nullを期待しましたが、{$actual}が得られました"; + } + + throw new AssertionFailedException($message); + } + + return $this; + } + + /** + * 値がnullでない事をアサートする + * + * @param mixed $actual チェックする値 + * @param string|null $message 失敗時のオプションメッセージ + * @throws AssertionFailedException アサーションが失敗した場合 + * @return Tester このインスタンス + */ + public function assertNotNull(mixed $actual, ?string $message = null): Tester { + if ($actual === null) { + if ($message === null) { + $message = "値がnullでない事を期待しました"; + } + + throw new AssertionFailedException($message); + } + + return $this; + } + + /** + * 値が特定のキーを持つ事をアサートする + * + * @param mixed $key チェックするキー + * @param array $array チェックする配列 + * @param string|null $message 失敗時のオプションメッセージ + * @throws AssertionFailedException アサーションが失敗した場合 + * @return Tester このインスタンス + */ + public function assertArrayHasKey(mixed $key, array $array, + ?string $message = null): Tester { + if (!is_array($array) && !($array instanceof \ArrayAccess)) { + throw new AssertionFailedException( + '第2引数は配列又はArrayAccessを実装している必要があります'); + } + + if (!array_key_exists($key, $array)) { + if ($message === null) { + $message = "配列がキー '{$key}' を持つ事を期待しました"; + } + + throw new AssertionFailedException($message); + } + + return $this; + } + + /** + * 文字列がサブ文字列を含むことをアサートする + * + * @param string $needle 検索するサブ文字列 + * @param string $haystack 検索対象の文字列 + * @param string|null $message 失敗時のオプションメッセージ + * @throws AssertionFailedException アサーションが失敗した場合 + * @return Tester このインスタンス + */ + public function assertStringContains(string $needle, string $haystack, + ?string $message = null): Tester { + if (!is_string($needle) || !is_string($haystack)) { + throw new AssertionFailedException('両方の引数は文字列である必要があります'); + } + + if (strpos($haystack, $needle) === false) { + if ($message === null) { + $message = "文字列 '{$haystack}' が '{$needle}' を含む事を期待しました"; + } + + throw new AssertionFailedException($message); + } + + return $this; + } + + /** + * コールバックが例外をスローする事をアサートする + * + * @param callable $callback 実行するコールバック + * @param string $exceptionClass 期待される例外クラス + * @param string|null $message 失敗時のオプションメッセージ + * @throws AssertionFailedException アサーションが失敗した場合 + * @return Tester このインスタンス + */ + public function assertThrows(callable $callback, string $exceptionClass, + ?string $message = null): Tester { + try { + call_user_func($callback); + + if ($message === null) { + $message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした"; + } + + throw new AssertionFailedException($message); + } catch (\Throwable $e) { + if (!($e instanceof $exceptionClass)) { + if ($message === null) { + $message = "'{$exceptionClass}' 型の例外を期待しましたが、" + .get_class($e)." が得られました"; + } + + throw new AssertionFailedException($message); + } + } + + return $this; + } + + /** + * Print a summary of the test results + * + * @return Tester + */ + public function printSummary(): Tester { + $this->output(''); + $this->output($this->colorize('bold', "テスト結果の概要:")); + $this->output(" テスト総数: {$this->testCount}"); + $this->output(" ".$this->colorize('green', "合格: {$this->passCount}")); + + if ($this->failCount > 0) { + $this->output(" ".$this->colorize('red', "失敗: {$this->failCount}")); + } else { + $this->output(" 失敗: 0"); + } + + if ($this->errorCount > 0) { + $this->output(" ".$this->colorize('yellow', "エラー: {$this->errorCount}")); + } else { + $this->output(" エラー: 0"); + } + + $this->output(''); + + // 失敗を書き出す + if (count($this->failures) > 0) { + $this->output($this->colorize('bold', "失敗:")); + + foreach ($this->failures as $i => $f) { + $num = $i + 1; + $this->output(" {$num}) {$f['testCase']} → {$f['test']}"); + $this->output(" ".$this->colorize('red', $f['message'])); + $this->output(''); + } + } + + // エラーを書き出す + if (count($this->errors) > 0) { + $this->output($this->colorize('bold', "エラー:")); + + foreach ($this->errors as $i => $e) { + $num = $i + 1; + $this->output(" {$num}) {$e['testCase']} → {$e['test']}"); + $this->output(" ".$this->colorize('yellow', $e['message'])); + + if (isset($e['trace'])) { + $this->output(" ".$this->colorize('yellow', "スタックトレース:")); + $this->output(" ".$this->colorize('yellow', $e['trace'])); + } + + $this->output(''); + } + } + + if ($this->failCount === 0 && $this->errorCount === 0) { + $this->output($this->colorize('green', "全てのテストに合格しました!")); + } else { + $this->output($this->colorize('red', "テストが失敗・エラーで完了しました。")); + } + + return $this; + } + + // 機能性メソッド + + /** + * コンソールにテキストを出力する + * + * @param string $text 出力するテキスト + * @param bool $newline 改行を追加するかどうか + * @return void + */ + private function output(string $text, bool $newline = true): void { + echo $text.($newline ? PHP_EOL : ''); + } + + /** + * 有効な場合はテキストに色を適用する + * + * @param string $color 色名 + * @param string $text 色付けするテキスト + * @return string + */ + private function colorize(string $color, string $text): string { + if (!$this->colorOutput || !isset($this->colors[$color])) { + return $text; + } + + return $this->colors[$color].$text.$this->colors['reset']; + } + + /** + * 値を表示用の文字列としてエクスポートする + * + * @param mixed $value エクスポートする値 + * @return string + */ + private function exportValue(mixed $value): string { + if (is_null($value)) return 'null'; + if (is_bool($value)) return $value ? 'true' : 'false'; + if (is_array($value)) return 'Array('.count($value).')'; + if (is_object($value)) return get_class($value).' Object'; + + if (is_string($value)) { + if (strlen($value) > 40) { + return "'".substr($value, 0, 37)."...'"; + } + + return "'{$value}'"; + } + + return (string)$value; + } + + /** + * テストの失敗を記録する + * + * @param string $message 失敗メッセージ + * @return void + */ + private function recordFailure(string $message): void { + $this->failures[] = [ + 'testCase' => $this->currentTestCase, + 'test' => $this->currentTest, + 'message' => $message, + ]; + } + + /** + * テストのエラーを記録する + * + * @param string $message エラーメッセージ + * @param string|null $trace スタックトレース + * @return void + */ + private function recordError(string $message, ?string $trace = null): void { + $this->errors[] = [ + 'testCase' => $this->currentTestCase, + 'test' => $this->currentTest, + 'message' => $message, + 'trace' => $trace, + ]; + } +} + +/** + * アサーション失敗用のカスタム例外 + */ +class AssertionFailedException extends \Exception { +} \ No newline at end of file diff --git a/src/Site/Test/LibCsv.php b/src/Site/Test/LibCsv.php new file mode 100644 index 0000000..ef6ed71 --- /dev/null +++ b/src/Site/Test/LibCsv.php @@ -0,0 +1,122 @@ + true, + 'verboseOutput' => true +]); + +$test->describe('Csvの基本的なパーシング', function($test): void { + $test->it('簡単なCSVファイルをパーシングするはず', function($test): void { + $str = "岩田聡,プロジューサー\n宮本茂,デザイナー\nJeffrey Epstein,幼児性愛者"; + + $tmpFile = tempnam(sys_get_temp_dir(), 'csv_test'); + file_put_contents($tmpFile, $str); + + $csv = new Csv($tmpFile); + $res = $csv->parse(); + + unlink($tmpFile); + + $expect = [ + ["岩田聡", "プロジューサー"], + ["宮本茂", "デザイナー"], + ["Jeffrey Epstein", "幼児性愛者"] + ]; + + $test->assertNotNull($res); + $test->assertEquals($res, $expect); + }); + + $test->it('異なるデリミタでCSVをパーシングするはず', function ($test): void { + // セミコロン + $semiStr = "岩田聡;プロジューサー\n宮本茂;デザイナー"; + $tmpFile = tempnam(sys_get_temp_dir(), 'csv_semi_test'); + file_put_contents($tmpFile, $semiStr); + + $csv = new Csv($tmpFile); + $res = $csv->parse(Delimiter::SEMICOLON); + + unlink($tmpFile); + + $expect = [ + ["岩田聡", "プロジューサー"], + ["宮本茂", "デザイナー"] + ]; + + $test->assertEquals($res, $expect, "セミコロンデリミタでパーシングに失敗"); + + // タブ + $tabStr = "岩田聡\tプロジューサー\n宮本茂\tデザイナー"; + $tmpFile = tempnam(sys_get_temp_dir(), 'csv_tab_test'); + file_put_contents($tmpFile, $tabStr); + + $csv = new Csv($tmpFile); + $res = $csv->parse(Delimiter::TAB); + + unlink($tmpFile); + + $test->assertEquals($res, $expect, "タブデリミタでパーシングに失敗"); + + // パイプ + $pipeStr = "岩田聡|プロジューサー\n宮本茂|デザイナー"; + $tmpFile = tempnam(sys_get_temp_dir(), 'csv_pipe_test'); + file_put_contents($tmpFile, $pipeStr); + + $csv = new Csv($tmpFile); + $res = $csv->parse(Delimiter::PIPE); + + unlink($tmpFile); + + $test->assertEquals($res, $expect, "パイプデリミタでパーシングに失敗"); + }); + + $test->it('ヘッダー付きCSVをパーシングするはず', function ($test) { + $str = "name,job title\n岩田聡,プロジューサー\n宮本茂,デザイナー"; + + $tmpFile = tempnam(sys_get_temp_dir(), 'csv_header_test'); + file_put_contents($tmpFile, $str); + + $csv = new Csv($tmpFile); + $res = $csv->parse(Delimiter::COMMA, true); // isHeader = true + + unlink($tmpFile); + + $expect = [ + 'header' => ["name", "job title"], + 'body' => [ + ["岩田聡", "プロジューサー"], + ["宮本茂", "デザイナー"], + ], + ]; + + $test->assertNotNull($res); + $test->assertEquals($res, $expect, "ヘッダーパーシングに失敗"); + }); + + $test->it('セル内にカンマを含むCSVをパーシングするはず', function ($test): void { + $str = "\"守矢, 諏訪子\",エンジニア\n\"青, 猫ちゃん\",サーバー管理者"; + + $tmpFile = tempnam(sys_get_temp_dir(), 'csv_quoted_comma_test'); + file_put_contents($tmpFile, $str); + + $csv = new Csv($tmpFile); + $res = $csv->parse(\Site\Lib\delimiter::COMMA); + + unlink($tmpFile); + + $expect = [ + ["守矢, 諏訪子", "エンジニア"], + ["青, 猫ちゃん", "サーバー管理者"] + ]; + + $test->assertNotNull($res); + $test->assertEquals($res, $expect, "セル内にカンマを含むパーシングに失敗"); + }); +}); diff --git a/src/Site/Test/LibCurl.php b/src/Site/Test/LibCurl.php new file mode 100644 index 0000000..afded6b --- /dev/null +++ b/src/Site/Test/LibCurl.php @@ -0,0 +1,92 @@ + true, + 'verboseOutput' => true +]); + +$test->describe('Curlの基本的な機能性', function($test): void { + $test->it('URLで使って新しいインスタンスを作成するはず', function($test): void { + $curl = new Curl('https://076.moe'); + $test->assertNotNull($curl); + }); + + $test->it('メソッドでURLを設定出来るはす', function($test): void { + $curl = new Curl(); + $curl->setUrl('https://076.moe'); + $test->assertNotNull($curl); + }); + + $test->it('ヘッダー文字を作成出来るはず', function($test): void { + $curl = new Curl('https://076.moe'); + $curl->setHeaders([ + 'Accept' => 'application/json', + 'User-Agent' => 'LoliTest/1.0' + ]); + + $reflectionClass = new \ReflectionClass($curl); + $method = $reflectionClass->getMethod('buildHeaderString'); + $method->setAccessible(true); + + $headerString = $method->invoke($curl); + $test->assertStringContains('Accept: application/json', $headerString); + $test->assertStringContains('User-Agent: LoliTest/1.0', $headerString); + }); + + $test->skip('移転を対応するはず', '作成中・・・'); + + $test->it('メソッドチェーニングを対応するはず', function($test): void { + $curl = new Curl(); + $result = $curl->setUrl('https://076.moe') + ->setMethod('GET') + ->setTimeout(30); + + $test->assertSame($curl, $result); + }); +}); + +$test->describe('Curl HTTP リクエスト', function($test): void { + $networkAvailable = true; + + if (!$networkAvailable) { + $test->skip('076.moeでGETリクエストの確認', 'ネットワークが無効です。'); + $test->skip('postman-echo.comでPOSTリクエストの確認', 'ネットワークが無効です。'); + return; + } + + $test->it('076.moeでGETリクエストの確認', function($test): void { + $curl = new Curl('https://076.moe'); + $result = $curl->execute(); + + $test->assertTrue($result); + $test->assertEquals(200, $curl->getResponseCode()); + $test->assertNotNull($curl->getResponseBody()); + $test->assertStringContains('getResponseBody()); + }); + + $test->it('postman-echo.comでPOSTリクエストの確認', function($test): void { + $curl = new Curl(); + $curl->setUrl('https://postman-echo.com/post') + ->setMethod('POST') + ->setPostFields([ + 'name' => '山田太郎', + 'email' => 't.yamada@example.com' + ]); + + $result = $curl->execute(); + $test->assertTrue($result); + $test->assertEquals(200, $curl->getResponseCode()); + + $responseBody = $curl->getResponseBody(); + $test->assertStringContains('山田太郎', $responseBody); + $test->assertStringContains('t.yamada@example.com', $responseBody); + }); +}); + +$test->printSummary(); \ No newline at end of file diff --git a/src/Site/Test/LibMysql.php b/src/Site/Test/LibMysql.php new file mode 100644 index 0000000..9d9b6c5 --- /dev/null +++ b/src/Site/Test/LibMysql.php @@ -0,0 +1,64 @@ + true, + 'verboseOutput' => true +]); + +$test->describe('パケットのデバッグ', function($test): void { + try { + $db = new Mysql(); + + $db->setDebug(true); + $db->connect(); + + $result = $db->query('SELECT * FROM user WHERE id = 1'); + + foreach ($result['rows'] as $row) { + echo "ユーザー名: ".$row['nickname']."\n"; + } + + $db->savePacketLogToFile('mysql_log.txt'); + $db->close(); + } catch (\Exception $e) { + echo 'エラー: '.$e->getMessage()."\n"; + } +}); + +$test->describe('プリペアドステートメント', function($test): void { + try { + $db = new Mysql(); + $db->connect(); + + // データの入り + $stmt = $db->prepare('INSERT INTO users (name, age) VALUES (?, ?)'); + $test->assertTrue($stmt); + + $db->execute($stmt, ['山田太郎', 25]); + // TODO: assert + + $close = $db->demolish($stmt); + $this->assertTrue($close); + + // データの受け取り + $stmt = $db->prepare('SELECT * FROM users WHERE age > ?'); + $test->assertTrue($stmt); + + $res = $db->execute($stmt, [20]); + // TODO: assert + print_r($res); + + $close = $db->demolish($stmt); + $this->assertTrue($close); + + $db->close(); + } catch (\Exception $e) { + echo 'エラー: '.$e->getMessage()."\n"; + } +}); \ No newline at end of file diff --git a/tester.php b/tester.php new file mode 100644 index 0000000..b5568da --- /dev/null +++ b/tester.php @@ -0,0 +1,62 @@ +getMessage()."\n"; + echo "ファイル: ".$e->getFile()." (行: ".$e->getLine().")\n\n"; + $failedFiles[] = [ + 'file' => $filename, + 'error' => $e->getMessage(), + 'line' => $e->getLine(), + ]; + } +} + +echo "\n"; +echo "テスト結果:\n"; +echo "------------------------------------------------\n"; +echo "テストファイル数: {$totalFiles}\n"; +echo "成功: {$successFiles}\n"; +echo "失敗: ".count($failedFiles)."\n"; + +if (!empty($failedFiles)) { + echo "\n失敗したテストファイル:\n"; + foreach ($failedFiles as $failed) { + echo "- {$failed['file']} (行: {$failed['line']}): {$failed['error']}\n"; + } +} \ No newline at end of file diff --git a/util.php b/util.php new file mode 100644 index 0000000..1d0d3a3 --- /dev/null +++ b/util.php @@ -0,0 +1,61 @@ +html { color: #fcfcfc; background-color: #232023; } body { margin: 0; }'; + echo '
    KILL
    YOUR
    SELF
    '; + echo '
    ';
    +  print_r($arg);
    +  echo '
    ';
    +  die();
    +}
    +
    +function base58btc_encode(string $bin): string {
    +  $a = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
    +  $base = 58;
    +  $num = \gmp_import($bin, 1, GMP_LSW_FIRST | GMP_NATIVE_ENDIAN);
    +  $res = '';
    +
    +  if (\gmp_cmp($num, 0) == 0) return '1';
    +
    +  while (\gmp_cmp($num, 0) > 0) {
    +    $mod = \gmp_intval(\gmp_mod($num, $base));
    +    $res = $a[$mod].$res;
    +    $num = \gmp_div_q($num, $base);
    +  }
    +
    +  $bytes = str_split($bin);
    +  foreach ($bytes as $byte) {
    +    if (ord($byte) === 0) {
    +      $res = '1'.$res;
    +    } else {
    +      break;
    +    }
    +  }
    +
    +  return $res;
    +}
    +
    +function logger(LogType $section, mixed $arg): void {
    +  if (LOGGING_ENABLED) {
    +    $logfile = ROOT.'/log/';
    +    if ($section == LogType::ActivityPub) $logfile .= 'ap_log.txt';
    +    else if ($section == LogType::Mailer) $logfile .= 'mail_log.txt';
    +    else if ($section == LogType::MySQL) $logfile .= 'mysql_log.txt';
    +    else if ($section == LogType::Csv) $logfile .= 'csv_log.txt';
    +
    +    file_put_contents($logfile, $arg."\n", FILE_APPEND);
    +  }
    +}
    diff --git a/view/404.maron b/view/404.maron
    new file mode 100644
    index 0000000..ead8ffd
    --- /dev/null
    +++ b/view/404.maron
    @@ -0,0 +1,3 @@
    +{@ include(common/header) @}
    +      

    見つけられません。ごめんなしゃーい!!

    +{@ include(common/footer) @} diff --git a/view/about.maron b/view/about.maron new file mode 100644 index 0000000..63c04f4 --- /dev/null +++ b/view/about.maron @@ -0,0 +1,6 @@ +{@ include(common/header) @} +

    新ページ

    +

    + 新ページだ +

    +{@ include(common/footer) @} diff --git a/view/article.maron b/view/article.maron new file mode 100644 index 0000000..49cfd4f --- /dev/null +++ b/view/article.maron @@ -0,0 +1,16 @@ +{@ include(common/header) @} +
    + {@ if (isset($meta->thumbnail) && $meta->thumbnail != '') @} +
    + +
    + {@ endif @} +
    +
    {{ $meta->date }}
    +
    {{ $meta->author }}
    +
    + +

    {{ $meta->title }}

    + {{{ $article }}} +
    +{@ include(common/footer) @} diff --git a/view/common/footer.maron b/view/common/footer.maron new file mode 100644 index 0000000..088f8bd --- /dev/null +++ b/view/common/footer.maron @@ -0,0 +1,8 @@ + + + + + diff --git a/view/common/header.maron b/view/common/header.maron new file mode 100644 index 0000000..b34f511 --- /dev/null +++ b/view/common/header.maron @@ -0,0 +1,59 @@ + + + + + + + {{ SITEINFO['title'] }}: {{ $pagetit }} + +{@ if (isset($custCss) && !empty($custCss) && !is_bool($custCss)) @} + {@ foreach ($custCss as $css) @} + {{{ $css }}} + {@ endforeach @} +{@ endif @} +{@ if (isset($meta)) @} + + {@ if (isset($meta->thumbnail)) @} + + {@ endif @} +{@ endif @} + + + + + + + + {@ if (isset($meta) && isset($meta->thumbnail)) @} + + {@ endif @} + + {@ if (TWITTER_HANDLE != '') @} + + + + + + {@ if (isset($meta) && isset($meta->thumbnail)) @} + + {@ endif @} + {@ endif @} + {@ if (ATOM_ENABLED) @} + + {@ endif @} + + +
    +
    + + +
    +
    diff --git a/view/common/newscard.maron b/view/common/newscard.maron new file mode 100644 index 0000000..58190a5 --- /dev/null +++ b/view/common/newscard.maron @@ -0,0 +1,23 @@ +
    + {@ if (isset($post['thumbnail']) && $post['thumbnail'] != '') @} +
    + + {{ $post['title'] }} + +
    + {@ endif @} +
    +
    + {{ $post['date'] }} + {# {@ if (isset($post['category']) && is_array($post['category'])) @} #} + {@ foreach ($post['category'] as $cat) @} + {{ $cat }} + {@ endforeach @} + {# {@ endif @} #} +
    +

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

    +

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

    +
    +
    \ No newline at end of file diff --git a/view/common/pagination.maron b/view/common/pagination.maron new file mode 100644 index 0000000..bdffdd9 --- /dev/null +++ b/view/common/pagination.maron @@ -0,0 +1,71 @@ + {@ if (isset($totalPages) && $totalPages > 1) @} + + {@ endif @} \ No newline at end of file diff --git a/view/common/search.maron b/view/common/search.maron new file mode 100644 index 0000000..2b90440 --- /dev/null +++ b/view/common/search.maron @@ -0,0 +1,6 @@ +
    +
    + + +
    +
    \ No newline at end of file diff --git a/view/home.maron b/view/home.maron new file mode 100644 index 0000000..03ea4a0 --- /dev/null +++ b/view/home.maron @@ -0,0 +1,9 @@ +{@ include(common/header) @} +{@ include(common/search) @} +
    + {@ foreach ($posts as $post) @} + {@ include(common/newscard) @} + {@ endforeach @} +
    + {@ include(common/pagination) @} +{@ include(common/footer) @} diff --git a/view/monero.maron b/view/monero.maron new file mode 100644 index 0000000..dcf3eaa --- /dev/null +++ b/view/monero.maron @@ -0,0 +1,15 @@ +{@ include(common/header) @} +

    Moneroで支援♡

    +

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

    + +

    + + +

    + +

    +

    88daW9ANXGVg9zHe6tzHSpQjxHN6JPFDz9wvZBecL1BfTFwmkuLYm9xRsLUt1WAVGPQ6h5pZX6nyu9zXFwE5efSz1gtE1oz
    +

    +{@ include(common/footer) @} diff --git a/view/secret.maron b/view/secret.maron new file mode 100644 index 0000000..fe600ec --- /dev/null +++ b/view/secret.maron @@ -0,0 +1,6 @@ +{@ include(common/header) @} +

    秘密のページ

    +

    + 内緒だね~ +

    +{@ include(common/footer) @}