複数ネームスペースを可能に

諏訪子 <suwako@076.moe>、2026-02-27 03:15
コミット a1bd29cff67efe9aa6cffcca56cb5ee54a0fdcfe ・親 b723230 ・ファイルを見る

52個のファイル、+5420 -5398

変更 autoload.php
@@ -1,9 +1,11 @@
11 <?php
22 spl_autoload_register(function ($class): void {
3- $prefix = 'Site\\';
3+ $prefixes = ['Site\\', 'Std\\'];
44 $base = realpath(__DIR__.'/src');
5+ $prefixExist = false;
56
6- if (strncmp($prefix, $class, strlen($prefix)) !== 0) return;
7+ foreach ($prefixes as $prefix) { if (strncmp($prefix, $class, strlen($prefix)) === 0) $prefixExist = true; }
8+ if (!$prefixExist) return;
79 $ps = str_replace('\\', DIRECTORY_SEPARATOR, $class);
810 $file = $base.DIRECTORY_SEPARATOR.$ps.'.php';
911 if (file_exists($file)) require $file;
@@ -1,6 +1,6 @@
11 # ルートの仕組み
22
3-`route.php` では、 `use Site\Lib\Route; `というインクルードもある事に
3+`route.php` では、 `use Std\Lib\Route; `というインクルードもある事に
44 気づくでしょう。
55 これが全てのルートを処理します。
66 ルートの基本的な流れは次の通りです:
変更 route.php
@@ -2,7 +2,7 @@
22
33 require_once __DIR__.DIRECTORY_SEPARATOR.'/autoload.php';
44
5-use Site\Lib\Route;
5+use Std\Lib\Route;
66
77 use Site\Controller\Atom;
88 use Site\Controller\Fediverse;
@@ -19,7 +19,6 @@
1919 ini_set('display_errors', 1);
2020 }
2121 include(ROOT.'/util.php');
22-
2322
2423 $routes = [
2524 Route::add('GET', 'blog/{page}', Home::class.'@article'),
@@ -2,7 +2,7 @@
22 namespace Site\Controller;
33
44 use Site\Controller\BlogPost;
5-use Site\Lib\Markdown;
5+use Std\Lib\Markdown;
66
77 class Atom extends BlogPost {
88 private string $domain = 'technicalsuwako.moe';
@@ -3,7 +3,7 @@
33
44 use Site\Controller\BlogPost;
55 use Site\Controller\Mods;
6-use Site\Lib\Activitypub;
6+use Std\Lib\Activitypub;
77
88 class Fediverse extends BlogPost {
99 use Mods;
@@ -3,10 +3,10 @@
33
44 use Site\Controller\BlogPost;
55 use Site\Controller\Mods;
6-use Site\Lib\Activitypub;
7-use Site\Lib\Auth;
8-use Site\Lib\Markdown;
9-use Site\Lib\Template;
6+use Std\Lib\Activitypub;
7+use Std\Lib\Auth;
8+use Std\Lib\Markdown;
9+use Std\Lib\Template;
1010
1111 class Home extends BlogPost {
1212 use Mods;
@@ -2,8 +2,8 @@
22 namespace Site\Controller;
33
44 use Site\Controller\Mods;
5-use Site\Lib\Auth;
6-use Site\Lib\Template;
5+use Std\Lib\Auth;
6+use Std\Lib\Template;
77
88 class Notfound {
99 use Mods;
@@ -2,8 +2,8 @@
22 namespace Site\Controller;
33
44 use Site\Controller\Mods;
5-use Site\Lib\Auth;
6-use Site\Lib\Template;
5+use Std\Lib\Auth;
6+use Std\Lib\Template;
77
88 class Page {
99 use Mods;
@@ -2,8 +2,8 @@
22 namespace Site\Controller;
33
44 use Site\Controller\Mods;
5-use Site\Lib\Auth;
6-use Site\Lib\Template;
5+use Std\Lib\Auth;
6+use Std\Lib\Template;
77
88 class User {
99 use Mods;
削除 src/Site/Lib/Activitypub.php
@@ -1,557 +0,0 @@
1-<?php
2-namespace Site\Lib;
3-
4-use Site\Lib\Curl;
5-
6-/**
7- * ActivityPubプロトコルの実装クラス
8- *
9- * このクラスはActivityPubプロトコルを利用して分散型
10- * ソーシャルネットワーキングを実装します。
11- */
12-class ActivityPub {
13- private string $domain;
14- private string $actor;
15- private string $actorNick;
16- private string $desc;
17- private string $icon;
18- private array $posts = [];
19- private string $https;
20-
21- /**
22- * コンストラクタ
23- *
24- * @param array $posts 投稿データの配列
25- */
26- public function __construct(array $posts = []) {
27- if (!ACTIVITYPUB_ENABLED) return;
28- $this->https = 'http'.((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 's' : '').'://';
29- $this->domain = $this->https.($_SERVER['SERVER_NAME'] ?? '127.0.0.1:8000');
30- $this->actor = FEDIINFO['actor'];
31- $this->actorNick = FEDIINFO['actorNick'];
32- $this->desc = FEDIINFO['desc'];
33- $this->icon = "{$this->domain}".FEDIINFO['icon'];
34- $this->posts = $posts;
35- }
36-
37- /**
38- * ActivityPubアクタープロフィールを受け取る
39- *
40- * @return string アクターオブジェクト
41- * @throws \Exception 公開鍵の読み込みに失敗した場合
42- */
43- public function getActor(): string {
44- if (!ACTIVITYPUB_ENABLED) return '';
45- $pubkey = file_get_contents(FEDIINFO['pubkey']);
46- if ($pubkey === false) {
47- throw new \Exception('公開鍵の受取に失敗。パス:'.FEDIINFO['pubkey']);
48- }
49-
50- $actor = [
51- '@context' => [
52- 'https://www.w3.org/ns/activitystreams',
53- 'https://w3id.org/security/v1',
54- ],
55- 'id' => "{$this->domain}/ap/actor",
56- 'name' => $this->actorNick,
57- 'summary' => $this->desc,
58- 'manuallyApprovesFollowers' => false,
59- 'icon' => [
60- 'type' => 'Image',
61- 'mediaType' => 'image/png',
62- 'url' => $this->icon,
63- ],
64- 'image' => [
65- 'type' => 'Image',
66- 'url' => "{$this->domain}/static/article/o_53803618dc1691.28179609.jpg",
67- 'mediaType' => 'image/jpeg',
68- ],
69- 'type' => 'Person',
70- 'url' => "{$this->domain}",
71- 'preferredUsername' => $this->actor,
72- 'inbox' => "{$this->domain}/ap/inbox",
73- 'outbox' => "{$this->domain}/ap/outbox",
74- 'followers' => "{$this->domain}/ap/followers",
75- 'following' => "{$this->domain}/ap/following",
76- 'published' => '2025-03-28T18:00:00Z',
77- 'updated' => gmdate('c'),
78- 'publicKey' => [
79- 'id' => "{$this->domain}/ap/actor#main-key",
80- 'owner' => "{$this->domain}/ap/actor",
81- 'publicKeyPem' => $pubkey,
82- ],
83- ];
84-
85- return json_encode($actor, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
86- }
87-
88- /**
89- * 特定のUUIDに対応するActivityを取得する
90- *
91- * @param string $uuid 取得するアクティビティのUUID
92- * @return string JSONエンコードされたアクティビティデータ
93- */
94- public function getActivity(string $uuid): string {
95- if (!ACTIVITYPUB_ENABLED) return '';
96- $items = [];
97-
98- foreach ($this->posts as $post) {
99- if ($post['uuid'] != $uuid) continue;
100-
101- $items = [
102- '@context' => [
103- 'https://www.w3.org/ns/activitystreams',
104- 'https://w3id.org/security/v1',
105- [
106- 'Emoji' => 'toot:Emoji',
107- 'EmojiReact' => 'litepub:EmojiReact',
108- 'Hashtag' => 'as:Hashtag',
109- 'litepub' => 'http://litepub.social/ns#',
110- 'sensitive' => 'as:sensitive',
111- 'toot' => 'http://joinmastodon.org/ns#',
112- ],
113- ],
114- 'id' => "{$this->domain}/ap/activities/create/{$post['uuid']}",
115- 'type' => 'Create',
116- 'actor' => "{$this->domain}/ap/actor",
117- 'cc' => [
118- "{$this->domain}/ap/followers",
119- ],
120- 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
121- 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
122- 'object' => [
123- 'id' => "{$this->domain}/ap/objects/{$post['uuid']}",
124- 'type' => 'Note',
125- 'name' => $post['title'],
126- 'attributedTo' => "{$this->domain}/ap/actor",
127- 'cc' => [
128- "{$this->domain}/ap/followers",
129- ],
130- 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
131- 'content' => $post['preview']."<br /><br /><a href=\"{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
132- 'url' => "{$this->domain}/blog/{$post['slug']}",
133- 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
134- 'replies' => "{$this->domain}/ap/objects/{$uuid}/replies",
135- 'sensitive' => false,
136- ],
137- ];
138-
139- if (isset($post['category']) && !empty($post['category'])) {
140- $item['tag'] = [];
141- foreach ($post['category'] as $cat) {
142- $items['tag'][] = $cat;
143- }
144- }
145-
146- if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
147- $imgurl = "{$this->domain}/static/article/{$post['thumbnail']}";
148- $imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
149- $imgraw = file_get_contents($imgpath);
150-
151- $items['attachment'] = [
152- [
153- 'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
154- 'mediaType' => mime_content_type($imgpath),
155- 'type' => "Image",
156- 'url' => $imgurl,
157- ],
158- ];
159- }
160-
161- break;
162- }
163-
164- return json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
165- }
166-
167- /**
168- * アウトボックスデータを取得する
169- *
170- * @return string JSONエンコードされたアウトボックスデータ
171- */
172- public function getOutbox(): string {
173- if (!ACTIVITYPUB_ENABLED) return '';
174- $items = [];
175- $counter = 0;
176-
177- foreach ($this->posts as $post) {
178- $uid = $post['uuid'];
179-
180- $items[] = [
181- '@context' => [
182- 'https://www.w3.org/ns/activitystreams',
183- 'https://w3id.org/security/v1',
184- [
185- 'Emoji' => 'toot:Emoji',
186- 'EmojiReact' => 'litepub:EmojiReact',
187- 'Hashtag' => 'as:Hashtag',
188- 'litepub' => 'http://litepub.social/ns#',
189- 'sensitive' => 'as:sensitive',
190- 'toot' => 'http://joinmastodon.org/ns#',
191- ],
192- ],
193- 'id' => "{$this->domain}/ap/activities/create/{$uid}",
194- 'type' => 'Create',
195- 'actor' => "{$this->domain}/ap/actor",
196- 'cc' => [
197- "{$this->domain}/ap/followers",
198- ],
199- 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
200- 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
201- 'object' => [
202- 'id' => "{$this->domain}/ap/objects/{$uid}",
203- 'type' => 'Note',
204- 'name' => $post['title'],
205- 'attributedTo' => "{$this->domain}/ap/actor",
206- 'cc' => [
207- "{$this->domain}/ap/followers",
208- ],
209- 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
210- 'content' => $post['preview']."<br /><br /><a href=\"{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
211- 'url' => "{$this->domain}/blog/{$post['slug']}",
212- 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
213- 'replies' => "{$this->domain}/ap/objects/{$uid}/replies",
214- 'sensitive' => false,
215- ],
216- ];
217-
218- if (isset($post['category']) && !empty($post['category'])) {
219- $items[$counter]['tag'] = [];
220- foreach ($post['category'] as $cat) {
221- $items[$counter]['tag'][] = $cat;
222- }
223- }
224-
225- if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
226- $imgurl = "{$this->domain}/static/article/{$post['thumbnail']}";
227- $imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
228- $imgraw = file_get_contents($imgpath);
229-
230- $items[$counter]['attachment'] = [
231- [
232- 'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
233- 'mediaType' => mime_content_type($imgpath),
234- 'type' => "Image",
235- 'url' => $imgurl,
236- ],
237- ];
238- }
239-
240- $counter++;
241- }
242-
243- $outbox = [
244- '@context' => [
245- 'https://www.w3.org/ns/activitystreams',
246- 'https://w3id.org/security/v1',
247- ],
248- 'id' => "{$this->domain}/ap/outbox",
249- 'type' => 'OrderedCollection',
250- 'totalItems' => count($items),
251- 'orderedItems' => $items,
252- ];
253-
254- return json_encode($outbox, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
255- }
256-
257- /**
258- * WebFingerデータを取得する
259- *
260- * @return string JSONエンコードされたWebFingerデータ
261- */
262- public function getWebfinger(): string {
263- if (!ACTIVITYPUB_ENABLED) return '';
264- $domain = str_replace(['http://', 'https://'], '', $this->domain);
265- $webfinger = [
266- 'subject' => "acct:{$this->actor}@{$domain}",
267- 'links' => [
268- [
269- 'rel' => 'self',
270- 'type' => 'application/activity+json',
271- 'href' => "{$domain}/ap/actor",
272- ],
273- ],
274- ];
275-
276- return json_encode($webfinger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
277- }
278-
279- /**
280- * フォロワーのリストを取得する
281- *
282- * @return string JSONエンコードされたフォロワーのリスト
283- */
284- public function getFollowers(): string {
285- if (!ACTIVITYPUB_ENABLED) return '';
286- $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
287-
288- $followers = [
289- '@context' => [
290- 'https://www.w3.org/ns/activitystreams',
291- 'https://w3id.org/security/v1',
292- ],
293- 'id' => "{$this->domain}/ap/followers",
294- 'type' => 'OrderredCollection',
295- 'totalItems' => count($f),
296- 'orderedItems' => $f,
297- ];
298-
299- return json_encode($followers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
300- }
301-
302- /**
303- * フォローしているアカウントのリストを取得する
304- *
305- * @return string JSONエンコードされたフォローリスト
306- */
307- public function getFollowing(): string {
308- if (!ACTIVITYPUB_ENABLED) return '';
309- $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/following.txt')));
310-
311- $following = [
312- '@context' => [
313- 'https://www.w3.org/ns/activitystreams',
314- 'https://w3id.org/security/v1',
315- ],
316- 'id' => "{$this->domain}/ap/following",
317- 'type' => 'OrderredCollection',
318- 'totalItems' => count($f),
319- 'orderedItems' => $f,
320- ];
321-
322- return json_encode($following, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
323- }
324-
325- /**
326- * インボックスにアクティビティを投稿する
327- *
328- * @param array $activity 処理するアクティビティデータ
329- * @return void
330- */
331- public function postInbox(array $activity): void {
332- if (!ACTIVITYPUB_ENABLED) return;
333- switch ($activity['type']) {
334- case 'Follow':
335- $this->acceptFollower($activity);
336- break;
337- default:
338- header('HTTP/1.1 501 Not Implemented');
339- header('Content-Type: application/activity+json');
340- echo json_encode(['error' => '未対応なアクティビティタイプ: '.$activity['type']]);
341- exit;
342- }
343-
344- header('HTTP/1.1 200 OK');
345- header('Content-Type: application/activity+json');
346- echo json_encode(['status' => 'OK'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
347- exit;
348- }
349-
350- /**
351- * アクタープロフィールの更新アクティビティを作成する
352- *
353- * @return string JSONエンコードされた更新アクティビティ
354- */
355- public function update(): string {
356- if (!ACTIVITYPUB_ENABLED) return '';
357- $update = [
358- '@context' => [
359- 'https://www.w3.org/ns/activitystreams',
360- 'https://w3id.org/security/v1',
361- ],
362- 'id' => "{$this->domain}/ap/activities/update/".uuid(),
363- 'type' => 'Update',
364- 'actor' => "{$this->domain}/ap/actor",
365- 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
366- 'object' => json_decode($this->getActor(), true),
367- ];
368-
369- return json_encode($update, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
370- }
371-
372- /**
373- * アクター更新をフォロワーに送信する
374- *
375- * @param array $params パラメータ配列
376- * @return void
377- */
378- public function sendActorUpdate(array $params): void {
379- if (!ACTIVITYPUB_ENABLED) return;
380- $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
381- $ap = new Activitypub();
382- $inboxes = implode("\n", $f);
383- $update = json_decode($ap->update(), true);
384-
385- foreach ($f as $inbox) $this->sendActivity($inbox, $update);
386- }
387-
388- // 機能性メソッド
389-
390- /**
391- * 指定されたインボックスURLにアクティビティを送信する
392- *
393- * @param string $inboxUrl 送信先のインボックスURL
394- * @param array $activity 送信するアクティビティデータ
395- * @return void
396- */
397- private function sendActivity(string $inboxUrl, array $activity): void {
398- $privFile = FEDIINFO['privkey'];
399- $priv = file_get_contents($privFile);
400- if ($priv === false) {
401- logger(\LogType::ActivityPub, "エラー:秘密鍵「{$privFile}」の読込に失敗");
402- header('HTTP/1.1 500 Internal Server Error');
403- header('Content-Type: application/activity+json');
404- echo json_encode(['error' => '秘密鍵の読込に失敗']);
405- exit;
406- }
407-
408- $body = json_encode($activity, JSON_UNESCAPED_SLASHES);
409- $digest = base64_encode(hash('sha256', $body, true));
410- $date = gmdate('D, d M Y H:i:s \G\M\T');
411- $host = parse_url($inboxUrl, PHP_URL_HOST);
412-
413- $headers = [
414- 'Host' => $host,
415- 'Date' => $date,
416- 'Content-Type' => 'application/activity+json',
417- 'Digest' => "SHA-256=$digest",
418- ];
419-
420- $stringToSign = "host: {$headers['Host']}\n"."date: {$headers['Date']}\n"."digest: {$headers['Digest']}";
421- logger(\LogType::ActivityPub, "署名対象: {$stringToSign}");
422-
423- if (!openssl_sign($stringToSign, $signature, $priv, OPENSSL_ALGO_SHA256)) {
424- $error = openssl_error_string();
425- logger(\LogType::ActivityPub, "エラー:署名に失敗: {$error}");
426- header('HTTP/1.1 500 Internal Server Error');
427- header('Content-Type: application/activity+json');
428- echo json_encode(['error' => '署名に失敗']);
429- exit;
430- }
431-
432- $sigValue = base64_encode($signature);
433- $headers['Signature'] = "keyId=\"{$this->domain}/ap/actor#main-key\",";
434- $headers['Signature'] .= 'algorithm="rsa-sha256",';
435- $headers['Signature'] .= 'headers="host date digest",';
436- $headers['Signature'] .= 'signature="'.$sigValue.'"';
437- logger(\LogType::ActivityPub, "署名: {$headers['Signature']}\n送信データ: {$body}");
438-
439- $curl = new Curl($inboxUrl);
440- $curl->setMethod('POST')
441- ->setPostRaw($body)
442- ->setHeaders(array_map(fn($k, $v) => "$k: $v", array_keys($headers), $headers))
443- ->setCaInfo('/etc/ssl/cert.pem')
444- ->setVerbose(true)
445- ->setStderr(fopen(ROOT.'/log/ap_log.txt', 'a'));
446-
447- $success = $curl->execute();
448- $res = $curl->getResponseBody();
449- $code = $curl->getResponseCode();
450- $err = $curl->getError();
451-
452- var_dump(print_r($res));
453- logger(\LogType::ActivityPub, "アクティビティは「{$inboxUrl}」に送信しました: HTTP {$code}");
454- logger(\LogType::ActivityPub, "エラー: {$err}");
455- logger(\LogType::ActivityPub, "レスポンス: {$res}");
456- }
457-
458- /**
459- * フォロワーを受け入れる
460- *
461- * @param array $activity フォローアクティビティデータ
462- * @return void
463- */
464- private function acceptFollower(array $activity): void {
465- $followerActor = $activity['actor'] ?? null;
466- if (!$followerActor) {
467- header('HTTP/1.1 400 Bad Request');
468- header('Content-Type: application/activity+json');
469- echo json_encode(['error' => 'アクターがない']);
470- exit;
471- }
472-
473- $this->storeFollower($followerActor);
474-
475- $inbox = $this->getInboxFromActor($followerActor);
476- if (!$inbox) {
477- header('HTTP/1.1 500 Internal Server Error');
478- header('Content-Type: application/activity+json');
479- echo json_encode(['error' => 'フォロワーの受付ボックスの受取に失敗']);
480- exit;
481- }
482-
483- $accept = [
484- '@context' => [
485- 'https://www.w3.org/ns/activitystreams',
486- 'https://w3id.org/security/v1',
487- ],
488- 'id' => "{$this->domain}/ap/activities/".uniqid(),
489- 'type' => 'Accept',
490- 'actor' => "{$this->domain}/ap/actor",
491- 'object' => $activity,
492- ];
493-
494- $this->sendActivity($inbox, $accept);
495- }
496-
497- /**
498- * フォロワーをストレージに保存する
499- *
500- * @param string $followerActor フォロワーのアクターURL
501- * @return void
502- */
503- private function storeFollower(string $followerActor): void {
504- $file = ROOT.'/data/followers.txt';
505- if (!file_exists($file)) {
506- touch($file);
507- chmod($file, 0644);
508- }
509-
510- $followers = $this->getFollowersList();
511- if (!in_array($followerActor, $followers)) file_put_contents($file, "$followerActor\n", FILE_APPEND);
512- }
513-
514- /**
515- * フォロワーのリストを配列として取得する
516- *
517- * @return array フォロワーのURLの配列
518- */
519- private function getFollowersList(): array {
520- $file = ROOT.'/data/followers.txt';
521- $f = array_filter(explode("\n", file_get_contents($file)));
522- return file_exists($file) ? array_filter(explode("\n", file_get_contents($file))) : [];
523- }
524-
525- /**
526- * アクターのインボックスURLを取得する
527- *
528- * @param string $actor アクターのURL
529- * @return string|null インボックスURL、取得に失敗した場合はnull
530- */
531- private function getInboxFromActor(string $actor): ?string {
532- $curl = new Curl($actor);
533- $curl->setHeaders(['Accept: application/activity+json'])
534- ->setFollowRedirects(true)
535- ->setMaxRedirects(5)
536- ->setCaInfo('/etc/ssl/cert.pem');
537-
538- logger(\LogType::ActivityPub, "アクターURLにリクエスト: {$actor}");
539- $success = $curl->execute();
540- if (!$success) {
541- logger(\LogType::ActivityPub, "アクターリクエストに失敗: ".$curl->getError());
542- return null;
543- }
544-
545- $res = $curl->getResponseBody();
546- $code = $curl->getResponseCode();
547- $err = $curl->getError();
548-
549- if ($code !== 200) {
550- logger(\LogType::ActivityPub, "アクター取得に失敗: HTTP {$code}, エラー: {$err}");
551- return null;
552- }
553-
554- $data = json_decode($res, true);
555- return $data['inbox'] ?? null;
556- }
557-}
削除 src/Site/Lib/Auth.php
@@ -1,451 +0,0 @@
1-<?php
2-namespace Site\Lib;
3-
4-class Auth {
5- private int $id;
6-
7- private \stdClass $user;
8- private \stdClass $pubUser;
9- private ?string $token;
10-
11- protected string $dataDir = ROOT."/data/user/";
12- protected string $assDir = ROOT."/public/static/user/";
13- protected int $minPassLen = 20;
14- protected int $tokenDuration = 31536000; // 1年間
15- protected string|int|null $algo = PASSWORD_ARGON2ID;
16-
17- public function __construct(?string $username = null) {
18- if (!AUTH_ENABLED) return;
19- $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
20- $this->token = \getcookie('kerozen');
21- $this->id = $this->getUserId($username, $this->token);
22- if ($this->id > 0) $this->user = $this->getUserData();
23- }
24-
25- public function getLoggedInUser(): \stdClass|null {
26- if ($this->id === 0) return null;
27- $this->pubUser = $this->user;
28- unset($this->pubUser->password);
29- unset($this->pubUser->tokens);
30- return $this->pubUser;
31- }
32-
33- public function getUser(string $name): \stdClass|null {
34- $user = $this->getUserDataFromName($name);
35- if (!$user) return null;
36- unset($user->password);
37- unset($user->tokens);
38- $myself = $this->getUserData();
39- if (!$myself || ($myself->id != $user->id && $myself->role < \Roles::STAFF)) $user->email = '(秘密)';
40- $user->name = namecolor($user);
41- $user->regDate = date('Y年m月d日', $user->regDate);
42- $user->gender = $user->gender === \Gender::MALE ? '男' : ($user->gender === \Gender::FEMALE ? '女' : '不明');
43-
44- switch (true) {
45- case ($user->role & \Roles::ADMIN):
46- $user->role = '管理者';
47- break;
48- case ($user->role & \Roles::STAFF):
49- $user->role = '076スタジオの会社員';
50- break;
51- case ($user->role & (\Roles::FSUBSCRIBER | \Roles::PSUBSCRIBER | \Roles::NSUBSCRIBER | \Roles::SUBSCRIBER)):
52- $user->role = '支援者♡';
53- break;
54- case ($user->role & \Roles::BANNED):
55- $user->role = 'BANされた';
56- break;
57- default:
58- $user->role = 'ユーザー';
59- break;
60- }
61-
62- $user->avatar = '/static/user/'.($user->avatar === '' ? 'noicon.png' : $user->username.'/'.$user->avatar);
63- $user->altName = $user->displayname ?: $user->username;
64- unset($user->username);
65- unset($user->displayname);
66- unset($user->namecolor);
67- return $user;
68- }
69-
70- public function setToken(string $username, #[SensitiveParameter] string $password): \Result {
71- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
72- $userData = $this->getUserData();
73- if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
74-
75- $verify = $this->verifyLogin($username, $password);
76- if (!$verify->isSuccess) {
77- return \Result::error($verify->message);
78- }
79-
80- $ip = $_SERVER['REMOTE_ADDR'];
81- if (!empty($_SERVER['HTTP_X_FORMARDED_FOR'])) {
82- $ipList = explode(',', $_SERVER['HTTP_X_FORMARDED_FOR']);
83- $ip = trim($ipList[0]);
84- }
85-
86- $userData = $this->purgeOldTokens($userData);
87- $token = bin2hex(random_bytes(64));
88- $expire = time() + $this->tokenDuration;
89- $newToken = [
90- 'token' => $token,
91- 'ip' => $ip,
92- 'createDate' => time(),
93- 'expDate' => $expire,
94- 'lastDate' => time(),
95- 'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '不明'
96- ];
97-
98- $userData->tokens[] = $newToken;
99-
100- $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
101- $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
102-
103- if (file_put_contents($path, $json) === false) {
104- logger(\LogType::Auth, '【setToken】JSONファイルにユーザーデータを保存出来なかった。パス:'.$path);
105- return \Result::error('エラー:ユーザーデータの保存に失敗。');
106- }
107-
108- if (!setcookie('kerozen', $token, [
109- 'expires' => $expire,
110- 'path' => '/',
111- 'domain' => $_SERVER['SERVER_NAME'],
112- 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
113- 'httponly' => true,
114- 'samesite' => 'Strict'
115- ])) {
116- logger(\LogType::Auth, '【setToken】クッキーを設定出来なかった。トークン:'.$token);
117- return \Result::error('エラー:クッキーを設定に失敗。');
118- }
119-
120- return \Result::success('ログイン成功');
121- }
122-
123- public function getToken(): string {
124- if (!AUTH_ENABLED) return '';
125- $userData = $this->getUserData();
126- if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
127- $userData = $this->purgeOldTokens($userData);
128- }
129-
130- public function logout(?string $token = null): \Result {
131- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
132- $userData = $this->getUserData();
133-
134- if (!$token) {
135- $userData->tokens = array_filter($userData->tokens, function($t): bool {
136- return ($t->expires ?? 0) > time();
137- });
138- }
139-
140- setcookie('kerozen', null, [
141- 'expires' => 0,
142- 'path' => '/',
143- 'domain' => $_SERVER['SERVER_NAME'],
144- 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
145- 'httponly' => true,
146- 'samesite' => 'Strict'
147- ]);
148-
149- return \Result::success();
150- }
151-
152- public function isUserExist(?string $username = null): \Result {
153- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
154- if (null === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。');
155- $userList = scandir($this->dataDir);
156-
157- foreach ($userList as $list) {
158- if ($list === '.kara' || $list === '.' || $list === '..') continue;
159- $file = str_replace('.json', '', $list);
160- $user = explode('.', $file)[1];
161- if ($username === $user) return \Result::Success("ユーザー「{$username}」は既に存在します。");
162- }
163-
164- return \Result::Error('');
165- }
166-
167- public function mkUser(?string $username = null, #[SensitiveParameter] ?string $password = null, ?string $passwordVerify = null, ?string $email = null): \Result {
168- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
169- if (!AUTH_REGISTER_ENABLED) return \Result::Error('ユーザー登録は無効です。');
170- $resUsr = $this->verifyUsername($username);
171- $resPwd = $this->verifyPassword($password, $passwordVerify);
172- $resEml = $this->verifyEmail($email);
173-
174- $err = '';
175-
176- if (!$resUsr->isSuccess) $err .= $resUsr->message."<br />";
177- if (!$resPwd->isSuccess) $err .= $resPwd->message."<br />";
178- if (!$resEml->isSuccess) $err .= $resEml->message."<br />";
179-
180- if ($err != '') return \Result::Error($err);
181- $err = '';
182-
183- if (!mkdir($this->assDir.$username, 0755)) {
184- logger(\LogType::Auth, '【mkUser】アセットディレクトリを作成出来なかった。chownをご確認下さい。パス:'.$this->assDir.$username);
185- return \Result::Error('エラー:ユーザーのアイコンディレクトリの作成に失敗。');
186- }
187-
188- $file = scandir($this->dataDir);
189- $lastId = 0;
190- if ($file) {
191- foreach ($file as $f) {
192- if ($f === '.kara' || $f === '.' || $f === '..') continue;
193- $base = substr($f, 0, -5); // .jsonの削除
194- $dot = strpos($base, '.');
195- if (!$dot) continue;
196- $id = substr($base, 0, $dot);
197- if (ctype_digit($id)) {
198- if ((int)$id > $lastId) $lastId = $id;
199- }
200- }
201- $lastId++;
202- }
203-
204- $user = new \stdClass;
205- $user->id = $lastId;
206- $user->username = $username;
207- $user->password = password_hash($password, $this->algo);
208- $user->avatar = '';
209- $user->email = $email;
210- $user->regDate = time();
211- $user->namecolor = '';
212- $user->displayname = '';
213- $user->gender = \Gender::UNKNOWN;
214- $user->role = \Roles::MEMBER;
215- $user->tokens = [];
216-
217- $path = "{$this->dataDir}{$lastId}.{$username}.json";
218- $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
219-
220- if (file_put_contents($path, $json) === false) {
221- logger(\LogType::Auth, '【mkUser】JSONファイルを作成出来なかった。パス:'.$path);
222- rmdir($this->assDir.$username);
223- return \Result::Error('エラー:ユーザーデータの保存に失敗。');
224- }
225-
226- return \Result::Success();
227- }
228-
229- ////////////////////
230-
231- private function verifyLogin(string $username, #[SensitiveParameter] string $password): \Result {
232- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
233- $userData = $this->getUserData();
234- if ($username !== $userData->username || !password_verify($password, $userData->password)) {
235- return \Result::Error('エラー:ユーザー名又はパスワードが一致していません。');
236- }
237-
238- if (password_needs_rehash($userData->password, $this->algo)) {
239- $userData->password = password_hash($password, $this->algo);
240- $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
241- $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
242-
243- if (file_put_contents($path, $json) === false) {
244- logger(\LogType::Auth, '【verifyLogin】JSONファイルを変更出来なかった。chownをご確認下さい。パス:'.$path);
245- return \Result::Error('エラー:ユーザーデータの保存に失敗。');
246- }
247- }
248-
249- return \Result::Success();
250- }
251-
252- private function purgeOldTokens(\stdClass $userData): \stdClass|\Result {
253- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
254- // 有効期限切れたトークンの削除
255- $out = $userData;
256- $out->tokens = array_filter($userData->tokens, function($t): bool {
257- return ($t->expDate ?? 0) > time();
258- });
259-
260- $path = $this->dataDir.$userData->id.'.'.$userData->username.'.json';
261- $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
262-
263- if (file_put_contents($path, $json) === false) {
264- logger(\LogType::Auth, '【purgeOldTokens】JSONファイルを変更出来なかった。chownをご確認下さい。パス:'.$path);
265- return \Result::Error('エラー:ユーザーデータの保存に失敗。');
266- }
267-
268- return $out;
269- }
270-
271- private function getUserId(?string $username = null, ?string $token = null): int {
272- if (!AUTH_ENABLED) return 0;
273- if (!$username && !$token) return 0;
274- $file = scandir($this->dataDir);
275- $matches = [];
276- $id = 0;
277-
278- foreach ($file as $f) {
279- if ($f === '.kara' || $f === '.' || $f === '..') continue;
280-
281- $path = "{$this->dataDir}{$f}";
282- if (!is_file($path)) continue;
283- $content = file_get_contents($path);
284- if ($token && str_contains($content, $token)) {
285- $matches[] = $path;
286- break;
287- } else if (!$token && str_contains($content, '"username": "'.$username.'",')) {
288- $matches[] = $path;
289- break;
290- }
291- }
292- if (!isset($matches[0])) return 0;
293- $id = (int)preg_replace('/^(.*?)\.(.*?)$/', "$1", str_replace($this->dataDir, '', $matches[0]));
294-
295- return $id;
296- }
297-
298- private function getUserData(): ?\stdClass {
299- if (!AUTH_ENABLED) return new \stdClass;
300- $file = scandir($this->dataDir);
301- $userFile = "";
302- $matches = [];
303-
304- foreach ($file as $f) {
305- if ($f === '.kara' || $f === '.' || $f === '..') continue;
306- if (preg_match('/^'.$this->id.'\.(.*?)\.json$/', $f, $matches)) {
307- $userFile = $matches[0];
308- break;
309- }
310- }
311- $path = $this->dataDir.$userFile;
312- unset($file, $userFile);
313-
314- if (!file_exists($path) || !is_readable($path)) {
315- kys('エラー');
316- }
317-
318- $fp = fopen($path, 'r');
319- assert_not_null($fp);
320- if (!$fp) kys('ユーザーが存在しない。');
321- $lines = "";
322- while (($buf = fgets($fp, 4096)) !== false) $lines .= $buf.PHP_EOL;
323- if (!feof($fp)) kys("エラー:不明なエラー");
324- fclose($fp);
325- unset($path);
326- return json_decode($lines);
327- }
328-
329- private function getUserDataFromName(string $name): \stdClass|null {
330- if (!AUTH_ENABLED) return new \stdClass;
331- $file = scandir($this->dataDir);
332- $userFile = "";
333- $matches = [];
334-
335- foreach ($file as $f) {
336- if ($f === '.kara' || $f === '.' || $f === '..') continue;
337- if (preg_match('/^(.*?)\.(.*?)\.json$/', $f, $matches)) {
338- if (isset($matches[2]) && $matches[2] === $name) {
339- $userFile = $matches[0];
340- break;
341- }
342- }
343- }
344- if (!$userFile) return null;
345- $path = $this->dataDir.$userFile;
346- unset($file, $userFile);
347-
348- if (!file_exists($path) || !is_readable($path)) {
349- kys('エラー');
350- }
351-
352- $fp = fopen($path, 'r');
353- assert_not_null($fp);
354- if (!$fp) kys('ユーザーが存在しない。');
355- $lines = "";
356- while (($buf = fgets($fp, 4096)) !== false) $lines .= $buf.PHP_EOL;
357- if (!feof($fp)) kys("エラー:不明なエラー");
358- fclose($fp);
359- unset($path);
360- return json_decode($lines);
361- }
362-
363- private function isEmailExist(?string $email = null): \Result {
364- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
365- $userList = scandir($this->dataDir);
366- $matches = [];
367-
368- foreach ($userList as $f) {
369- if ($f === '.kara' || $f === '.' || $f === '..') continue;
370- $path = "{$this->dataDir}{$f}";
371- if (!is_file($path)) continue;
372- $content = file_get_contents($path);
373- $file = str_replace('.json', '', $f);
374- if (str_contains($content, '"email": "'.$email.'",')) {
375- $matches[] = $email;
376- }
377- if (count($matches) > 0) return \Result::Error("ユーザー「{$email}」は既に存在します。");
378- }
379-
380- return \Result::Success('エラー:メールアドレスが存在しません。');
381- }
382-
383- private function verifyEmail(?string $email = null): \Result {
384- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
385- if (null === $email || '' === $email) return \Result::Error('エラー:メールアドレスをご入力下さい。');
386- if (strpos($email, '@') === false) return \Result::Error('エラー:メールアドレスは不正です。');
387-
388- $domain = explode('@', $email)[1];
389- if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
390- return \Result::Error('エラー:メールアドレスは不正です。');
391- }
392-
393- if (!checkdnsrr($domain, 'MX')) {
394- return \Result::Error('エラー:メールアドレスは不正です。');
395- }
396-
397- $res = $this->isEmailExist($email);
398- if (!$res->isSuccess) return \Result::Error($res->message);
399-
400- return \Result::Success();
401- }
402-
403- private function verifyUsername(?string $username): \Result {
404- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
405- if (null === $username || '' === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。');
406- if (strlen($username) < 6) return \Result::Error('エラー:ユーザー名は6文字以上をご入力下さい。');
407-
408- $res = $this->isUserExist($username);
409- if ($res->isSuccess) return \Result::Error($res->message);
410-
411- $accepted = 'A-Za-z0-9';
412- if (preg_match("/[^{$accepted}]/", $username)) {
413- return \Result::Error('エラー:ユーザー名に不正な文字が含みます。');
414- }
415-
416- return \Result::Success();
417- }
418-
419- private function verifyPassword(#[SensitiveParameter] ?string $password = null, #[SensitiveParameter] ?string $passwordVerify = null): \Result {
420- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
421- if (null === $password || '' === $password) return \Result::Error('エラー:パスワードをご入力下さい。');
422- if ($password !== $passwordVerify) return \Result::Error('エラー:パスワードは一致していません。');
423-
424- $res = $this->checkPasswordStandards($password);
425- if (!$res->isSuccess) {
426- return \Result::Error($res->message);
427- }
428-
429- return \Result::Success();
430- }
431-
432- private function checkPasswordStandards(#[SensitiveParameter] ?string $password = null): \Result {
433- if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
434- if (strlen($password) < $this->minPassLen) return \Result::Error("エラー:パスワードは{$this->minPassLen}以上をご入力下さい。");
435-
436- if (!\countmatch($password)) {
437- return \Result::Error('エラー:パスワードに不正な文字が含みます。');
438- }
439-
440- $countUpper = preg_match_all('/[A-Z]/', $password) < 2;
441- $countLower = preg_match_all('/[a-z]/', $password) < 2;
442- $countDigit = preg_match_all('/[0-9]/', $password) < 2;
443- $countSymbol = \count_special_chars($password) < 2;
444-
445- if ($countUpper || $countLower || $countDigit || $countSymbol) {
446- return \Result::Error('エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。');
447- }
448-
449- return \Result::Success();
450- }
451-};
削除 src/Site/Lib/Curl.php
@@ -1,693 +0,0 @@
1-<?php
2-namespace Site\Lib;
3-
4-/**
5- * php_curlへの依存を排除するための独自のCURL実装
6- */
7-class Curl {
8- // リクエスト関連のプロパティ
9- private string $url = '';
10- private string $method = 'GET';
11- private int $timeout = 30;
12- private array $headers = [];
13- private array $cookies = [];
14- private array $postFields = [];
15- private string $postRaw = '';
16- private string $userAgent = 'LittleBeast/1.0';
17- private bool $followRedirects = true;
18- private int $maxRedirects = 5;
19- private bool $verbose = false;
20- private $stderr = null;
21- private string $caInfoPath = '';
22- private bool $verifySSL = true;
23- private string $username = '';
24- private string $password = '';
25- private string $referer = '';
26-
27- // レスポンス関連のプロパティ
28- private array $responseHeaders = [];
29- private string $responseBody = '';
30- private int $responseCode = 0;
31- private string $responseError = '';
32- private array $info = [];
33-
34- /**
35- * コンストラクタ
36- *
37- * @param string|null $url リクエスト先のURL
38- */
39- public function __construct(?string $url = null) {
40- if (!CURL_ENABLED) return;
41- if ($url !== null) {
42- $this->url = $url;
43- }
44- }
45-
46- /**
47- * リクエスト先のURLを設定する
48- *
49- * @param string $url リクエスト先のURL
50- * @return Curl このインスタンス(メソッドチェーン用)
51- */
52- public function setUrl(string $url): Curl {
53- $this->url = $url;
54- return $this;
55- }
56-
57- /**
58- * リクエストメソッドを設定する
59- *
60- * @param string $method GE又はPOST等のHTTPメソッド
61- * @return Curl このインスタンス(メソッドチェーン用)
62- */
63- public function setMethod(string $method): Curl {
64- $this->method = strtoupper($method);
65- return $this;
66- }
67-
68- /**
69- * リクエストのタイムアウト秒数を設定する
70- *
71- * @param int $seconds タイムアウト秒数
72- * @return Curl このインスタンス(メソッドチェーン用)
73- */
74- public function setTimeout(int $seconds): Curl {
75- $this->timeout = (int)$seconds;
76- return $this;
77- }
78-
79- /**
80- * リクエストヘッダーを設定する
81- *
82- * @param array $headers リクエストヘッダーの配列
83- * @return Curl このインスタンス(メソッドチェーン用)
84- */
85- public function setHeaders(array $headers): Curl {
86- $this->headers = $headers;
87- return $this;
88- }
89-
90- /**
91- * 単一のヘッダーを追加する
92- *
93- * @param string $name ヘッダー名
94- * @param mixed $value ヘッダー値
95- * @return Curl このインスタンス(メソッドチェーン用)
96- */
97- public function addHeader(string $name, mixed $value): Curl {
98- $this->headers[$name] = $value;
99- return $this;
100- }
101-
102- /**
103- * リクエストのクッキーを設定する
104- *
105- * @param array $cookies クッキーの配列
106- * @return Curl このインスタンス(メソッドチェーン用)
107- */
108- public function setCookies(array $cookies): Curl {
109- $this->cookies = $cookies;
110- return $this;
111- }
112-
113- /**
114- * 単一のクッキーを追加する
115- *
116- * @param string $name クッキー名
117- * @param mixed $value クッキー値
118- * @return Curl このインスタンス(メソッドチェーン用)
119- */
120- public function addCookie(string $name, mixed $value): Curl {
121- $this->cookies[$name] = $value;
122- return $this;
123- }
124-
125- /**
126- * POSTフィールドを設定する
127- *
128- * @param array $fields POSTデータの配列
129- * @return Curl このインスタンス(メソッドチェーン用)
130- */
131- public function setPostFields(array $fields): Curl {
132- $this->postFields = $fields;
133- return $this;
134- }
135-
136- /**
137- * 生のPOSTデータを設定する
138- *
139- * @param string $data 生のPOSTデータ
140- * @return Curl このインスタンス(メソッドチェーン用)
141- */
142- public function setPostRaw(string $data): Curl {
143- $this->postRaw = $data;
144- return $this;
145- }
146-
147- /**
148- * ユーザーエージェントを設定する
149- *
150- * @param string $userAgent カスタムユーザーエージェント
151- * @return Curl このインスタンス(メソッドチェーン用)
152- */
153- public function setUserAgent(string $userAgent): Curl {
154- $this->userAgent = $userAgent;
155- return $this;
156- }
157-
158- /**
159- * リダイレクトを追跡するかどうかを設定する
160- *
161- * @param bool $follow 追跡するかどうか(デフォルトはtrue)
162- * @return Curl このインスタンス(メソッドチェーン用)
163- */
164- public function setFollowRedirects(bool $follow): Curl {
165- $this->followRedirects = (bool)$follow;
166- return $this;
167- }
168-
169- /**
170- * 追跡するリダイレクトの最大数を設定する
171- *
172- * @param int $max リダイレクトの最大数
173- * @return Curl このインスタンス(メソッドチェーン用)
174- */
175- public function setMaxRedirects(int $max): Curl {
176- $this->maxRedirects = (int)$max;
177- return $this;
178- }
179-
180- /**
181- * SSL証明書を検証するかどうかを設定する
182- *
183- * @param bool $verify SSL検証を行うかどうか(デフォルトはtrue)
184- * @return Curl このインスタンス(メソッドチェーン用)
185- */
186- public function setVerifySSL(bool $verify): Curl {
187- $this->verifySSL = (bool)$verify;
188- return $this;
189- }
190-
191- /**
192- * 基本認証の資格情報を設定する
193- *
194- * @param string $username ユーザー名
195- * @param string $password パスワード
196- * @return Curl このインスタンス(メソッドチェーン用)
197- */
198- public function setBasicAuth(string $username, string $password): Curl {
199- $this->username = $username;
200- $this->password = $password;
201- return $this;
202- }
203-
204- /**
205- * リファラーURLを設定する
206- *
207- * @param string $referer リファラーURL
208- * @return Curl このインスタンス(メソッドチェーン用)
209- */
210- public function setReferer(string $referer): Curl {
211- $this->referer = $referer;
212- return $this;
213- }
214-
215- /**
216- * 詳細ログを有効にする
217- *
218- * @param bool $verbose 詳細ログを有効にするかどうか
219- * @return Curl このインスタンス(メソッドチェーン用)
220- */
221- public function setVerbose(bool $verbose): Curl {
222- $this->verbose = (bool)$verbose;
223- return $this;
224- }
225-
226- /**
227- * エラー出力先を設定する
228- *
229- * @param resource $handle エラー出力先のファイルハンドル
230- * @return Curl このインスタンス(メソッドチェーン用)
231- */
232- public function setStderr($handle): Curl {
233- $this->stderr = $handle;
234- return $this;
235- }
236-
237- /**
238- * SSL証明書のファイルパスを設定する
239- *
240- * @param string $path 証明書ファイルのパス
241- * @return Curl このインスタンス(メソッドチェーン用)
242- */
243- public function setCaInfo(string $path): Curl {
244- $this->caInfoPath = $path;
245- return $this;
246- }
247-
248- /**
249- * リクエストを実行する
250- *
251- * @return Result 成功または失敗
252- */
253- public function execute(): \Result {
254- if (!CURL_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
255- if (empty($this->url)) {
256- $this->responseError = 'URLがありません';
257- return \Result::Error($this->responseError);
258- }
259-
260- // レスポンスデータのリセット
261- $this->responseHeaders = [];
262- $this->responseBody = '';
263- $this->responseCode = 0;
264- $this->responseError = '';
265- $this->info = [
266- 'url' => $this->url,
267- 'content_type' => '',
268- 'http_code' => 0,
269- 'header_size' => 0,
270- 'request_size' => 0,
271- 'total_time' => 0,
272- 'redirect_count' => 0,
273- 'redirect_url' => '',
274- ];
275-
276- $startTime = microtime(true);
277-
278- // ソケットベースの実装を使用する
279- $redirectCount = 0;
280- $currentUrl = $this->url;
281- $originalMethod = $this->method;
282-
283- do {
284- if ($this->verbose && $this->stderr) {
285- fwrite($this->stderr, "* 接続中: {$currentUrl}\n");
286- }
287-
288- $parsed = parse_url($currentUrl);
289- if (!$parsed) {
290- $this->responseError = "無効なURL: {$currentUrl}";
291- return \Result::Error($this->responseError);
292- }
293-
294- $scheme = isset($parsed['scheme']) ? strtolower($parsed['scheme']) : 'http';
295- $host = $parsed['host'];
296- $port = isset($parsed['port'])
297- ? $parsed['port'] : ($scheme === 'https' ? 443 : 80);
298- $path = isset($parsed['path']) ? $parsed['path'] : '/';
299- if (isset($parsed['query'])) {
300- $path .= '?'.$parsed['query'];
301- }
302-
303- // Basic認証
304- $authHeader = '';
305- if (!empty($this->username) && !empty($this->password)) {
306- $authHeader = "Authorization: Basic "
307- .base64_encode($this->username.':'.$this->password)."\r\n";
308- } elseif (isset($parsed['user']) && isset($parsed['pass'])) {
309- $authHeader = "Authorization: Basic "
310- .base64_encode($parsed['user'].':'.$parsed['pass'])."\r\n";
311- }
312-
313- // 送信するHTTPリクエストを構築
314- $method = $this->method;
315- $httpData = '';
316-
317- if ($method === 'POST' || $method === 'PUT') {
318- if (!empty($this->postRaw)) {
319- $httpData = $this->postRaw;
320- } elseif (!empty($this->postFields)) {
321- $httpData = http_build_query($this->postFields);
322- if (!isset($this->headers['Content-Type'])) {
323- $this->headers['Content-Type'] = 'application/x-www-form-urlencoded';
324- }
325- }
326- $this->headers['Content-Length'] = strlen($httpData);
327- }
328-
329- // HTTPリクエストを構築
330- $accept = 'Accept: */*';
331- foreach ($this->headers as $h) {
332- if (str_contains($h, 'Accept:')) $accept = $h;
333- }
334-
335- $request = "{$method} {$path} HTTP/1.1\r\n";
336- $request .= "Host: {$host}\r\n";
337- $request .= "User-Agent: {$this->userAgent}\r\n";
338- $request .= "{$accept}\r\n";
339- $request .= "Connection: close\r\n";
340-
341- if (!empty($authHeader)) {
342- $request .= $authHeader;
343- }
344-
345- // ヘッダーを追加
346- foreach ($this->headers as $name => $value) {
347- $request .= "{$name}: {$value}\r\n";
348- }
349-
350- // リファラーが設定されていれば追加
351- if (!empty($this->referer) && !isset($this->headers['Referer'])) {
352- $request .= "Referer: {$this->referer}\r\n";
353- }
354-
355- // クッキーヘッダーを追加
356- if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
357- $cookieStrings = [];
358- foreach ($this->cookies as $name => $value) {
359- $cookieStrings[] = $name.'='.urlencode($value);
360- }
361- $request .= 'Cookie: '.implode('; ', $cookieStrings)."\r\n";
362- }
363-
364- $request .= "\r\n";
365-
366- // POSTデータを追加
367- if ($method === 'POST' || $method === 'PUT') {
368- $request .= $httpData;
369- }
370-
371- if ($this->verbose && $this->stderr) {
372- fwrite($this->stderr, "* リクエストヘッダー:\n{$request}\n");
373- }
374-
375- // ソケット接続を確立
376- $errno = 0;
377- $errstr = '';
378-
379- if ($scheme === 'https') {
380- $sslOptions = [
381- 'verify_peer' => $this->verifySSL,
382- 'verify_peer_name' => $this->verifySSL
383- ];
384-
385- if (!empty($this->caInfoPath) && file_exists($this->caInfoPath)) {
386- $sslOptions['cafile'] = $this->caInfoPath;
387- }
388-
389- $context = stream_context_create(['ssl' => $sslOptions]);
390-
391- $socket = @stream_socket_client(
392- "tls://{$host}:{$port}",
393- $errno,
394- $errstr,
395- $this->timeout,
396- STREAM_CLIENT_CONNECT,
397- $context
398- );
399- } else {
400- $socket = @fsockopen($host, $port, $errno, $errstr, $this->timeout);
401- }
402-
403- if (!$socket) {
404- $this->responseError = "接続出来ません: {$errstr} ({$errno})";
405- if ($this->verbose && $this->stderr) {
406- fwrite($this->stderr, "* エラー: {$this->responseError}\n");
407- }
408- return \Result::Error($this->responseError);
409- }
410-
411- // タイムアウトを設定
412- stream_set_timeout($socket, $this->timeout);
413-
414- // リクエストを送信
415- fwrite($socket, $request);
416-
417- // レスポンスを読み込む
418- $rawResponse = '';
419- $headers = '';
420- $body = '';
421- $headersComplete = false;
422-
423- // ヘッダーとボディを分けて読み込む
424- while (!feof($socket)) {
425- $line = fgets($socket);
426- if ($line === false) {
427- break;
428- }
429-
430- $rawResponse .= $line;
431-
432- if (!$headersComplete) {
433- if (trim($line) === '') {
434- $headersComplete = true;
435- } else {
436- $headers .= $line;
437- }
438- } else {
439- $body .= $line;
440- }
441- }
442-
443- fclose($socket);
444-
445- // レスポンスヘッダーを解析
446- $headerLines = explode("\r\n", $headers);
447-
448- // ステータスコードを取得
449- $statusLine = isset($headerLines[0]) ? $headerLines[0] : '';
450- $statusParts = explode(' ', $statusLine, 3);
451- $this->responseCode = isset($statusParts[1]) ? (int)$statusParts[1] : 0;
452- $this->info['http_code'] = $this->responseCode;
453-
454- // ヘッダーを解析
455- $this->responseHeaders = [];
456- $redirectUrl = '';
457-
458- foreach ($headerLines as $index => $header) {
459- if ($index === 0) continue;
460-
461- if (strpos($header, ':') !== false) {
462- list($name, $value) = explode(':', $header, 2);
463- $name = trim($name);
464- $value = trim($value);
465- $this->responseHeaders[$name] = $value;
466-
467- if (strtolower($name) === 'content-type') {
468- $this->info['content_type'] = $value;
469- }
470-
471- // リダイレクトをチェック
472- if ($this->followRedirects &&
473- strtolower($name) === 'location' &&
474- $this->responseCode >= 300 &&
475- $this->responseCode < 400) {
476-
477- $redirectUrl = $value;
478-
479- // 相対URLを絶対URLに変換
480- if (strpos($redirectUrl, 'http') !== 0) {
481- if ($redirectUrl[0] === '/') {
482- $redirectUrl = "{$scheme}://{$host}"
483- .($port != 80 && $port != 443 ? ":{$port}" : '').$redirectUrl;
484- } else {
485- $redirectUrl = "{$scheme}://{$host}"
486- .($port != 80 && $port != 443 ? ":{$port}" : '')
487- .dirname($path).'/'.$redirectUrl;
488- }
489- }
490-
491- $this->info['redirect_url'] = $redirectUrl;
492- }
493- }
494- }
495-
496- $this->info['header_size'] += strlen($headers);
497- $this->responseBody .= $body;
498-
499- if ($this->verbose && $this->stderr) {
500- fwrite($this->stderr, "* レスポンスコード: {$this->responseCode}\n");
501- fwrite($this->stderr, "* レスポンスヘッダー:\n{$headers}\n");
502- if (!empty($redirectUrl)) {
503- fwrite($this->stderr, "* リダイレクト先: {$redirectUrl}\n");
504- }
505- }
506-
507- // リダイレクトが必要な場合
508- if (!empty($redirectUrl) && $redirectCount < $this->maxRedirects) {
509- $currentUrl = $redirectUrl;
510- $redirectCount++;
511- $this->info['redirect_count'] = $redirectCount;
512-
513- // 302や303リダイレクトはGETにメソッドを変更
514- if ($this->responseCode == 302 || $this->responseCode == 303) {
515- $this->method = 'GET';
516- $this->postRaw = '';
517- $this->postFields = [];
518- }
519- } else {
520- break;
521- }
522- } while (true);
523-
524- // リクエスト完了後、元のメソッドに戻す
525- $this->method = $originalMethod;
526- $this->info['total_time'] = microtime(true) - $startTime;
527-
528- return \Result::Success();
529- }
530-
531- /**
532- * レスポンスボディを取得する
533- *
534- * @return string レスポンスボディ
535- */
536- public function getResponseBody(): string {
537- return $this->responseBody;
538- }
539-
540- /**
541- * レスポンスヘッダーを取得する
542- *
543- * @return array レスポンスヘッダーの配列
544- */
545- public function getResponseHeaders(): array {
546- return $this->responseHeaders;
547- }
548-
549- /**
550- * HTTPレスポンスコードを取得する
551- *
552- * @return int HTTPレスポンスコード
553- */
554- public function getResponseCode(): int {
555- return $this->responseCode;
556- }
557-
558- /**
559- * エラーメッセージがあれば取得する
560- *
561- * @return string エラーメッセージ
562- */
563- public function getError(): string {
564- return $this->responseError;
565- }
566-
567- /**
568- * リクエスト/レスポンス情報を取得する
569- *
570- * @return array 情報の配列
571- */
572- public function getInfo(): array {
573- return $this->info;
574- }
575-
576- // 機能性メソッド
577-
578- /**
579- * リダイレクトURLを確認する
580- *
581- * @param string $name ヘッダー名
582- * @param string $value ヘッダー値
583- * @param string $currentUrl 現在のURL
584- * @return string リダイレクトURL、リダイレクトがない場合は空文字
585- */
586- private function checkReds(string $name, string $value, string $currentUrl): string {
587- $redirectUrl = '';
588-
589- if ($this->followRedirects && (strtolower($name) === 'location'
590- && $this->responseCode >= 300 && $this->responseCode < 400)) {
591- $redirectUrl = $value;
592-
593- if (strpos($redirectUrl, 'http') !== 0) {
594- if ($redirectUrl[0] === '/') {
595- $parsed = parse_url($currentUrl);
596- $redirectUrl = $parsed['scheme'].'://'.$parsed['host']
597- .(isset($parsed['port']) ? ':'.$parsed['port'] : '')
598- .$redirectUrl;
599- } else {
600- $redirectUrl = dirname($currentUrl).'/'.$redirectUrl;
601- }
602- }
603- }
604-
605- return $redirectUrl;
606- }
607-
608- /**
609- * ヘッダー文字列を構築する
610- *
611- * @return string 構築されたヘッダー文字列
612- */
613- private function buildHeaderString(): string {
614- $headers = [];
615-
616- // ユーザー指定のヘッダーを追加
617- foreach ($this->headers as $name => $value) {
618- $headers[] = "{$name}: {$value}";
619- }
620-
621- // リファラーが設定されていれば追加
622- if (!empty($this->referer) && !isset($this->headers['Referer'])) {
623- $headers[] = "Referer: {$this->referer}";
624- }
625-
626- // 必要に応じてクッキーヘッダーを追加
627- if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
628- $cookieStrings = [];
629- foreach ($this->cookies as $name => $value) {
630- $cookieStrings[] = $name.'='.urlencode($value);
631- }
632-
633- $headers[] = 'Cookie: '.implode('; ', $cookieStrings);
634- }
635-
636- return implode("\r\n", $headers)."\r\n";
637- }
638-
639- /**
640- * レスポンスを解析してヘッダーとボディに分割する
641- *
642- * @param string $response 完全なHTTPレスポンス
643- * @return array [ヘッダー配列, ボディ文字列]
644- */
645- private function parseResponse(string $response): array {
646- $parts = explode("\r\n\r\n", $response, 2);
647-
648- if (count($parts) < 2) {
649- return [[], ''];
650- }
651-
652- $headers = explode("\r\n", $parts[0]);
653- $body = $parts[1];
654-
655- // チャンク転送エンコーディングを処理
656- if (isset($this->responseHeaders['Transfer-Encoding']) &&
657- strtolower($this->responseHeaders['Transfer-Encoding']) === 'chunked') {
658- $body = $this->decodeChunkedBody($body);
659- }
660-
661- return [$headers, $body];
662- }
663-
664- /**
665- * チャンク転送エンコーディングされたボディをデコードする
666- *
667- * @param string $body チャンクエンコードされたボディ
668- * @return string デコードされたボディ
669- */
670- private function decodeChunkedBody(string $body): string {
671- $decodedBody = '';
672- $position = 0;
673-
674- while ($position < strlen($body)) {
675- $lineEnd = strpos($body, "\r\n", $position);
676- if ($lineEnd === false) {
677- break;
678- }
679-
680- $chunkSize = hexdec(substr($body, $position, $lineEnd - $position));
681-
682- if ($chunkSize === 0) {
683- break;
684- }
685-
686- $position = $lineEnd + 2;
687- $decodedBody .= substr($body, $position, $chunkSize);
688- $position += $chunkSize + 2; // チャンクサイズ + CRLF
689- }
690-
691- return $decodedBody;
692- }
693-}
削除 src/Site/Lib/DiffViewer.php
@@ -1,130 +0,0 @@
1-<?php
2-namespace Site\Lib;
3-
4-class DiffViewer {
5- private $diffContent;
6-
7- public function __construct(string $filePath) {
8- if (!file_exists($filePath)) {
9- throw new \Exception("Diff file not found: $filePath");
10- }
11- $this->diffContent = file_get_contents($filePath);
12- }
13-
14- public function displaySideBySide(): string {
15- $lines = explode("\n", $this->diffContent);
16- $fileDiffs = [];
17- $currentFile = null;
18- $hunk = [];
19- $lineNumbers = ['left' => 0, 'right' => 0];
20- $currentLeftLines = [];
21- $currentRightLines = [];
22-
23- foreach ($lines as $line) {
24- // ファイルヘッダーの処理
25- if (preg_match('/^---\s+(.+)/', $line, $matches)) {
26- // ファイルを処理する場合、データを保存する
27- if ($currentFile !== null) {
28- $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
29- $fileDiffs[$currentFile] = [
30- 'leftLines' => $currentLeftLines,
31- 'rightLines' => $currentRightLines
32- ];
33- $hunk = [];
34- $currentLeftLines = [];
35- $currentRightLines = [];
36- $lineNumbers = ['left' => 0, 'right' => 0];
37- }
38- $currentFile = $matches[1];
39- continue;
40- }
41- if (preg_match('/^\+\+\+\s+(.+)/', $line)) {
42- continue;
43- }
44-
45- // ハンクヘッダーの処理 (例:@@ -10,6 +10,7 @@)
46- if (preg_match('/^@@\s+-(\d+),\d+\s+\+(\d+),\d+\s+@@/', $line, $matches)) {
47- $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
48- $hunk = [];
49- $lineNumbers['left'] = (int)$matches[1];
50- $lineNumbers['right'] = (int)$matches[2];
51- continue;
52- }
53-
54- // ハンクでの行列の集まり
55- if (substr($line, 0, 1) === '-' || substr($line, 0, 1) === '+' || substr($line, 0, 1) === ' ') {
56- $hunk[] = $line;
57- }
58- }
59-
60- // 最後のハンク・ファイルの処理
61- if ($currentFile !== null) {
62- $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
63- $fileDiffs[$currentFile] = [
64- 'leftLines' => $currentLeftLines,
65- 'rightLines' => $currentRightLines
66- ];
67- }
68-
69- // 各ファイルにHTMLの出力の作成
70- $html = '';
71- foreach ($fileDiffs as $fileName => $diff) {
72- $html .= "\n <h2>ファイル: ".htmlspecialchars($fileName)."</h2>\n";
73- $html .= $this->generateHtml($diff['leftLines'], $diff['rightLines']);
74- }
75-
76- return $html;
77- }
78-
79- private function processHunk(array $hunk, array &$leftLines, array &$rightLines, array &$lineNumbers): void {
80- foreach ($hunk as $line) {
81- $prefix = substr($line, 0, 1);
82- $content = substr($line, 1);
83-
84- if ($prefix === '-') {
85- $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'removed', 'line' => $lineNumbers['left']];
86- $lineNumbers['left']++;
87- } elseif ($prefix === '+') {
88- $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'added', 'line' => $lineNumbers['right']];
89- $lineNumbers['right']++;
90- } elseif ($prefix === ' ') {
91- // 両側のコンテキストは同じ行列があるかの確認
92- while ($lineNumbers['left'] < $lineNumbers['right']) {
93- $leftLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['left']];
94- $lineNumbers['left']++;
95- }
96- while ($lineNumbers['right'] < $lineNumbers['left']) {
97- $rightLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['right']];
98- $lineNumbers['right']++;
99- }
100- $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['left']];
101- $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['right']];
102- $lineNumbers['left']++;
103- $lineNumbers['right']++;
104- }
105- }
106- }
107-
108- private function generateHtml(array $leftLines, array $rightLines): string {
109- $html = " <p>\n <table class=\"diff-table\">\n";
110- $html .= " <tr class=\"diff-header\"><th colspan=\"2\">前</th><th colspan=\"2\">新</th></tr>\n";
111-
112- $maxLines = max(count($leftLines), count($rightLines));
113- for ($i = 0; $i < $maxLines; $i++) {
114- $left = isset($leftLines[$i]) ? $leftLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
115- $right = isset($rightLines[$i]) ? $rightLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
116-
117- $html .= " <tr>\n";
118- // 左(変更前)
119- $html .= " <td class=\"line-number\">".($left['line'] ?: '&nbsp;')."</td>\n";
120- $html .= " <td class=\"{$left['type']}\">".($left['content'] ?: '&nbsp;')."</td>\n";
121- // 右(変更後)
122- $html .= " <td class=\"line-number\">".($right['line'] ?: '&nbsp;')."</td>\n";
123- $html .= " <td class=\"{$right['type']}\">".($right['content'] ?: '&nbsp;')."</td>\n";
124- $html .= " </tr>\n";
125- }
126-
127- $html .= " </table>\n </p>\n";
128- return $html;
129- }
130-}
削除 src/Site/Lib/Image.php
@@ -1,79 +0,0 @@
1-<?php
2-namespace Site\Lib;
3-
4-use Site\Lib\Image\ImageInterface;
5-use Site\Lib\Image\Gif;
6-use Site\Lib\Image\Jpeg;
7-use Site\Lib\Image\Png;
8-use Site\Lib\Image\Targa;
9-
10-class RGB {
11- public int $r;
12- public int $g;
13- public int $b;
14- public array $rgb;
15-
16- public function __construct(int $r, int $g, int $b) {
17- $this->r = $r;
18- $this->g = $g;
19- $this->b = $b;
20- $this->rgb = ['r' => $r, 'g' => $g, 'b' => $b];
21- }
22-}
23-
24-class Image {
25- public string $filename = 'UNKNOWN';
26- public string $extension = 'UNKNOWN';
27- public string $type = 'UNSUPPORTED';
28- public string $size = '0 B';
29- public int $bytes = 0;
30- public \stdClass $fullInfo;
31-
32- private array $supported = [
33- 'image/gif' => Gif::class,
34- 'image/jpeg' => Jpeg::class,
35- 'image/png' => Png::class,
36- 'image/x-tga' => Targa::class,
37- ];
38-
39- public function __construct(string $file) {
40- $file = ROOT.$file;
41- // $file = ROOT.'/public/static/article/mock-screenshot.tga';
42- if (!file_exists($file)) return;
43- $fp = fopen($file, 'rb');
44- if (!$fp) return;
45- $extBytes = fread($fp, 15);
46- fclose($fp);
47-
48- $this->bytes = filesize($file);
49- $this->size = $this->formatBytes($this->bytes);
50- $this->extension = pathinfo($file, PATHINFO_EXTENSION);
51- $this->filename = str_replace('.'.$this->extension, '', array_last(explode('/', $file)));
52-
53- if (substr($extBytes, 0, 3) === "\xff\xd8\xff") $this->type = 'image/jpeg';
54- else if (substr($extBytes, 0, 15) === "\x52\x49\x46\x46\x1a\x28\x00\x00\x57\x45\x42\x50\x56\x50\x38") $this->type = 'image/webp';
55- else if (substr($extBytes, 0, 3) === "\x42\x4d\x36") $this->type = 'image/bmp';
56- else if (substr($extBytes, 0, 6) === "\x89\x50\x4e\x47\x0d\x0a") $this->type = 'image/png';
57- else if (substr($extBytes, 0, 6) === "\x47\x49\x46\x38\x37\x61" || substr($extBytes, 0, 6) === "\x47\x49\x46\x38\x39\x61") $this->type = 'image/gif';
58- else if (str_ends_with($file, '.tga')) $this->type = 'image/x-tga';
59-
60- $this->fullInfo = $this->createHandler($this->type)->parse($file);
61- $this->fullInfo->type = $this->type;
62- $this->fullInfo->size = $this->size;
63- $this->fullInfo->bytes = $this->bytes;
64- }
65-
66- ///////////////
67-
68- private function createHandler(string $ext): ImageInterface {
69- $class = $this->supported[$ext] ?? null;
70- if (!$class) throw new \RuntimeException("形式のハンドルが存在しない: {$ext}");
71- return new $class();
72- }
73-
74- private function formatBytes(int $size, int $precision = 2): string {
75- $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
76- for ($i = 0; $size > 1024 && $i < count($units) - 1; ++$i) $size /= 1024;
77- return round($size, $precision).' '.$units[$i];
78- }
79-}
削除 src/Site/Lib/Image/Gif.php
@@ -1,91 +0,0 @@
1-<?php
2-namespace Site\Lib\Image;
3-
4-use Site\Lib\Image\ImageInterface;
5-
6-class Gif implements ImageInterface {
7- public int $width = 0;
8- public int $height = 0;
9- public int $bitDepth = 0;
10- public int $numColors = 0;
11- public string $compression = 'NONE';
12- public int $interlace = 0;
13- public int $bgColor = 0;
14- public float $delayTime = 0;
15- public array $globalColorTable = [];
16- public int $numTransparentPixels = 0;
17- public int $lzwMinCodeSize = 0;
18- public string $imageData = '';
19-
20- /**
21- * TODO: 現在、インタレースした画像が未対応です。
22- */
23- public function parse(string $file): \stdClass {
24- $gif = new \stdClass();
25- $fp = fopen($file, 'rb');
26- $magic = fread($fp, 6); // マジックのスキップ
27-
28- // v = リトルエンディアン(2ビット)
29- $this->width = unpack('v', fread($fp, 2))[1];
30- $this->height = unpack('v', fread($fp, 2))[1];
31- $packed = ord(fread($fp, 1));
32- $this->bitDepth = ($packed & 0x07) + 1;
33- $this->numColors = 1 << $this->bitDepth;
34- $hasGct = $this->bitDepth === 8;
35-
36- $gif->width = $this->width;
37- $gif->height = $this->height;
38- $gif->bitDepth = $this->bitDepth;
39- $gif->numColors = $this->numColors;
40- if ($magic === 'GIF87a' || !$hasGct) return $gif; // それ以外情報がない
41-
42- $this->bgColor = (int)bin2hex(fread($fp, 1));
43- $gif->bgColor = $this->bgColor;
44- bin2hex(fread($fp, 1)); // いつでも 0:0?
45-
46- $gceLen = 0;
47- while (!feof($fp)) {
48- $skip = bin2hex(fread($fp, 3));
49- $color = new RGB((int)($skip[0].$skip[1]), (int)($skip[2].$skip[3]), (int)($skip[4].$skip[5]));
50- $this->globalColorTable[] = $color->rgb;
51- if ($skip[0].$skip[1] === '21' && $skip[2].$skip[3] === 'f9') {
52- $gceLen = (int)($skip[4].$skip[5]);
53- break;
54- }
55- }
56-
57- // GCE開始
58- $this->hasAlpha = (ord(fread($fp, 1)) & 0x01) === 0x01;
59- $this->delayTime = (unpack('v', fread($fp, 2))[1]) / 100.0;
60- $this->numTransparentPixels = ord(fread($fp, 1));
61- $gif->hasAlpha = $this->hasAlpha;
62- $gif->delayTime = $this->delayTime;
63- $gif->numTransparentPixels = $this->numTransparentPixels;
64- fread($fp, 1); // GCE終了
65- fread($fp, 1); // 画像指定子開始
66- fread($fp, 4); // 画面のXとY、いつでも(0, 0)
67- fread($fp, 4); // 画像のサイズ、関数の開始で既に保存した
68- $pack = ord(fread($fp, 1));
69- $this->interlace = ($pack & 0x40); // 画像のサイズ、関数の開始で既に保存した
70-
71- $this->lzwMinCodeSize = ord(fread($fp, 1)); // 画像データ開始
72- $gif->lzwMinCodeSize = $this->lzwMinCodeSize;
73- $imageData = '';
74- while (true) {
75- $blockSize = ord(fread($fp, 1));
76- if ($blockSize === 0) break;
77- $imageData .= fread($fp, 1);
78- }
79-
80- $this->imageData = base64_encode($imageData);
81- $this->colorType = 3;
82- $this->compression = 'LZW';
83- $gif->colorType = $this->colorType;
84- $gif->compression = $this->compression;
85- $gif->globalColorTable = $this->globalColorTable;
86- $gif->imageData = $this->imageData;
87-
88- fclose($fp);
89- return $gif;
90- }
91-}
削除 src/Site/Lib/Image/ImageInterface.php
@@ -1,6 +0,0 @@
1-<?php
2-namespace Site\Lib\Image;
3-
4-interface ImageInterface {
5- public function parse(string $file): \stdClass;
6-}
削除 src/Site/Lib/Image/Jpeg.php
@@ -1,54 +0,0 @@
1-<?php
2-namespace Site\Lib\Image;
3-
4-use Site\Lib\Image\ImageInterface;
5-
6-class Jpeg implements ImageInterface {
7- public int $width = 0;
8- public int $height = 0;
9- public int $bpp = 0;
10- public string $imageData = '';
11-
12- /**
13- * TODO: 現在、IHDRヘッダーのみを受け取る
14- */
15- public function parse(string $file): \stdClass {
16- $jpg = new \stdClass;
17- $fp = fopen($file, 'rb');
18-
19- $i = 0;
20- while (!feof($fp)) {
21- $byte = fread($fp, 1);
22- if ($byte !== "\xFF") continue;
23-
24- $marker = ord(fread($fp, 1));
25- if ($marker >= 0xC0 && $marker <= 0xC3) { // 0xC0 = SOF0, 0xC1 = SOF1, 0xC2 = SOF2, 0xC3 = SOF3
26- fread($fp, 2);
27- $this->bpp = ord(fread($fp, 1));
28- // n = ビッグエンディアン(2ビット)
29- $this->height = unpack('n', fread($fp, 2))[1];
30- $this->width = unpack('n', fread($fp, 2))[1];
31- $jpg->width = $this->width;
32- $jpg->height = $this->height;
33- $jpg->bpp = $this->bpp;
34- } else if ($marker == 0xC4) { // DHT (Huffmanテーブル(複数可))
35- //
36- } else if ($marker == 0xDB) { // DQT (量子化テーブル(複数可))
37- //
38- } else if ($marker == 0xDD) { // DRI (リセット期間)
39- } else if ($marker == 0xDA) { // SOS(画像開始)
40- } else if ($marker >= 0xD0 && $marker <= 0xD7) { // RST
41- } else if ($marker >= 0xE1 && $marker <= 0xEF) { // APP
42- } else if ($marker == 0xFE) { // COM
43- } else if ($marker == 0xD9) { // EOI(画像終了)
44- break;
45- }
46- }
47-
48- // kys($jpg);
49-
50- $i++;
51- fclose($fp);
52- return $jpg;
53- }
54-}
削除 src/Site/Lib/Image/Png.php
@@ -1,616 +0,0 @@
1-<?php
2-namespace Site\Lib\Image;
3-
4-use Site\Lib\Image\ImageInterface;
5-
6-class Png implements ImageInterface {
7- public \stdClass $IHDR; // 画像ヘッダー
8- // 横(4バイト)
9- // 丈(4バイト)
10- // ビット深さ(1バイト)
11- // 色類(1バイト)
12- //// | PNG画像タイプ | 色類 | 許可したビット深さ | 解釈 |
13- //// |--------------------------|------|--------------------|----------------------------------------------- |
14- //// | グレースケール | 0 | 1,2,4,8,16 | 各ピクセルはグレースケールのサンプル |
15- //// | トゥルーカラー | 2 | 8,16 | 各ピクセルはR,G,Bトリプル |
16- //// | 指標した色 | 3 | 1,2,4,8 | 各ピクセルはパレット指標(PLTEチャンクあり) |
17- //// | グレースケール+アルファ | 4 | 8,16 | 各ピクセルはグレースケール+アルファのサンプル |
18- //// | トゥルーカラー+アルファ | 6 | 8,16 | 各ピクセルはR,G,B,Aトリプル |
19- // 圧縮関数(1バイト)
20- //// 0 (DEFLATE)しかない
21- // フィルター関数(1バイト)
22- //// 0(アダプティブ・フィルタリング)しかない
23- // インタレース関数(1バイト)
24- //// 0 = なし
25- //// 1 = Adam7
26- public \stdClass $PLTE; // パレット(赤、緑、青)
27- // 色類3 = 必須
28- // 色類2と6 = 任意
29- public \stdClass $IDAT; // 画像データ
30- public \stdClass $IEND; // 画像終了
31-
32- public \stdClass $tRNS; // 透明情報
33- // 色類0 = グレーサンプル値(2バイト)
34- // 色類2 = RGBサンプル値(2x2x2バイト)
35- // 色類3 = パレット指標のアルファ(各1バイト)
36- public \stdClass $cHRM; // 主な色度とホワイトポイント
37- // 白X(4バイト)
38- // 白Y(4バイト)
39- // 赤X(4バイト)
40- // 赤Y(4バイト)
41- // 緑X(4バイト)
42- // 緑Y(4バイト)
43- // 青X(4バイト)
44- // 青Y(4バイト)
45- public \stdClass $gAMA; // ガンマ(4バイト)
46- public \stdClass $iCCP; // 組み込みしたICCプロファイル
47- // プロファイル名(1~79バイト)
48- // NULL分離子(1バイト)
49- // 圧縮関数(1バイト)
50- // 圧縮プロファイル(nバイト)
51- public \stdClass $sBIT; // 上位ビット(4バイト)
52- // 色類0 = グレーサンプルビット(1バイト)
53- // 色類2, 3 = RGBサンプルビット(1x1x1バイト)
54- // 色類4 = グレーサンプルビット(1x1バイト)
55- // 色類6 = RGBサンプルビット(1x1x1x1バイト)
56- public \stdClass $sRGB; // レンダリングインテント(1バイト)
57- // 0 = 知覚
58- // 1 = 相対カラー・メトリック
59- // 2 = 彩度
60- // 3 = 絶対カラー・メトリック
61- public \stdClass $cICP; // (1バイト)
62- public \stdClass $mDCV; // (1バイト)
63- public \stdClass $cLLI; // (1バイト)
64- public \stdClass $tEXt; // 文字データ
65- public \stdClass $iTXt; // 圧縮した文字データ
66- public \stdClass $zTXt; // 国際文字データ
67- public \stdClass $bKGD; // 拝啓色
68- public \stdClass $hIST; // 画像ヒストグラム
69- public \stdClass $pHYs; // 物理的なぷくセルのサイズ
70- // 単位当たりのピクセル数(PPU)のX軸(4バイト)
71- // 単位当たりのピクセル数(PPU)のY軸(4バイト)
72- // ユニット指定子(1バイト)
73- //// 0 = 不明
74- //// 1 = メートル
75- public \stdClass $sPLT; // 提案されている配色パターン
76- // パレット名(1~79バイト)
77- // NULL分離子(1バイト)
78- // 赤(1か2バイト)
79- // 緑(1か2バイト)
80- // 青(1か2バイト)
81- // アルファ(1か2バイト)
82- // 周波数(2バイト)
83- // 等
84- public \stdClass $eXIf; // Exifプロファイル
85- public \stdClass $tIME; // 画像の最後変更日
86- // 年(2バイト)
87- // 月(1バイト:1-12)
88- // 日(1バイト:1-31)
89- // 時(1バイト:0-23)
90- // 分(1バイト:0-59)
91- // 秒(1バイト:0-60)
92- public \stdClass $acTL; // アニメーション・コントロール
93- // フレーム数(4バイト)
94- // 再生数(4バイト、0 = 無限)
95-
96- public array $frames = [];
97- private ?array $currentFrame = null;
98-
99- // TODO: 残り: sBIT, iTXt, hIST, sPLT, tIME
100- public function parse(string $file): \stdClass {
101- $png = new \stdClass();
102- $fp = fopen($file, 'rb');
103-
104- $magic = bin2hex(fread($fp, 8)); // マジックのスキップ
105- $length = unpack('N', fread($fp, 4))[1];
106- $header = fread($fp, 4);
107- if ($header !== 'IHDR') {
108- fclose($fp);
109- return $png;
110- }
111-
112- // IHDRデータ
113- $data = fread($fp, $length);
114-
115- // N = ビッグエンディアン(4ビット)
116- $this->IHDR = new \stdClass;
117- $this->IHDR->name = 'IHDR';
118- $this->IHDR->length = $length;
119- $this->IHDR->width = unpack('N', substr($data, 0, 4))[1];
120- $this->IHDR->height = unpack('N', substr($data, 4, 4))[1];
121- $this->IHDR->bitDepth = ord($data[8]); // 1, 2, 4, 8, 16
122- $this->IHDR->numColors = 1 << $this->IHDR->bitDepth;
123- $this->IHDR->colorType = ord($data[9]); // 0, 2, 3, 4, 6
124- $this->IHDR->compressionType = ord($data[10]);
125- $this->IHDR->compression = 'DEFLATE';
126- $this->IHDR->filter = ord($data[11]);
127- $this->IHDR->interlace = ord($data[12]); // 0 = なし, 1 = Adam7
128- $this->IHDR->bpp = $this->IHDR->bitDepth * $this->getSamplesPerPixel($this->IHDR->colorType);
129- $this->IHDR->hasAlpha = ($this->IHDR->colorType === 4 || $this->IHDR->colorType === 6);
130- $crc = fread($fp, 4);
131- $this->IHDR->crc = $crc;
132- $this->IHDR->crc32 = unpack('N', $crc)[1];
133- $this->IHDR->crcHex = bin2hex($crc);
134-
135- $png->header = $this->IHDR;
136-
137-next_chunk:
138- $lengthBytes = fread($fp, 4);
139- if (strlen($lengthBytes) < 4) {
140- logger(LogType::Image, 'バイト長さが悪い。ファイル:'.$file.'、バイト長さ:'.'$lengthBytes');
141- fclose($fp);
142- return $png;
143- }
144-
145- $length = unpack('N', $lengthBytes)[1];
146- $nextChunk = fread($fp, 4);
147- $data = $length > 0 ? fread($fp, $length) : '';
148- $crc = fread($fp, 4);
149-
150- // PLTEデータ
151- if ($nextChunk === "\x50\x4c\x54\x45") { // PLTE
152- $palette = [];
153- for ($i = 0; $i < $length; $i += 3) {
154- $colors = new \Site\Lib\RGB(ord($data[$i]), ord($data[$i+1]), ord($data[$i+2]));
155- $palette[] = $colors->rgb;
156- }
157-
158- $this->PLTE = new \stdClass;
159- $this->PLTE->name = 'PLTE';
160- $this->PLTE->length = $length;
161- $this->PLTE->palette = $palette;
162- $this->PLTE->crc = $crc;
163- $this->PLTE->crc32 = unpack('N', $crc)[1];
164- $this->PLTE->crcHex = bin2hex($crc);
165-
166- $png->palette = $this->PLTE;
167- goto next_chunk;
168- } else if ($nextChunk === "\x74\x52\x4e\x53") { // tRNS
169- $this->tRNS = new \stdClass;
170- $this->tRNS->name = 'tRNS';
171- $this->tRNS->length = $length;
172- $vals = [];
173- $bytes = $this->IHDR->colorType === 2 ? 3 : 1;
174- for ($i = 0; $i < $length; $i += $bytes) {
175- if ($this->IHDR->colorType === 2) {
176- $colors = new \Site\Lib\RGB(ord($data[$i]), ord($data[$i+1]), ord($data[$i+2]));
177- $vals[] = $colors->rgb;
178- } else {
179- $vals[] = ord($data[$i]);
180- }
181- }
182- $this->tRNS->values = $vals;
183- $this->tRNS->crc = $crc;
184- $this->tRNS->crc32 = unpack('N', $crc)[1];
185- $this->tRNS->crcHex = bin2hex($crc);
186- $png->transparency = $this->tRNS;
187- goto next_chunk;
188- } else if ($nextChunk === "\x63\x48\x52\x4d") { // cHRM
189- $this->cHRM = new \stdClass;
190- $this->cHRM->name = 'cHRM';
191- $this->cHRM->length = $length;
192- $this->cHRM->whitePointX = unpack('N', substr($data, 0, 4))[1];
193- $this->cHRM->whitePointY = unpack('N', substr($data, 4, 4))[1];
194- $this->cHRM->redX = unpack('N', substr($data, 8, 4))[1];
195- $this->cHRM->redY = unpack('N', substr($data, 12, 4))[1];
196- $this->cHRM->greenX = unpack('N', substr($data, 16, 4))[1];
197- $this->cHRM->greenY = unpack('N', substr($data, 20, 4))[1];
198- $this->cHRM->blueX = unpack('N', substr($data, 24, 4))[1];
199- $this->cHRM->blueY = unpack('N', substr($data, 28, 4))[1];
200- $this->cHRM->crc = $crc;
201- $this->cHRM->crc32 = unpack('N', $crc)[1];
202- $this->cHRM->crcHex = bin2hex($crc);
203- $png->chromaticities = $this->cHRM;
204- goto next_chunk;
205- } else if ($nextChunk === "\x67\x41\x4d\x41") { // gAMA
206- $this->gAMA = new \stdClass;
207- $this->gAMA->name = 'gAMA';
208- $this->gAMA->length = $length;
209- $this->gAMA->value = unpack('N', $data)[1];
210- $this->gAMA->crc = $crc;
211- $this->gAMA->crc32 = unpack('N', $crc)[1];
212- $this->gAMA->crcHex = bin2hex($crc);
213- $png->gamma = $this->gAMA;
214- goto next_chunk;
215- } else if ($nextChunk === "\x69\x43\x43\x50") { // iCCP
216- $this->iCCP = new \stdClass;
217- $this->iCCP->name = 'iCCP';
218- $this->iCCP->length = $length;
219-
220- $nullPos = strpos($data, "\0");
221- if ($nullPos === false || $nullPos < 1 || $nullPos > 79) {
222- $this->iCCP->valid = false;
223- $this->iCCP->error = "Missing or invalid profile name terminator";
224- goto iccp_crc;
225- }
226-
227- $this->iCCP->profileName = substr($data, 0, $nullPos);
228- $compressionMethod = ord($data[$nullPos + 1]);
229- $this->iCCP->compressionMethod = $compressionMethod;
230-
231- if ($compressionMethod !== 0) {
232- $this->iCCP->valid = false;
233- $this->iCCP->error = "Unsupported compression method: {$compressionMethod}";
234- goto iccp_crc;
235- }
236-
237- $compressedData = substr($data, $nullPos + 2);
238- $this->iCCP->compressedData = $compressedData;
239- $this->iCCP->compressedSize = strlen($compressedData);
240- $this->iCCP->valid = true;
241-
242-iccp_crc:
243- $this->iCCP->crc = $crc;
244- $this->iCCP->crc32 = unpack('N', $crc)[1];
245- $this->iCCP->crcHex = bin2hex($crc);
246- $png->iccProfile = $this->iCCP;
247- goto next_chunk;
248- } else if ($nextChunk === "\x73\x42\x49\x54") { // sBIT
249- $this->sBIT = new \stdClass;
250- $this->sBIT->name = 'sBIT';
251- $this->sBIT->length = $length;
252- kys(['sBIT', $length, bin2hex($data)]);
253- $this->sBIT->crc = $crc;
254- $this->sBIT->crc32 = unpack('N', $crc)[1];
255- $this->sBIT->crcHex = bin2hex($crc);
256- $png->significantBits = $this->sBIT;
257- goto next_chunk;
258- } else if ($nextChunk === "\x73\x52\x47\x42") { // sRGB
259- $this->sRGB = new \stdClass;
260- $this->sRGB->name = 'sRGB';
261- $this->sRGB->length = $length;
262- $this->sRGB->intent = ord($data);
263- $name = [
264- 0 => 'Perceptual',
265- 1 => 'Relative colorimetric',
266- 2 => 'Saturation',
267- 3 => 'Absolute colorimetric',
268- ];
269- $this->sRGB->intentName = $name[$this->sRGB->intent];
270- $this->sRGB->crc = $crc;
271- $this->sRGB->crc32 = unpack('N', $crc)[1];
272- $this->sRGB->crcHex = bin2hex($crc);
273- $png->rgbColorSpace = $this->sRGB;
274- goto next_chunk;
275- } else if ($nextChunk === "\x63\x49\x43\x50") { // cICP
276- $this->cICP = new \stdClass;
277- $this->cICP->name = 'cICP';
278- $this->cICP->length = $length;
279- $this->cICP->colorPrimaries = ord($data[0]);
280- $this->cICP->transferFunction = ord($data[1]);
281- $this->cICP->matrixCoefficients = ord($data[2]);
282- $this->cICP->videoFullRangeFlag = ord($data[3]);
283- $this->cICP->crc = $crc;
284- $this->cICP->crc32 = unpack('N', $crc)[1];
285- $this->cICP->crcHex = bin2hex($crc);
286- $png->videoSignalTypeId = $this->cICP;
287- goto next_chunk;
288- } else if ($nextChunk === "\x6d\x44\x43\x56") { // mDCV
289- $this->mDCV = new \stdClass;
290- $this->mDCV->name = 'mDCV';
291- $this->mDCV->length = $length;
292-
293- $u16 = fn($off) => unpack('n', substr($data, $off, 2))[1];
294- $u32 = fn($off) => unpack('N', substr($data, $off, 4))[1];
295- $this->mDCV->primaries = [
296- 'red' => ['x' => $u16(0), 'y' => $u16(2)],
297- 'green' => ['x' => $u16(4), 'y' => $u16(6)],
298- 'blue' => ['x' => $u16(8), 'y' => $u16(10)],
299- ];
300- $this->mDCV->whitePoint = [
301- 'x' => $u16(12),
302- 'y' => $u16(14),
303- ];
304- $this->mDCV->luminance = [
305- 'max' => $u16(16) * 0.0001,
306- 'min' => $u16(20) * 0.0001,
307- 'maxRaw' => $u16(16),
308- 'minRaw' => $u16(20),
309- ];
310- $this->mDCV->crc = $crc;
311- $this->mDCV->crc32 = unpack('N', $crc)[1];
312- $this->mDCV->crcHex = bin2hex($crc);
313- $png->masterDisplayColorVolume = $this->mDCV;
314- goto next_chunk;
315- } else if ($nextChunk === "\x63\x4c\x4c\x49") { // cLLI
316- $this->cLLI = new \stdClass;
317- $this->cLLI->name = 'cLLI';
318- $this->cLLI->length = $length;
319- $this->cLLI->maxCCL = unpack('N', substr($data, 0, 4))[1] * 0.0001;
320- $this->cLLI->maxCCLRaw = unpack('N', substr($data, 0, 4))[1];
321- $this->cLLI->maxFALL = unpack('N', substr($data, 4, 4))[1] * 0.0001;
322- $this->cLLI->maxFALLRaw = unpack('N', substr($data, 4, 4))[1];
323- $this->cLLI->crc = $crc;
324- $this->cLLI->crc32 = unpack('N', $crc)[1];
325- $this->cLLI->crcHex = bin2hex($crc);
326- $png->contentLightLevelInfo = $this->cLLI;
327- goto next_chunk;
328- } else if ($nextChunk === "\x74\x45\x58\x74") { // tEXt
329- if (!isset($png->textualData)) $png->textualData = [];
330- $this->tEXt = new \stdClass;
331- $this->tEXt->name = 'tEXt';
332- $this->tEXt->length = $length;
333- $this->tEXt->text = $data;
334- $this->tEXt->crc = $crc;
335- $this->tEXt->crc32 = unpack('N', $crc)[1];
336- $this->tEXt->crcHex = bin2hex($crc);
337- $png->textualData[] = $this->tEXt;
338- goto next_chunk;
339- } else if ($nextChunk === "\x69\x54\x58\x74") { // iTXt
340- if (!isset($png->internationalTextualData)) $png->textualData = [];
341- $this->iTXt = new \stdClass;
342- $this->iTXt->name = 'iTXt';
343- $this->iTXt->length = $length;
344- kys(['iTXt', $length, bin2hex($data)]);
345- $this->iTXt->crc = $crc;
346- $this->iTXt->crc32 = unpack('N', $crc)[1];
347- $this->iTXt->crcHex = bin2hex($crc);
348- $png->internationalTextualData[] = $this->iTXt;
349- kys($png);
350- goto next_chunk;
351- } else if ($nextChunk === "\x7a\x54\x58\x74") { // zTXt
352- if (!isset($png->compressedTextualData)) $png->textualData = [];
353- $this->zTXt = new \stdClass;
354- $this->zTXt->name = 'zTXt';
355- $this->zTXt->length = $length;
356-
357- $nullPos = strpos($data, "\0");
358- if ($nullPos === false || $nullPos < 1 || $nullPos > 79) {
359- $this->zTXt->valid = false;
360- $this->zTXt->error = "Missing or invalid profile name terminator";
361- goto zTXt_crc;
362- }
363-
364- $this->zTXt->keyword = rtrim(substr($data, 0, $nullPos));
365- $compressionMethod = ord($data[$nullPos + 1] ?? "\xFF");
366-
367- $this->zTXt->compressionMethod = $compressionMethod;
368-
369- if ($compressionMethod !== 0) {
370- $this->zTXt->valid = false;
371- $this->zTXt->error = "Unsupported compression method";
372- goto zTXt_crc;
373- }
374-
375- $compressedText = substr($data, $nullPos + 2);
376- fread($fp, 13); // ????????????????????????
377- $this->zTXt->compressedText = $compressedText;
378- $this->zTXt->compressedLength = strlen($compressedText);
379-
380- $text = zlib_decode($compressedText);
381-
382- if ($text === false) {
383- $this->zTXt->valid = false;
384- $this->zTXt->decompressionError = true;
385- $this->zTXt->text = null;
386- goto zTXt_crc;
387- } else {
388- $this->zTXt->text = $text;
389- $this->zTXt->textUtf8 = mb_convert_encoding($text, 'UTF-8', 'ISO-8859-1');
390- }
391-
392- $this->zTXt->valid = true;
393-
394-zTXt_crc:
395- $this->zTXt->crc = $crc;
396- $this->zTXt->crc32 = unpack('N', $crc)[1];
397- $this->zTXt->crcHex = bin2hex($crc);
398- $png->compressedTextualData[] = $this->zTXt;
399- goto next_chunk;
400- } else if ($nextChunk === "\x62\x4b\x47\x44") { // bKGD
401- $this->bKGD = new \stdClass;
402- $this->bKGD->name = 'bKGD';
403- $this->bKGD->length = $length;
404- if ($this->IHDR->colorType === 2 || $this->IHDR->colorType === 6) {
405- $r = (ord($data[0]) << 8) | ord($data[1]);
406- $g = (ord($data[2]) << 8) | ord($data[3]);
407- $b = (ord($data[4]) << 8) | ord($data[5]);
408-
409- $this->bKGD->type = 'rgb16';
410- $this->bKGD->r = $r;
411- $this->bKGD->g = $g;
412- $this->bKGD->b = $b;
413-
414- $this->bKGD->r8 = $r >> 8;
415- $this->bKGD->g8 = $g >> 8;
416- $this->bKGD->b8 = $b >> 8;
417- $this->bKGD->webColor = sprintf("#%02x%02x%02x", $this->bKGD->r8, $this->bKGD->g8, $this->bKGD->b8);
418- } else if ($this->IHDR->colorType === 2 || $this->IHDR->colorType === 6) {
419- $gray = (ord($data[0]) << 8) | ord($data[1]);
420- $this->bKGD->type = 'gray16';
421- $this->bKGD->gray = $gray;
422- $this->bKGD->gray8 = $gray >> 8;
423- } else if ($this->IHDR->colorType === 3) {
424- $this->bKGD->type = 'palette_index';
425- $this->bKGD->value = ord($data);
426- } else {
427- $this->bKGD->type = 'unknown';
428- $this->bKGD->error = 'Invalid color type for bKGD';
429- }
430-
431- $this->bKGD->crc = $crc;
432- $this->bKGD->crc32 = unpack('N', $crc)[1];
433- $this->bKGD->crcHex = bin2hex($crc);
434- $png->backgroundColor = $this->bKGD;
435- goto next_chunk;
436- } else if ($nextChunk === "\x68\x49\x53\x54") { // hIST
437- $this->hIST = new \stdClass;
438- $this->hIST->name = 'hIST';
439- $this->hIST->length = $length;
440- kys(['hIST', $length, bin2hex($data)]);
441- $this->hIST->crc = $crc;
442- $this->hIST->crc32 = unpack('N', $crc)[1];
443- $this->hIST->crcHex = bin2hex($crc);
444- $png->imageHistogram = $this->hIST;
445- kys($png);
446- goto next_chunk;
447- } else if ($nextChunk === "\x70\x48\x59\x73") { // pHYs
448- $this->pHYs = new \stdClass;
449- $this->pHYs->name = 'pHYs';
450- $this->pHYs->length = $length;
451- $this->pHYs->x = unpack('N', substr($data, 0, 4))[1];
452- $this->pHYs->y = unpack('N', substr($data, 4, 4))[1];
453- $unit = [
454- 0 => 'unit is unknown',
455- 1 => 'unit is the metre',
456- ];
457- $this->pHYs->unitId = ord($data[8]);
458- $this->pHYs->unit = $unit[$this->pHYs->unitId];
459- $this->pHYs->crc = $crc;
460- $this->pHYs->crc32 = unpack('N', $crc)[1];
461- $this->pHYs->crcHex = bin2hex($crc);
462- $png->physicalPixelDimensions = $this->pHYs;
463- goto next_chunk;
464- } else if ($nextChunk === "\x73\x50\x4c\x54") { // sPLT
465- $this->sPLT = new \stdClass;
466- $this->sPLT->name = 'sPLT';
467- $this->sPLT->length = $length;
468- kys(['sPLT', $length, bin2hex($data)]);
469- $this->sPLT->crc = $crc;
470- $this->sPLT->crc32 = unpack('N', $crc)[1];
471- $this->sPLT->crcHex = bin2hex($crc);
472- $png->suggestedPalette = $this->sPLT;
473- kys($png);
474- goto next_chunk;
475- } else if ($nextChunk === "\x65\x58\x49\x66") { // eXIf
476- $this->eXIf = new \stdClass;
477- $this->eXIf->name = 'eXIf';
478- $this->eXIf->length = $length;
479-
480- if (strlen($data) < 8) {
481- $this->eXIf->valid = false;
482- $this->eXIf->error = "eXIf chunk too short";
483- goto exif_crc;
484- }
485-
486- $byteOrder = substr($data, 0, 2);
487- $magic = substr($data, 2, 2);
488-
489- $this->eXIf->rawData = $data;
490- $this->eXIf->rawLength = $length;
491- $this->eXIf->byteOrder = $byteOrder; // II = リトルエンディアン、MM = ビッグエンディアン
492- $this->eXIf->isBigEndian = ($byteOrder === 'MM');
493- $this->eXIf->magic = unpack('n', $magic)[1]; // いつでも42ではず
494-
495- if (strlen($data) < 8) {
496- $this->eXIf->valid = false;
497- $this->eXIf->error = "Invalid TIFF magic number";
498- goto exif_crc;
499- }
500-
501- $this->eXIf->valid = true;
502-
503- $ifdOffset = unpack($this->eXIf->isBigEndian ? 'N' : 'V', substr($data, 4, 4))[1];
504- $this->eXIf->firstIfdOffset = $ifdOffset;
505-
506-exif_crc:
507- $this->eXIf->crc = $crc;
508- $this->eXIf->crc32 = unpack('N', $crc)[1];
509- $this->eXIf->crcHex = bin2hex($crc);
510- $png->exifProfile = $this->eXIf;
511- goto next_chunk;
512- } else if ($nextChunk === "\x74\x49\x4d\x45") { // tIME
513- $this->tIME = new \stdClass;
514- $this->tIME->name = 'tIME';
515- $this->tIME->length = $length;
516- kys(['tIME', $length, bin2hex($data)]);
517- $this->tIME->crc = $crc;
518- $this->tIME->crc32 = unpack('N', $crc)[1];
519- $this->tIME->crcHex = bin2hex($crc);
520- $png->lastModificationTime = $this->tIME;
521- kys($png);
522- goto next_chunk;
523- } else if ($nextChunk === "\x61\x63\x54\x4c") { // acTL
524- $this->acTL = new \stdClass;
525- $this->acTL->name = 'acTL';
526- $this->acTL->length = $length;
527- $this->acTL->numFrames = unpack('N', substr($data, 0, 4))[1];
528- $this->acTL->numPlays = unpack('N', substr($data, 4, 4))[1];
529- $this->acTL->crc = $crc;
530- $this->acTL->crc32 = unpack('N', $crc)[1];
531- $this->acTL->crcHex = bin2hex($crc);
532- $png->aniationControl = $this->acTL;
533- goto next_chunk;
534- } else if ($nextChunk === "\x66\x63\x54\x4c") { // fcTL
535- if ($this->currentFrame !== null) $this->frames[] = (object)$this->currentFrame;
536-
537- $this->currentFrame = [
538- 'name' => 'fcTL',
539- 'length' => $length,
540- 'sequenceNumber' => unpack('N', substr($data, 0, 4))[1],
541- 'width' => unpack('N', substr($data, 4, 4))[1],
542- 'height' => unpack('N', substr($data, 8, 4))[1],
543- 'xOffset' => unpack('N', substr($data, 12, 4))[1],
544- 'yOffset' => unpack('N', substr($data, 16, 4))[1],
545- 'delayNum' => unpack('n', substr($data, 20, 2))[1],
546- 'delayDen' => unpack('n', substr($data, 22, 2))[1],
547- 'disposeOp' => ord($data[24]),
548- 'blendOp' => ord($data[25]),
549- 'data' => null,
550- 'crc' => $crc,
551- 'crc32' => unpack('N', $crc)[1],
552- 'crcHex' => bin2hex($crc),
553- ];
554-
555- goto next_chunk;
556- } else if ($nextChunk === "\x49\x44\x41\x54" || $nextChunk === "\x66\x64\x41\x54") { // IDAT || fdAT
557- if ($this->currentFrame === null) {
558- $this->currentFrame = [
559- 'sequenceNumber' => 0,
560- 'isFirstFrame' => true,
561- 'width' => $this->IHDR->width,
562- 'height' => $this->IHDR->height,
563- 'data' => null,
564- ];
565- }
566-
567- $compressedData = ($nextChunk === "\x66\x64\x41\x54") ? substr($data, 4) : $data;
568- $this->currentFrame['name'] = 'fdAT';
569- $this->currentFrame['length'] = $length;
570- $this->currentFrame['data'] = $compressedData;
571- $this->currentFrame['dataBase64'] = base64_encode($compressedData);
572- $this->currentFrame['dataHex'] = bin2hex($compressedData);
573- $this->currentFrame['dataLength'] = strlen($compressedData);
574- $this->currentFrame['dataChunkType'] = $nextChunk;
575- $this->currentFrame['crc'] = $crc;
576- $this->currentFrame['crcBase64'] = unpack('N', $crc)[1];
577- $this->currentFrame['crcHex'] = bin2hex($crc);
578-
579- if ($nextChunk === "\x49\x44\x41\x54") {
580- $this->currentFrame['name'] = 'IDAT';
581- $this->frames[] = (object)$this->currentFrame;
582- $this->currentFrame = null;
583- }
584-
585- $png->data = $this->frames;
586- goto next_chunk;
587- } else if ($nextChunk === "\x49\x45\x4e\x44") { // IEND
588- if ($this->currentFrame !== null) {
589- $this->frames[] = (object)$this->currentFrame;
590- }
591- $png->end = (object)['crc' => $crc, 'crc32' => unpack('N', $crc)[1], 'crcHex' => bin2hex($crc)];
592- } else {
593- fclose($fp);
594- $err = '不明なチャンク:'.$nextChunk.'、HEX:'.bin2hex($nextChunk);
595- kys($err);
596- logger(\LogType::Image, '【PNG】'.$err);
597- return $png;
598- }
599-
600- fclose($fp);
601- return $png;
602- }
603-
604- ///////////////
605-
606- private function getSamplesPerPixel(int $colorType): int {
607- return match($colorType) {
608- 0 => 1, // グレースケール
609- 2 => 3, // RGB
610- 3 => 1, // 指標した色(ペレット)
611- 4 => 2, // グレースケール+アルファ
612- 6 => 4, // RGBA
613- default => 1,
614- };
615- }
616-}
削除 src/Site/Lib/Image/Targa.php
@@ -1,115 +0,0 @@
1-<?php
2-namespace Site\Lib\Image;
3-
4-use Site\Lib\Image\ImageInterface;
5-
6-class Targa implements ImageInterface {
7- public int $id = 0;
8- public int $colorMapType = 0;
9- public int $imageTypeByte = 0;
10- public string $imageType = '';
11- public int $colorMapEntryIndex = 0;
12- public int $colorMapLength = 0;
13- public int $colorMapBits = 0;
14- public int $x = 0;
15- public int $y = 0;
16- public int $width = 0;
17- public int $height = 0;
18- public int $bitDepth = 0;
19- public int $bpp = 0;
20- public int $interlace = 0;
21- public int $isRightToLeft = 0; // 0 = 左→右, 1 = 右→左
22- public int $isTopToBottom = 0; // 0 = 下→上, 1 = 上→下
23- public int $alphaBits = 0;
24- public bool $hasAlpha = false;
25- //--------
26- public string $imageId = '';
27- public string $colorMapData = '';
28- public string $imageData = '';
29-
30- private array $imageTypes = [
31- 0 => 'NO DATA',
32- 1 => 'UNCOMPRESSED COLOR-MAPPED IMAGE',
33- 2 => 'UNCOMPRESSED TRUE COLOR IMAGE',
34- 3 => 'UNCOMPRESSED GRAYSCALE IMAGE',
35- 9 => 'RUN-LENGTH ENCODED COLOR-MAPPED IMAGE',
36- 10 => 'RUN-LENGTH ENCODED TRUE-COLOR IMAGE',
37- 11 => 'RUN-LENGTH ENCODED GRAYSCALE IMAGE',
38- 32 => 'HUFFMAN-DELTA-RUN-LENGTH ENCODED COLOR-MAPPED IMAGE',
39- 33 => 'HUFFMAN-DELTA-RUN-LENGTH-4-PASS-QUADTREE-TYPE PROCESS ENCODED COLOR-MAPPED IMAGE',
40- ];
41-
42- public function parse(string $file): \stdClass {
43- $tga = new \stdClass();
44- $fp = fopen($file, 'rb');
45-
46- $this->id = ord(fread($fp, 1));
47- $this->colorMapType = ord(fread($fp, 1)); // 0 = なし, 1 = あり, 2~127 = Truevision向け, 128~255 = 開発者向け
48- $this->imageTypeByte = ord(fread($fp, 1));
49- $this->imageType = $this->imageTypes[$this->imageTypeByte];
50- $tga->id = $this->id;
51- $tga->colorMapType = $this->colorMapType;
52- $tga->imageTypeByte = $this->imageTypeByte;
53- $tga->imageType = $this->imageType;
54-
55- $this->colorMapEntryIndex = unpack('v', fread($fp, 2))[1];
56- $this->colorMapLength = unpack('v', fread($fp, 2))[1];
57- $this->colorMapBits = ord(fread($fp, 1));
58- $tga->colorMapEntryIndex = $this->colorMapEntryIndex;
59- $tga->colorMapLength = $this->colorMapLength;
60- $tga->colorMapBits = $this->colorMapBits;
61-
62- $this->x = unpack('v', fread($fp, 2))[1];
63- $this->y = unpack('v', fread($fp, 2))[1];
64- $this->width = unpack('v', fread($fp, 2))[1];
65- $this->height = unpack('v', fread($fp, 2))[1];
66- $this->bitDepth = ord(fread($fp, 1));
67- $tga->x = $this->x;
68- $tga->y = $this->y;
69- $tga->width = $this->width;
70- $tga->height = $this->height;
71- $tga->bitDepth = $this->bitDepth;
72-
73- $desc = ord(fread($fp, 1));
74- $this->interlace = ($desc & 0xC0) >> 6;
75- $this->isTopToBottom = ($desc & 0x20) !== 0;
76- $this->isRightToLeft = ($desc & 0x10) !== 0;
77- $this->alphaBits = $desc & 0x0F;
78- $this->hasAlpha = ($this->alphaBits == 8) && in_array($this->imageTypeByte == [2, 3, 10, 11]);
79- $tga->interlace = $this->interlace;
80- $tga->isRightToLeft = $this->isRightToLeft;
81- $tga->isTopToBottom = $this->isTopToBottom;
82- $tga->alphaBits = $this->alphaBits;
83- $tga->hasAlpha = $this->hasAlpha;
84-
85- $needsXFlip = $tga->isRightToLeft;
86- $needsYFlip = !$tga->isTopToBottom;
87- // 0010 0000
88-
89- //---------
90- $imageIdBytes = $this->id;
91- $colorMapBytes = 0;
92- if ($this->colorMapType === 1 && $this->colorMapLength > 0) {
93- $bytesPerEntry = (int)($this->colorMapBits / 8);
94- $colorMapBytes = $this->colorMapLength * $bytesPerEntry;
95- }
96-
97- $this->imageId = $imageIdBytes > 0 ? fread($fp, $imageIdBytes) : '';
98- $this->colorMapData = $colorMapBytes > 0 ? fread($fp, $colorMapBytes) : '';
99- $tga->imageId = $this->imageId;
100- $tga->colorMapData = $this->colorMapData;
101-
102- if (in_array($this->imageTypeByte, [9, 10, 11])) { // RLE
103- // TODO
104- } else if (in_array($this->imageTypeByte, [1, 2, 3])) { // 非圧縮化
105- $this->bpp = (int)($this->bitDepth / 8);
106- $bytes = $this->width * $this->height * $this->bpp;
107- $this->imageData = fread($fp, $bytes);
108- $tga->bpp = $this->bpp;
109- $tga->imageData = $this->imageData;
110- }
111-
112- fclose($fp);
113- return $tga;
114- }
115-}
削除 src/Site/Lib/Mailer.php
@@ -1,238 +0,0 @@
1-<?php
2-namespace Site\Lib;
3-
4-class Mailer {
5- private $socket;
6- private string $host;
7- private int $port;
8- private ?string $user;
9- private ?string $pass;
10-
11- private ?string $pgpKey;
12- private ?string $pgpPass;
13-
14- /**
15- * コンストラクタ
16- */
17- public function __construct() {
18- if (!MAILER_ENABLED) {
19- throw new \Exception("メーラーは無効です。");
20- }
21-
22- $this->host = MAILINFO['host'];
23- $this->port = MAILINFO['port'];
24- $this->user = MAILINFO['user'];
25- $this->pass = MAILINFO['pass'];
26- }
27-
28- /**
29- * ソケット接続を開く。
30- *
31- * @return void
32- */
33- public function connect(): void {
34- $this->socket = fsockopen($this->host, $this->port, $errno, $err, 30);
35- if (!$this->socket) {
36- $msg = "接続に失敗: {$err} ({$errno})";
37- logger(\LogType::Mailer, $msg);
38- throw new \Exception($msg);
39- }
40-
41- $this->readResponse();
42- }
43-
44- /**
45- * ソケット接続を切断する。
46- *
47- * @return void
48- */
49- public function disconnect(): void {
50- $this->sendCommand('QUIT', 221);
51- fclose($this->socket);
52- }
53-
54- /**
55- * サーバーで認証する。
56- * ユーザー名とパスワードが指定されている場合はAUTH LOGINを使用する。
57- *
58- * @return void
59- */
60- public function authenticate(): void {
61- $ehloRes = $this->sendCommand('EHLO '.$this->host, 250);
62-
63- // STARTTLSは対応するかどうか確認
64- if (strpos($ehloRes, 'STARTTLS') !== false) {
65- $this->sendCommand('STARTTLS', 220);
66-
67- // TLS暗号化
68- if (!stream_socket_enable_crypto(
69- $this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
70- $msg = "TLSハンドシェイクに失敗";
71- logger(\LogType::Mailer, $msg);
72- throw new \Exception($msg);
73- }
74-
75- $this->sendCommand('EHLO '.gethostname(), 250);
76- }
77-
78- if ($this->user && $this->pass) {
79- $this->sendCommand('AUTH LOGIN', 334);
80- $this->sendCommand(base64_encode($this->user), 334);
81- $this->sendCommand(base64_encode($this->pass), 235);
82- }
83- }
84-
85- /**
86- * TLSハンドシェイクに失敗する。
87- *
88- * @param string $to 受信者のメールアドレス。
89- * @param string $subject メールの件名。
90- * @param string $body メールの本文。
91- * @param string|null $toName 受信者の名前。
92- * @param string|null $replyTo 返信先メールアドレス。
93- * @param string|null $replyToName 返信先の名前。
94- * @param bool $pgpSign PGPで署名するかどうか(現在は正常に動作していません)。
95- *
96- * @return void
97- */
98- public function send(
99- string $to,
100- string $subject,
101- string $body,
102- ?string $toName = null,
103- ?string $replyTo = null,
104- ?string $replyToName = null,
105- bool $pgpSign = false
106- ): void {
107- $from = MAILINFO['from'];
108-
109- $this->sendCommand("MAIL FROM: <{$from}>", 250);
110- $this->sendCommand("RCPT TO: <{$to}>", 250);
111- $this->sendCommand('DATA', 354);
112-
113- $headers = "Date: ".date('r')."\r\n"; // RFC 2822
114-
115- $encSubject = mb_encode_mimeheader($subject, 'UTF-8', 'Q');
116- $headers .= "Subject: {$encSubject}\r\n";
117-
118- $fromHeader = mb_encode_mimeheader(SITEINFO['title'], 'UTF-8', 'Q')." <{$from}>";
119- $headers .= "From: {$fromHeader}\r\n";
120-
121- $toHeader = $toName
122- ? mb_encode_mimeheader($toName, 'UTF-8', 'Q')." <{$to}>" : $to;
123- $headers .= "To: {$toHeader}\r\n";
124-
125- if ($replyTo) {
126- $replyToHeader = $replyToName
127- ? mb_encode_mimeheader($replyToName, 'UTF-8', 'Q')." <{$replyTo}>" : $replyTo;
128- $headers .= "Reply-To: {$replyToHeader}\r\n";
129- }
130-
131- $headers .= "MIME-Version: 1.0\r\n";
132-
133- if ($pgpSign) {
134- $boundary = uniqid('BOUNDARY_');
135- $headers .= "Content-Type: multipart/signed;\r\n";
136- $headers .= " protocol=\"application/pgp-signature\";\r\n";
137- $headers .= " micalg=php-sha512;\r\n";
138- $headers .= " boundary=\"{$boundary}\"\r\n";
139- } else {
140- $headers .= "Content-Type: text/plain; charset=utf-8\r\n";
141- $headers .= "Content-Transfer-Encoding: quoted-printable\r\n";
142- }
143-
144- $headers .= "X-Mailer: 076 Little Beast\r\n";
145-
146- $encBody = quoted_printable_encode($body);
147- $data = $headers."\r\n".$encBody;
148-
149- if ($pgpSign) {
150- $signature = $this->signMessage($data);
151- $data = "--{$boundary}\r\n"
152- .$data."\r\n"
153- ."Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n"
154- ."Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n"
155- .$signature."\r\n"
156- ."--{$boundary}--\r\n";
157- }
158-
159- $data .= "\r\n.\r\n";
160- fwrite($this->socket, $data);
161-
162- $response = $this->readResponse();
163- if (substr($response, 0, 3) != '250') {
164- $msg = "メール送信に失敗: {$response}";
165- logger(\LogType::Mailer, $msg);
166- throw new \Exception($msg);
167- }
168- }
169-
170- /**
171- * お好みのタイムゾーンを設定する。
172- * 設定しない場合、php.iniで設定されたタイムゾーンがデフォルトになる。
173- * それも設定されていない場合、GMTタイムゾーンがデフォルトになる。
174- *
175- * @param string $zone IANAタイムゾーンデータベース標準のタイムゾーン(例:Asia/Tokyo)
176- * @return void
177- */
178- public function setTimezone(string $zone): void {
179- date_default_timezone_set($zone);
180- }
181-
182- /**
183- * PGPキーとオプションでパスフレーズを設定する。
184- *
185- * @param string $keypath PGP署名に使用する秘密鍵へのパス。
186- * @param string|null $passphrase 設定されている場合、署名用のパスフレーズ。
187- *
188- * @return void
189- */
190- public function enablePGP(string $keypath, ?string $passphrase = null): void {
191- $this->pgpKey = file_get_contents($keypath);
192- $this->pgpPass = $passphrase;
193- }
194-
195- // 機能性メソッド
196-
197- private function sendCommand(string $command, int $retcode): string {
198- fwrite($this->socket, $command."\r\n");
199- $res = $this->readResponse();
200- if (substr($res, 0, 3) != $retcode) {
201- $msg = "「{$command}」に対する予期しないレスポンス: {$res}";
202- logger(\LogType::Mailer, $msg);
203- throw new \Exception($msg);
204- }
205-
206- return $res;
207- }
208-
209- private function readResponse(): string {
210- $res = '';
211-
212- while ($line = fgets($this->socket, 515)) {
213- $res .= $line;
214- if (substr($line, 3, 1) == ' ') break; // レスポンスの終了だ
215- }
216-
217- return $res;
218- }
219-
220- private function signMessage(string $message): string {
221- if (extension_loaded('gnupg')) {// gnupg延長は有効の場合
222- $gpg = new \gnupg();
223- $gpg->addsignkey($this->pgpKey, $this->pgpPass);
224- $gpg->setsignmode(\gnupg::SIG_MODE_DETACH);
225- return $gpg->sign($message);
226- } else { // なければ、CLIツールを使う(gnupgをインストールは必須)
227- $tmp = tempnam(sys_get_temp_dir(), 'pgpmsg');
228- file_put_contents($tmp, $message);
229- $sig = shell_exec(
230- "gpg --batch "
231- .($this->pgpPass ? "--passphrase {$this->pgpPass} " : '')
232- ."--detach-sign --armor {$tmp} 2>&1"
233- );
234- unlink($tmp);
235- return $sig;
236- }
237- }
238-}
削除 src/Site/Lib/Markdown.php
@@ -1,648 +0,0 @@
1-<?php
2-namespace Site\Lib;
3-
4-class Markdown {
5- private string $content;
6- private array $html;
7- private string $path;
8- private bool $inCodeBlock = false;
9- private string $codeBlockLanguage = '';
10- private array $codeBlockContent = [];
11- private const METADATA_LINE = "----";
12- private array $algebraicPlaceholder = [];
13-
14- public function __construct(string $path, string $section, ?string $lang = null) {
15- $this->html = [];
16- if ($lang) $this->path = ROOT.$section.$lang.'/'.$path.'.md';
17- else $this->path = ROOT.$section.$path.'.md';
18-
19- if (!file_exists($this->path)) {
20- header('Location: /404');
21- exit();
22- }
23- }
24-
25- /**
26- * メタデータを取得する
27- *
28- * @return \stdClass メタデータオブジェクト
29- */
30- public function getMetadata(): \stdClass {
31- $content = file_get_contents($this->path);
32- $metadata = new \stdClass();
33-
34- $parts = explode(self::METADATA_LINE, $content, 2);
35- if (count($parts) < 2) return $metadata;
36-
37- $lines = explode("\n", trim($parts[0]));
38- foreach ($lines as $line) {
39- $line = trim($line);
40- if (empty($line)) continue;
41-
42- $colonPos = strpos($line, ':');
43- if ($colonPos === false) continue;
44-
45- $key = trim(substr($line, 0, $colonPos));
46- $value = trim(substr($line, $colonPos + 1));
47- $value = trim($value, '"\'');
48-
49- if ($key == 'category' || $key == 'css') {
50- $cat = explode(',', $value);
51- $value = $cat;
52- }
53-
54- $metadata->$key = $value;
55- }
56-
57- return $metadata;
58- }
59-
60- /**
61- * Markdownをパースする
62- *
63- * @return string HTMLとしてレンダリングされたコンテンツ
64- */
65- public function parse(): string {
66- $content = file_get_contents($this->path);
67- $parts = explode(self::METADATA_LINE, $content, 2);
68- $this->content = count($parts) > 1 ? trim($parts[1]): trim($content);
69- $this->html = [];
70-
71- $lines = explode("\n", $this->content);
72- $currentParagraph = [];
73-
74- $inList = false;
75- $listItems = [];
76- $listLevel = 0;
77- $inBlockquote = false;
78- $blockquoteContent = [];
79- $tableHeaders = [];
80- $tableRows = [];
81- $inTable = false;
82- $i = 0;
83- $headerCount = 1;
84-
85- foreach ($lines as $line) {
86- $i++;
87- $hasBR = substr($line, -1) === '\\';
88- $line = rtrim($line, " \t\r\n\\");
89-
90- // コメント
91- // if (str_starts_with($line, '//')) {
92- // if ($hasBR && !empty($currentParagraph)) {
93- // $currentParagraph[] = '';
94- // }
95- // continue;
96- // }
97-
98- // コードブロック
99- if (preg_match('/^```(\w*)$/', $line, $matches)) {
100- if (!$this->inCodeBlock) {
101- if (!empty($currentParagraph)) {
102- $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
103- $currentParagraph = [];
104- }
105- $this->inCodeBlock = true;
106- $this->codeBlockLanguage = $matches[1];
107- continue;
108- } else {
109- $this->html[] = $this->createCodeBlock();
110- $this->inCodeBlock = false;
111- $this->codeBlockLanguage = '';
112- $this->codeBlockContent = [];
113- continue;
114- }
115- }
116-
117- if ($this->inCodeBlock) {
118- $this->codeBlockContent[] = $line;
119- continue;
120- }
121-
122- // テーブルの処理
123- if (preg_match('/^\|(.+)\|$/', $line)) {
124- if (!empty($currentParagraph)) {
125- $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
126- $currentParagraph = [];
127- }
128- $cells = array_map('trim', explode('|', trim($line, '|')));
129-
130- if (!$inTable) {
131- $tableHeaders = $cells;
132- $inTable = true;
133- } elseif (preg_match('/^\|(\s*:?-+:?\s*\|)+$/', $line)) {
134- continue;
135- } else {
136- $tableRows[] = $cells;
137- }
138- continue;
139- } elseif ($inTable) {
140- $this->html[] = $this->createTable($tableHeaders, $tableRows);
141- $tableHeaders = [];
142- $tableRows = [];
143- $inTable = false;
144- }
145-
146- // 水平線の処理
147- if (preg_match('/^([\-\*\_])\1{2,}$/', $line)) {
148- if (!empty($currentParagraph)) {
149- $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
150- $currentParagraph = [];
151- }
152- $this->html[] = " <hr />\n";
153- continue;
154- }
155-
156- // 引用ブロックの処理
157- if (preg_match('/^>\s(.+)/', $line, $matches)) {
158- if (!empty($currentParagraph)) {
159- $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
160- $currentParagraph = [];
161- }
162- $inBlockquote = true;
163- $blockquoteContent[] = $this->parseInline($matches[1]);
164- continue;
165- } elseif ($inBlockquote && empty($line)) {
166- $this->html[] = $this->createBlockquote($blockquoteContent);
167- $blockquoteContent = [];
168- $inBlockquote = false;
169- continue;
170- }
171-
172- // 空行をスキップ
173- if (empty($line)) {
174- if ($inList) {
175- $this->html[] = $this->createList($listItems);
176- $listItems = [];
177- $inList = false;
178- }
179- if (!empty($currentParagraph)) {
180- $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
181- $currentParagraph = [];
182- }
183-
184- continue;
185- }
186-
187- // ヘッダー
188- if (preg_match('/^(#{1,6})\s(.+)/', $line, $m)) {
189- if ($inList) {
190- $this->html[] = $this->createList($listItems);
191- $listItems = [];
192- $inList = false;
193- }
194- if (!empty($currentParagraph)) {
195- $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
196- $currentParagraph = [];
197- }
198-
199- $level = strlen($m[1]);
200- $this->html[] = " <h{$level} id=\"section-{$headerCount}\">".$this->parseInline($m[2])."</h{$level}>";
201- $headerCount++;
202- continue;
203- }
204-
205- // 箇条書きリスト
206- if (preg_match('/^(\s*)([\*\-])\s(.+)/', $line, $m)) {
207- if (!empty($currentParagraph)) {
208- $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
209- $currentParagraph = [];
210- }
211- $inList = true;
212- $currentLevel = strlen($m[1]) / 2;
213- $listLevel = max($listLevel, $currentLevel);
214- $listItems[] = [
215- 'content' => $this->parseInline($m[3]),
216- 'level' => $currentLevel,
217- 'type' => 'ul',
218- ];
219-
220- continue;
221- }
222-
223- // 番号付きリスト
224- if (preg_match('/^(\s*)\d+\.\s(.+)/', $line, $m)) {
225- if (!empty($currentParagraph)) {
226- $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
227- $currentParagraph = [];
228- }
229- $inList = true;
230- $currentLevel = strlen($m[1]) / 2;
231- $listLevel = max($listLevel, $currentLevel);
232- $listItems[] = [
233- 'content' => $this->parseInline($m[2]),
234- 'level' => $currentLevel,
235- 'type' => 'ol',
236- ];
237-
238- continue;
239- }
240-
241- if ($inList) {
242- $this->html[] = $this->createList($listItems);
243- $listItems = [];
244- $inList = false;
245- $listLevel = 0;
246- }
247-
248- $parsedLine = $this->parseInline($line);
249- $currentParagraph[] = $parsedLine;
250- if ($hasBR) {
251- $currentParagraph[] = "<br />\n ";
252- }
253- }
254-
255- if ($inList) $this->html[] = $this->createList($listItems);
256- if ($inBlockquote) $this->html[] = $this->createBlockquote($blockquoteContent);
257- if ($inTable) $this->html[] = $this->createTable($tableHeaders, $tableRows);
258- if (!empty($currentParagraph))
259- $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
260-
261- return implode("\n", $this->html);
262- }
263-
264- // 機能性メソッド
265-
266- /**
267- * インラインのMarkdown記法をパースする
268- *
269- * @param string $text パースするテキスト
270- * @return string HTMLとしてレンダリングされたテキスト
271- */
272- private function parseInline(string $text): string {
273- $this->algebraicPlaceholder = [];
274- $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
275-
276- $patterns = [
277- // 数式
278- '/\$\$([^$]+)\$\$/u' => function($matches): string {
279- $placeholder = "{{ALG".count($this->algebraicPlaceholder).'ALG}}';
280- $this->algebraicPlaceholder[$placeholder] = $matches[1];
281- return $placeholder;
282- },
283- // 太字
284- '/\*\*(.+?)\*\*/u' => '<strong>$1</strong>',
285- // 斜体
286- '/\*(.+?)\*/u' => '<em>$1</em>',
287- // 下線
288- '/\_(.+?)\_/u' => '<u>$1</u>',
289- // 取り消し線
290- '/\~(.+?)\~/u' => '<s>$1</s>',
291- // Blink (with speed)
292- '/!:\((.+?)\)(.+?):!/u' => '<span class="blink" style="animation-duration: $1s;">$2</span>',
293- // Blink
294- '/!:(.+?):!/u' => '<span class="blink">$1</span>',
295- // フォントの大きさ
296- '/\^\((.+?)\)(.+?)\^/u' => '<span style="font-size: $1px;">$2</span>',
297- // フォントカラー
298- '/\%\((.+?)\)(.+?)\%/u' => '<span style="color: #$1;">$2</span>',
299- // 画像
300- '/\!\[(.*?)(?:#([^\]]*))?\]\((.+?)\)/u' => '<img style="width: 100%; $2" src="$3" alt="$1" />',
301- // 音楽
302- '/\$\[([^\]]+)\]\(([^\)]+)\)/u' => '<audio controls><source src="$2" type="$1" /></audio>',
303- // 動画
304- '/\#\[([^\]]+)\]\(([^\)]+)\)/u' => '<video style="max-width: 100%;" controls><source src="$2" type="$1" /></video>',
305- // リンク
306- '/\[(.+?)\]\((.+?)\)/u' => '<a href="$2">$1</a>',
307- // 振り仮名
308- '/\&lt;(.+?)\&gt;\((.+?)\)/u' => '<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>',
309- // インラインコード
310- '/`(.+?)`/u' => '<code>$1</code>',
311- ];
312-
313- foreach ($patterns as $pattern => $replacement) {
314- if (is_callable($replacement)) {
315- $text = preg_replace_callback($pattern, $replacement, $text);
316- } else {
317- $result = preg_replace($pattern, $replacement, $text);
318- if ($result === null) continue;
319- $text = $result;
320- }
321- }
322-
323- // プレースホルダーの数式に交換
324- foreach ($this->algebraicPlaceholder as $placeholder => $expr) {
325- $text = str_replace($placeholder, $this->parseAlgebraic($expr), $text);
326- }
327-
328- return $text;
329- }
330-
331- /**
332- * 数式記法をパースする
333- *
334- * @param string $expression パースする数式
335- * @return string MathJax用にフォーマットされた数式
336- */
337- private function parseAlgebraic(string $expression): string {
338- // HTMLエスケープ
339- $expression = htmlspecialchars_decode($expression, ENT_QUOTES);
340-
341- // ふぃりしゃもじのマッピング
342- $greekLetters = [
343- 'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ', 'delta' => 'δ',
344- 'epsilon' => 'ε', 'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ',
345- 'iota' => 'ι', 'kappa' => 'κ', 'lambda' => 'λ', 'mu' => 'μ',
346- 'nu' => 'ν', 'xi' => 'ξ', 'omicron' => 'ο', 'pi' => 'π',
347- 'rho' => 'ρ', 'sigma' => 'σ', 'tau' => 'τ', 'upsilon' => 'υ',
348- 'phi' => 'φ', 'chi' => 'χ', 'psi' => 'ψ', 'omega' => 'ω',
349- 'Alpha' => 'Α', 'Beta' => 'Β', 'Gamma' => 'Γ', 'Delta' => 'Δ',
350- 'Epsilon' => 'Ε', 'Zeta' => 'Ζ', 'Eta' => 'Η', 'Theta' => 'Θ',
351- 'Iota' => 'Ι', 'Kappa' => 'Κ', 'Lambda' => 'Λ', 'Mu' => 'Μ',
352- 'Nu' => 'Ν', 'Xi' => 'Ξ', 'Omicron' => 'Ο', 'Pi' => 'Π',
353- 'Rho' => 'Ρ', 'Sigma' => 'Σ', 'Tau' => 'Τ', 'Upsilon' => 'Υ',
354- 'Phi' => 'Φ', 'Chi' => 'Χ', 'Psi' => 'Ψ', 'Omega' => 'Ω',
355- ];
356-
357- // ギリシャ文字を置換
358- foreach ($greekLetters as $text => $symbol) {
359- $expression = preg_replace("/\b$text\b/", $symbol, $expression);
360- }
361-
362- // 上付き文字(例: x^2)
363- $expression = preg_replace_callback('/(\w+)\^(\{?(\d+|\w+)\}?)/', function($matches) {
364- $base = $matches[1];
365- $exponent = $matches[2];
366- if ($exponent[0] === '{') $exponent = substr($exponent, 1, -1);
367-
368- $superscripts = str_split($exponent);
369- $supMap = [
370- '0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴',
371- '5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹',
372- 'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ',
373- 'i' => 'ⁱ', 'n' => 'ⁿ',
374- ];
375- $converted = '';
376-
377- foreach ($superscripts as $char) $converted .= $supMap[$char] ?? $char;
378-
379- return $base.'<sup>'.$converted.'</sup>';
380- }, $expression);
381-
382- // 下付き文字(例: x_1)
383- $expression = preg_replace_callback('/(\w+)_((\d+|\w+)\}?)/', function($matches) {
384- $base = $matches[1];
385- $subscript = $matches[2];
386-
387- if ($subscript[0] === '{') $subscript = substr($subscript, 1, -1);
388-
389- $subscripts = str_split($subscript);
390- $subMap = [
391- '0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄',
392- '5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉',
393- ];
394- $converted = '';
395-
396- foreach ($subscripts as $char) {
397- $converted .= $subMap[$char] ?? $char;
398- }
399-
400- return $base.'<sub>'.$converted.'</sub>';
401- }, $expression);
402-
403- // 分数(例: 4/5x, a/b)
404- $expression = preg_replace('/(\d+|\w+)\/(\d*\w+)/',
405- '<span class="fraction"><span class="numerator">$1</span><span class="denominator">$2</span></span>',
406- $expression);
407-
408- // 演算子
409- $operators = [
410- '\*' => '・',
411- '!=' => '≠',
412- '>=' => '≥',
413- '<=' => '≤',
414- 'sqrt' => '√',
415- ];
416-
417- foreach ($operators as $op => $symbol) $expression = preg_replace("/\b$op\b/", $symbol, $expression);
418-
419- // 数式全体をspanで囲む
420- return '<span class="algebraic">'.$expression.'</span>';
421- }
422-
423- /**
424- * リストを作成する
425- *
426- * @param array $items リストアイテムの配列
427- * @param int $maxLevel 最大ネストレベル
428- * @return string HTMLのリスト
429- */
430- private function createList(array $items, int $maxLevel = 1): string {
431- if ($items === []) return '';
432-
433- $html = '';
434- $currentLevel = 0;
435- $listStack = [];
436- $currentType = '';
437-
438- foreach ($items as $item) {
439- $level = isset($item['level']) ? $item['level'] : $currentLevel;
440-
441- while ($currentLevel > $level)
442- $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
443-
444- while ($currentLevel < $level) {
445- $currentLevel++;
446- $listStack[] = $item['type'];
447- $html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
448- }
449-
450- if ($currentType != $item['type'] && $currentLevel == $item['level']) {
451- if (!empty($listStack)) {
452- $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
453- $listStack[] = $item['type'];
454- $html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
455- }
456- }
457-
458- $currentType = $item['type'];
459- }
460-
461- $html .= " <p>\n <{$currentType}>\n";
462- foreach ($items as $item) {
463- $html .= str_repeat(' ', $currentLevel)." <li>".$item['content']."</li>\n";
464- }
465- $html .= " </{$currentType}>\n </p>";
466-
467- while (!empty($listStack)) {
468- $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
469- $currentLevel--;
470- }
471-
472- return rtrim($html);
473- }
474-
475- /**
476- * コードブロックを作成する
477- *
478- * @return string HTMLのコードブロック
479- */
480- private function createCodeBlock(): string {
481- $code = htmlspecialchars(implode("\n", $this->codeBlockContent));
482- $class = $this->codeBlockLanguage ? " class=\"language-{$this->codeBlockLanguage}\"" : '';
483- return " <p>\n <pre><code{$class}>{$code}</code></pre>";
484- // $raw = implode("\n", $this->codeBlockContent);
485- // $lang = $this->codeBlockLanguage ?: 'txt';
486- // $class = $lang === 'txt' ? '' : " class=\"language-{$lang}\"";
487- // $highlighted = $this->highlightCode($raw, $lang);
488- // return "<pre><code{$class}>{$highlighted}</code></pre>";
489- }
490-
491- private function createBlockquote(array $content): string {
492- return " <p>\n <blockquote>\n ".implode("<br />\n ", $content)."\n </blockquote>\n </p>";
493- }
494-
495- /**
496- * テーブルを作成する
497- *
498- * @param array $headers ヘッダー配列
499- * @param array $rows 行データの配列
500- * @return string HTMLのテーブル
501- */
502- private function createTable(array $headers, array $rows): string {
503- $html = " <p>\n <table>\n";
504-
505- // ヘッダーを追加
506- if (!empty($headers)) {
507- $html .= " <thead>\n <tr>\n";
508- foreach ($headers as $header) {
509- $html .= " <th>".$this->parseInline($header)."</th>\n";
510- }
511- $html .= " </tr>\n </thead>\n";
512- }
513-
514- // 行を追加
515- if (!empty($rows)) {
516- $html .= " <tbody>\n";
517- foreach ($rows as $row) {
518- $html .= " <tr>\n";
519- foreach ($row as $cell) {
520- $html .= " <td>".$this->parseInline($cell)."</td>\n";
521- }
522- $html .= " </tr>\n";
523- }
524- $html .= " </tbody>\n";
525- }
526-
527- $html .= " </table>\n </p>";
528- return $html;
529- }
530-
531- /**
532- * Pure-PHP syntax highlighter
533- *
534- * @param string $code Raw source code
535- * @param string $lang Language identifier (html, css, js, php, …)
536- * @return string HTML with <span class="hl-…"> tokens
537- */
538- private function highlightCode(string $code, string $lang): string
539- {
540- $lang = strtolower(trim($lang));
541- $rules = [];
542-
543- // ---- COMMON -------------------------------------------------------
544- $rules['comment'] = [
545- // /* … */ (multi-line)
546- ['/\/\*(?:.|\n)*?\*\//', 'hl-comment'],
547- // // …\n
548- ['/\/\/.*(?=\n|$)/', 'hl-comment'],
549- ];
550-
551- $rules['string'] = [
552- // "…" and '…' (allow escaped quotes)
553- ['/"(?:\\.|[^"\\\n])*"/', 'hl-string'],
554- ["/'(?:\\.|[^'\\\n])*'/", 'hl-string'],
555- ];
556-
557- $rules['number'] = [
558- ['/\b(?:0x[0-9a-fA-F]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/', 'hl-number'],
559- ];
560-
561- // ---- HTML ---------------------------------------------------------
562- if ($lang === 'html') {
563- $rules['tag'] = [['/&lt;\/?[a-zA-Z0-9\-]+/', 'hl-tag']];
564- $rules['attr'] = [['/\s[a-zA-Z0-9\-]+(?==)/', 'hl-attr']];
565- $rules['value'] = [['/(?<==)"(?:\\.|[^"\\\n])*"(?=[>\s])/', 'hl-value']];
566- }
567-
568- // ---- CSS ----------------------------------------------------------
569- if ($lang === 'css') {
570- $rules['selector'] = [['/^[\t ]*[a-zA-Z0-9#\.\-\:$$ $$\=\*\>\+\~\^]+(?=\s*\{)/m', 'hl-selector']];
571- $rules['property'] = [['/\b[a-z\-]+(?=\s*:)/', 'hl-property']];
572- $rules['value'] = [['/(?<=\:)\s*[^;]+(?=;)/', 'hl-value']];
573- $rules['unit'] = [['/\b\d+(px|em|rem|%|vh|vw|pt|pc|cm|mm|in)\b/i', 'hl-unit']];
574- }
575-
576- // ---- PHP ----------------------------------------------------------
577- if ($lang === 'php') {
578- $keywords = 'abstract|and|array|as|break|callable|case|catch|class|clone|'
579- . 'const|continue|declare|default|die|do|echo|else|elseif|empty|'
580- . 'enddeclare|endfor|endforeach|endif|endswitch|endwhile|eval|exit|'
581- . 'extends|final|finally|for|foreach|function|global|goto|if|'
582- . 'implements|include|include_once|instanceof|insteadof|interface|'
583- . 'isset|list|namespace|new|or|print|private|protected|public|'
584- . 'require|require_once|return|static|switch|throw|trait|try|unset|'
585- . 'use|var|while|xor|yield';
586- $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
587- $rules['function']= [['/\b([a-zA-Z_\x7f-\xff][\w\x7f-\xff]*)(?=\s*\()/', 'hl-function']];
588- $rules['variable']= [['/\$[a-zA-Z_\x7f-\xff][\w\x7f-\xff]*/', 'hl-variable']];
589- }
590-
591- // ---- Shell --------------------------------------------------------
592- if ($lang === 'sh') {
593- $keywords = 'if|then|else|elif|fi|case|esac|for|select|while|until|do|done|in';
594- $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
595- $rules['variable']= [['/\$\{?[a-zA-Z_][\w]*\}?/', 'hl-variable']];
596- }
597-
598- // ---- JSON ---------------------------------------------------------
599- if ($lang === 'json') {
600- $rules['property'] = [['/"([^"]+)":/', '<span class="hl-property">$1</span>:']];
601- }
602-
603- // ---- C / C++ -----------------------------------------------
604- if (in_array($lang, ['c', 'cpp', 'cs'])) {
605- $keywords = 'abstract|assert|boolean|break|byte|case|catch|char|class|const|'
606- . 'continue|default|do|double|else|enum|extends|false|final|finally|'
607- . 'float|for|goto|if|implements|import|instanceof|int|interface|long|'
608- . 'native|new|null|package|private|protected|public|return|short|static|'
609- . 'strictfp|super|switch|synchronized|this|throw|throws|transient|true|'
610- . 'try|void|volatile|while';
611- $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
612- $rules['type'] = [['/\b(?:int|float|double|char|bool|void|string)\b/', 'hl-type']];
613- $rules['define'] = [['/(#include)|(#define)/', 'hl-define']];
614- }
615-
616- if ($lang === 'markdown') {
617- $rules['header'] = [['/^(#{1,6})\s+(.+)$/', '<span class="hl-header">$1 $2</span>']];
618- $rules['link'] = [['/\[([^\]]+)\]\(([^)]+) $$/', '<span class="hl-link">[$1]($2)</span>']];
619- }
620-
621- $html = htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8');
622-
623- $order = ['comment', 'string', 'number'];
624- foreach (['tag','attr','value','selector','property','unit','keyword','function','variable','type','header','link'] as $k) {
625- if (isset($rules[$k])) $order[] = $k;
626- }
627-
628- foreach ($order as $type) {
629- if (!isset($rules[$type])) continue;
630- foreach ($rules[$type] as $rule) {
631- [$pattern, $class] = $rule;
632- $html = preg_replace_callback(
633- $pattern,
634- function ($m) use ($class) {
635- // For JSON property we already built the <span>
636- if (strpos($m[0], '<span') === 0) return $m[0];
637- $inner = $m[0];
638- // Preserve already escaped entities inside strings/comments
639- return '<span class="'.$class.'">'.$inner.'</span>';
640- },
641- $html
642- );
643- }
644- }
645-
646- return $html;
647- }
648-}
削除 src/Site/Lib/Route.php
@@ -1,163 +0,0 @@
1-<?php
2-namespace Site\Lib;
3-
4-class Route {
5- protected static array $routes = [];
6- protected static array $fallback = [];
7-
8- /**
9- * ルート設定を言語固有のハンドラで初期化する
10- *
11- * @param array $routes ルート設定の配列
12- * @return void
13- */
14- public static function init(array $routes): void {
15- self::$routes = $routes;
16- }
17-
18- /**
19- * ルートを追加する
20- *
21- * @param string $method HTTPメソッド
22- * @param string $path URLパス
23- * @param string|callable $class ハンドラクラスとメソッド、またはコールバック
24- * @param array $params オプションのパラメータ
25- * @return array ルート設定
26- */
27- public static function add(string $method, string $path, string|callable $class,
28- array $params = []): array {
29- $route = [
30- 'method' => $method,
31- 'path' => $path,
32- 'class' => $class,
33- 'params' => $params,
34- ];
35-
36- self::$routes[] = $route;
37- return $route;
38- }
39-
40- /**
41- * 404処理用のフォールバックルートを設定する
42- *
43- * @param array|string|callable $class
44- * @return void
45- */
46- public static function setFallback(array|string|callable $class): void {
47- self::$fallback = [
48- 'class' => $class,
49- 'params' => [],
50- ];
51- }
52-
53- /**
54- * 適切なルートをマッチさせて実行する
55- *
56- * @param string $uri リクエストURI
57- * @return void
58- */
59- public static function dispatch(string $uri): void {
60- // URIをパスとクエリ文字列に分割
61- $uriParts = explode('?', $uri, 2);
62- $path = trim($uriParts[0], " \t\n\r\0\x0B/");
63-
64- // ルートパスの処理(/?page=2のようなクエリパラメータを含む場合も処理)
65- if ($path === '') {
66- self::executeClass([
67- 'class' => [new \Site\Controller\Home(), 'show'],
68- 'params' => ['lang' => 'ja'],
69- ]);
70- return;
71- }
72-
73- if ($path === 'en') {
74- self::executeClass([
75- 'class' => [new \Site\Controller\Home(), 'show'],
76- 'params' => ['lang' => 'en'],
77- ]);
78- return;
79- }
80-
81- // パスに対してルートをマッチングする
82- foreach (self::$routes as $route) {
83- $matches = [];
84-
85- if (self::matchRoute($route['path'], $path, $matches)) {
86- $params = self::extractParams($route['path'], $path);
87- $params = array_merge($route['params'], $params);
88-
89- if (is_string($route['class'])) {
90- [ $class, $method ] = explode('@', $route['class']);
91- $controller = new $class();
92- self::executeClass([
93- 'class' => [ $controller, $method ],
94- 'params' => $params,
95- ]);
96-
97- return;
98- } elseif (is_callable($route['class'])) {
99- self::executeClass([
100- 'class' => $route['class'],
101- 'params' => $params,
102- ]);
103-
104- return;
105- }
106- }
107- }
108-
109- // マッチするルートがない場合、フォールバックを実行
110- self::executeClass(self::$fallback);
111- }
112-
113- /**
114- * ルートパターンとパスをマッチングする
115- *
116- * @param string $pattern ルートパターン
117- * @param string $path 現在のパス
118- * @param array $matches マッチを格納する参照
119- * @return bool
120- */
121- protected static function matchRoute(string $pattern, string $path,
122- array &$matches = []): bool {
123- // ルートパターンを正規表現パターンに変換
124- $pattern = preg_replace('/\{([^:}]+)(?::([^}]+))?\}/', '(?P<$1>[^/]+)', $pattern);
125- $pattern = str_replace('/', '\/', $pattern);
126- return (bool)preg_match('/^'.$pattern.'$/', $path, $matches);
127- }
128-
129- /**
130- * パターンに基づいてパスから名前付きパラメータを抽出する
131- *
132- * @param string $pattern ルートパターン
133- * @param string $path 現在のパス
134- * @return array
135- */
136- protected static function extractParams(string $pattern, string $path): array {
137- $params = [];
138- $patternParts = explode('/', $pattern);
139- $pathParts = explode('/', $path);
140-
141- foreach ($patternParts as $k => $v) {
142- if (preg_match('/\{([^:}]+)(?::([^}]+))?\}/', $v, $matches)) {
143- if (isset($pathParts[$k])) {
144- $params[$matches[1]] = $pathParts[$k];
145- }
146- }
147- }
148-
149- return $params;
150- }
151-
152- /**
153- * ルートクラスを実行する
154- *
155- * @param array $route ルート設定
156- * @return void
157- */
158- protected static function executeClass(array $route): void {
159- if (is_callable($route['class'])) {
160- call_user_func($route['class'], $route['params'] ?? []);
161- }
162- }
163-}
削除 src/Site/Lib/Template.php
@@ -1,243 +0,0 @@
1-<?php
2-namespace Site\Lib;
3-
4-class Template {
5- private string $tmplExt = '.maron';
6- private string $tmplPath;
7- private array $vars = [];
8- private array $blocks = [];
9- private array $custFunc = [];
10- private array $custCss = [];
11-
12- /**
13- * テンプレートクラスのコンストラクタ
14- *
15- * @param string $tmplPath テンプレートのパス
16- * @return void
17- */
18- public function __construct(string $tmplPath) {
19- $this->tmplPath = rtrim($tmplPath, '/');
20-
21- if (substr($this->tmplPath, 0, 1) !== '/') {
22- $this->tmplPath = '/'.$this->tmplPath;
23- }
24- }
25-
26- /**
27- * テンプレート変数に値を割り当てる
28- *
29- * @param string $name 変数名
30- * @param mixed $value 値
31- * @return void
32- */
33- public function assign(string $name, mixed $value): void {
34- $this->vars[$name] = $value;
35- }
36-
37- /**
38- * カスタムCSSファイルを追加する
39- *
40- * @param string $name CSSファイル名
41- * @return void
42- */
43- public function addCss(string $name): void {
44- $this->custCss[] =
45- "<link rel=\"stylesheet\" type=\"text/css\" href=\"/static/style-{$name}.css\" />\n";
46- $this->assign('custCss', $this->custCss);
47- }
48-
49- /**
50- * カスタム関数を登録する
51- *
52- * @param string $name 関数名
53- * @param callable $callback コールバック関数
54- * @return void
55- */
56- public function registerFunction(string $name, callable $callback): void {
57- $this->custFunc[$name] = $callback;
58- }
59-
60- /**
61- * テンプレートブロックを定義する
62- *
63- * @param string $name ブロック名
64- * @param string $content ブロック内容
65- * @return void
66- */
67- public function defineBlock(string $name, string $content): void {
68- if (!isset($this->blocks[$name]))
69- $this->blocks[$name] = $content;
70- }
71-
72- /**
73- * テンプレートをレンダリングする
74- *
75- * @param string $tmplName テンプレート名
76- * @return void
77- */
78- public function render(string $tmplName): void {
79- $tmplPath = ROOT.'/view'.$this->tmplPath.'/'.$tmplName.$this->tmplExt;
80- if (!file_exists($tmplPath))
81- throw new \RuntimeException("テンプレートファイルを見つけません:{$tmplPath}");
82-
83- extract($this->vars);
84-
85- $content = file_get_contents($tmplPath);
86-
87- // インクルードディレクティブを処理
88- while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
89- $content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string {
90- $inclPath = ROOT.'/view/'.trim($m[1], "'\" ").$this->tmplExt;
91- if (!file_exists($inclPath))
92- throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
93- return file_get_contents($inclPath);
94- }, $content);
95- }
96-
97- $content = $this->procDirs($content);
98- $content = $this->procVars($content);
99- $content = $this->procFuncs($content);
100-
101- $tmpFile = tempnam(sys_get_temp_dir(), 'tmpl_');
102- file_put_contents($tmpFile, $content);
103-
104- include $tmpFile;
105- unlink($tmpFile);
106- }
107-
108- // 機能性メソッド
109-
110- /**
111- * テンプレートディレクティブを処理する
112- *
113- * @param string $content テンプレート内容
114- * @return string|null 処理後の内容
115- */
116- private function procDirs(string $content): string|null {
117- // includeディレクティブの処理
118- while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
119- $content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string {
120- $inclPath = ROOT.'/view/'.trim($m[1], "'\" ").'.php';
121- if (!file_exists($inclPath))
122- throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
123- return file_get_contents($inclPath);
124- }, $content);
125- }
126-
127- $content = preg_replace('/\{@\s*if\s*\((.*?)\):\s*@\}/', '{@ if ($1) @}', $content);
128- $content = preg_replace('/\{@\s*endif;\s*@\}/', '{@ endif @}', $content);
129-
130- $processDirectives = function($c): array|string|null {
131- // kysディレクティブの処理
132- $c = preg_replace_callback('/\{@\s*kys\((.*?)\)\s*@\}/s', function($m): string {
133- return "<?php echo '<pre>'; print_r({$m[1]}); echo '</pre>'; die(); ?>";
134- }, $c);
135-
136- // foreachループとネストした内容の処理
137- $c = preg_replace_callback('/\{@\s*foreach\s*\((.*?)\)\s*@\}/s', function($m): string {
138- return "<?php foreach({$m[1]}): ?>";
139- }, $c);
140-
141- $c = preg_replace_callback('/\{@\s*endforeach\s*@\}/s', function($m): string {
142- return "<?php endforeach; ?>";
143- }, $c);
144-
145- // forループの処理
146- $c = preg_replace_callback('/\{@\s*for\s*\((.*?)\)\s*@\}/s', function($m): string {
147- return "<?php for({$m[1]}): ?>";
148- }, $c);
149-
150- $c = preg_replace_callback('/\{@\s*endfor\s*@\}/s', function($m): string {
151- return "<?php endfor; ?>";
152- }, $c);
153-
154- // if-elif-else-endifの処理
155- $c = preg_replace_callback('/\{@\s*if\s*\((.*?)\)\s*@\}/s', function($m): string {
156- return "<?php if ({$m[1]}): ?>";
157- }, $c);
158-
159- $c = preg_replace_callback('/\{@\s*elif\s*\((.*?)\)\s*@\}/s', function($m): string {
160- return "<?php elseif ({$m[1]}): ?>";
161- }, $c);
162-
163- $c = preg_replace_callback('/\{@\s*else\s*@\}/s', function($m): string {
164- return "<?php else: ?>";
165- }, $c);
166-
167- $c = preg_replace_callback('/\{@\s*endif\s*@\}/s', function($m): string {
168- return "<?php endif; ?>";
169- }, $c);
170-
171- return $c;
172- };
173-
174- $previousContent = '';
175- $maxIterations = 10;
176- $iterations = 0;
177-
178- while ($previousContent !== $content && $iterations < $maxIterations) {
179- $previousContent = $content;
180- $content = $processDirectives($content);
181- $iterations++;
182- }
183-
184- return $content;
185- }
186-
187- /**
188- * テンプレート変数を処理する
189- *
190- * @param string $content テンプレート内容
191- * @return string 処理後の内容
192- */
193- private function procVars(string $content): string {
194- // 変数の出力(エスケープ処理なし)
195- $content = preg_replace_callback('/\{\{\{s*(.*?)\s*\}\}\}/', function($m): string {
196- return '<?= '.trim($m[1]).' ?>';
197- }, $content);
198-
199- // 変数の出力(エスケープ処理あり)
200- $content = preg_replace_callback('/\{\{\s*(.*?)\s*\}\}/', function($m): string {
201- return '<?= htmlspecialchars('.trim($m[1]).', ENT_QUOTES, \'UTF-8\') ?>';
202- }, $content);
203-
204- // 変数の代入
205- $content = preg_replace_callback('/\{\$\s*(.*?)\s*\$\}/', function($m): string {
206- $parts = explode('=', $m[1], 2);
207- if (count($parts) !== 2)
208- throw new \RuntimeException("不正な値の形式");
209- return '<?php '.trim($parts[0]).' = '.trim($parts[1]).'; ?>';
210- }, $content);
211-
212- // コメント
213- $content = preg_replace_callback('/\{#\s*(.*?)\s*#\}/', function($m): string {
214- return '<?php /*'.trim($m[1]).'*/ ?>';
215- }, $content);
216-
217- // PHPコードの実行
218- $content = preg_replace_callback('/\{\!\s*(.*?)\s*\!\}/', function($m): string {
219- return '<?php '.trim($m[1]).' ?>';
220- }, $content);
221-
222- return $content;
223- }
224-
225- /**
226- * カスタム関数を処理する
227- *
228- * @param string $content テンプレート内容
229- * @return string 処理後の内容
230- */
231- private function procFuncs(string $content): string {
232- foreach ($this->custFunc as $name => $cb) {
233- $pattern = "/\{@\s*{$name}\((.*?)\)\s*@\}/";
234- $content = preg_replace_callback($pattern, function($m) use ($cb): mixed {
235- $args = explode(',', $m[1]);
236- $args = array_map('trim', $args);
237- return call_user_func_array($cb, $args);
238- }, $content);
239- }
240-
241- return $content;
242- }
243-}
削除 src/Site/Lib/Tester.php
@@ -1,617 +0,0 @@
1-<?php
2-namespace Site\Lib;
3-
4-/**
5- * アサーション失敗用のカスタム例外
6- */
7-class AssertionFailedException extends \Exception {
8-}
9-
10-/**
11- * 軽量なユニットテストフレームワーク
12- */
13-class Tester {
14- // テスト統計
15- private int $testCount = 0;
16- private int $passCount = 0;
17- private int $failCount = 0;
18- private int $errorCount = 0;
19-
20- // 現在のテストケース情報
21- private string $currentTestCase = '';
22- private string $currentTest = '';
23-
24- // テストスイート設定
25- private bool $colorOutput = true;
26- private bool $verboseOutput = true;
27- private bool $stopOnFailure = false;
28- private bool $skipCurrentDescribe = false;
29- private array $beforeEachCallbacks = [];
30- private array $afterEachCallbacks = [];
31- private array $beforeAllCallbacks = [];
32- private array $afterAllCallbacks = [];
33-
34- // ターミナル出力用の色
35- private array $colors = [
36- 'reset' => "\033[0m",
37- 'red' => "\033[31m",
38- 'green' => "\033[32m",
39- 'yellow' => "\033[33m",
40- 'blue' => "\033[34m",
41- 'magenta' => "\033[35m",
42- 'cyan' => "\033[36m",
43- 'white' => "\033[37m",
44- 'bold' => "\033[1m",
45- ];
46-
47- private array $failures = [];
48- private array $errors = [];
49-
50- /**
51- * コンストラクタ
52- *
53- * @param array $options 設定オプション
54- */
55- public function __construct(array $options = []) {
56- // オプションを設定
57- if (isset($options['colorOutput'])) {
58- $this->colorOutput = (bool)$options['colorOutput'];
59- }
60-
61- if (isset($options['verboseOutput'])) {
62- $this->verboseOutput = (bool)$options['verboseOutput'];
63- }
64-
65- if (isset($options['stopOnFailure'])) {
66- $this->stopOnFailure = (bool)$options['stopOnFailure'];
67- }
68-
69- // サポートされていない場合は色を無効にする
70- if (PHP_SAPI !== 'cli' || strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' && !getenv('ANSICON')) {
71- $this->colorOutput = false;
72- }
73- }
74-
75- /**
76- * 各テストの前に実行する関数を登録する
77- *
78- * @param callable $callback コールバック関数
79- * @return Tester このインスタンス
80- */
81- public function beforeEach(callable $callback): Tester {
82- $this->beforeEachCallbacks[] = $callback;
83- return $this;
84- }
85-
86- /**
87- * 各テストの後に実行する関数を登録する
88- *
89- * @param callable $callback コールバック関数
90- * @return Tester このインスタンス
91- */
92- public function afterEach(callable $callback): Tester {
93- $this->afterEachCallbacks[] = $callback;
94- return $this;
95- }
96-
97- /**
98- * すべてのテストの前に実行する関数を登録する
99- *
100- * @param callable $callback コールバック関数
101- * @return Tester このインスタンス
102- */
103- public function beforeAll(callable $callback): Tester {
104- $this->beforeAllCallbacks[] = $callback;
105- return $this;
106- }
107-
108- /**
109- * すべてのテストの後に実行する関数を登録する
110- *
111- * @param callable $callback コールバック関数
112- * @return Tester このインスタンス
113- */
114- public function afterAll(callable $callback): Tester {
115- $this->afterAllCallbacks[] = $callback;
116- return $this;
117- }
118-
119- /**
120- * テストケースを定義する
121- *
122- * @param string $description テストケースの説明
123- * @param callable $callback テストケース関数
124- * @return Tester このインスタンス
125- */
126- public function describe(string $description, callable $callback): Tester {
127- $this->currentTestCase = $description;
128- $this->skipCurrentDescribe = false;
129- $this->output($this->colorize('bold', "テストケース: {$description}"));
130-
131- try {
132- foreach ($this->beforeAllCallbacks as $before) {
133- call_user_func($before);
134- }
135-
136- call_user_func($callback, $this);
137-
138- foreach ($this->afterAllCallbacks as $after) {
139- call_user_func($after);
140- }
141- } catch (\Throwable $e) {
142- $this->recordError(
143- "テストケースのセットアップ/ティアダウンでエラー: ".$e->getMessage(),
144- $e->getTraceAsString());
145- }
146-
147- $this->output('');
148- return $this;
149- }
150-
151- /**
152- * 単一のテストを実行する
153- *
154- * @param string $description テストの説明
155- * @param callable $callback テスト関数
156- * @return Tester このインスタンス
157- */
158- public function it(string $description, callable $callback): Tester {
159- if ($this->skipCurrentDescribe) {
160- $this->skip($description, 'skipAll() が呼び出されました');
161- return $this;
162- }
163-
164- $this->currentTest = $description;
165- $this->testCount++;
166-
167- if ($this->verboseOutput) {
168- $this->output(" ⋄ テスト中: {$description}... ", false);
169- }
170-
171- try {
172- foreach ($this->beforeEachCallbacks as $before) {
173- call_user_func($before);
174- }
175-
176- call_user_func($callback, $this);
177-
178- foreach ($this->afterEachCallbacks as $after) {
179- call_user_func($after);
180- }
181-
182- // Test has passed.
183- $this->passCount++;
184-
185- if ($this->verboseOutput) {
186- $this->output($this->colorize('green', "合格"));
187- }
188- } catch (AssertionFailedException $e) {
189- $this->failCount++;
190-
191- if ($this->verboseOutput) {
192- $this->output($this->colorize('red', "失敗"));
193- $this->output($this->colorize('red', " → ".$e->getMessage()));
194- }
195-
196- $this->recordFailure($e->getMessage());
197-
198- if ($this->stopOnFailure) {
199- $this->printSummary();
200- exit(1);
201- }
202- } catch (\Throwable $e) {
203- $this->errorCount++;
204-
205- if ($this->verboseOutput) {
206- $this->output($this->colorize('yellow', "エラー"));
207- $this->output($this->colorize('yellow', " → ".$e->getMessage()));
208- }
209-
210- $this->recordError($e->getMessage(), $e->getTraceAsString());
211-
212- if ($this->stopOnFailure) {
213- $this->printSummary();
214- exit(1);
215- }
216- }
217-
218- return $this;
219- }
220-
221- /**
222- * テストをスキップする
223- *
224- * @param string $description テストの説明
225- * @param string $reason スキップする理由。デフォルト: "まだ実装されていません"
226- * @return Tester このインスタンス
227- */
228- public function skip(string $description,
229- string $reason = 'まだ実装されていません'): Tester {
230- if ($this->verboseOutput) {
231- $this->output(" ⋄ スキップ: {$description}... "
232- .$this->colorize('cyan', "スキップ"));
233- $this->output($this->colorize('cyan', " → {$reason}"));
234- }
235-
236- return $this;
237- }
238-
239- /**
240- * 条件がtrueである事をアサートする
241- *
242- * @param bool $condition チェックする条件
243- * @param string $message 失敗時のオプションメッセージ
244- * @throws AssertionFailedException アサーションが失敗した場合
245- * @return Tester このインスタンス
246- */
247- public function assertTrue(bool $condition,
248- string $message = '条件がtrueであることを期待しました'): Tester {
249- if ($condition !== true) {
250- throw new AssertionFailedException($message);
251- }
252-
253- return $this;
254- }
255-
256- /**
257- * 条件がfalseである事をアサートする
258- *
259- * @param bool $condition チェックする条件
260- * @param string $message 失敗時のオプションメッセージ
261- * @throws AssertionFailedException アサーションが失敗した場合
262- * @return Tester このインスタンス
263- */
264- public function assertFalse(bool $condition,
265- string $message = '条件がfalseであることを期待しました'): Tester {
266- if ($condition !== false) {
267- throw new AssertionFailedException($message);
268- }
269-
270- return $this;
271- }
272-
273- /**
274- * 二つの値が等しい事をアサートする
275- *
276- * @param mixed $expected 期待値
277- * @param mixed $actual 実際の値
278- * @param string|null $message 失敗時のオプションメッセージ
279- * @throws AssertionFailedException アサーションが失敗した場合
280- * @return Tester このインスタンス
281- */
282- public function assertEquals(mixed $expected, mixed $actual,
283- ?string $message = null): Tester {
284- if ($expected != $actual) {
285- if ($message === null) {
286- $expected = $this->exportValue($expected);
287- $actual = $this->exportValue($actual);
288- $message = "{$expected}を期待しましたが、{$actual}が得られました";
289- }
290-
291- throw new AssertionFailedException($message);
292- }
293-
294- return $this;
295- }
296-
297- /**
298- * 二つの値が同一である事をアサートする
299- *
300- * @param mixed $expected 期待値
301- * @param mixed $actual 実際の値
302- * @param string|null $message 失敗時のオプションメッセージ
303- * @throws AssertionFailedException アサーションが失敗した場合
304- * @return Tester このインスタンス
305- */
306- public function assertSame(mixed $expected, mixed $actual,
307- ?string $message = null): Tester {
308- if ($expected !== $actual) {
309- if ($message === null) {
310- $expected = $this->exportValue($expected);
311- $actual = $this->exportValue($actual);
312- $message =
313- "{$expected}を期待しましたが、{$actual}が得られました(厳密な比較)";
314- }
315-
316- throw new AssertionFailedException($message);
317- }
318-
319- return $this;
320- }
321-
322- /**
323- * 値がnullである事をアサートする
324- *
325- * @param mixed $actual チェックする値
326- * @param string|null $message 失敗時のオプションメッセージ
327- * @throws AssertionFailedException アサーションが失敗した場合
328- * @return Tester このインスタンス
329- */
330- public function assertNull(mixed $actual, ?string $message = null): Tester {
331- if ($actual !== null) {
332- if ($message === null) {
333- $actual = $this->exportValue($actual);
334- $message = "nullを期待しましたが、{$actual}が得られました";
335- }
336-
337- throw new AssertionFailedException($message);
338- }
339-
340- return $this;
341- }
342-
343- /**
344- * 値がnullでない事をアサートする
345- *
346- * @param mixed $actual チェックする値
347- * @param string|null $message 失敗時のオプションメッセージ
348- * @throws AssertionFailedException アサーションが失敗した場合
349- * @return Tester このインスタンス
350- */
351- public function assertNotNull(mixed $actual, ?string $message = null): Tester {
352- if ($actual === null) {
353- if ($message === null) {
354- $message = "値がnullでない事を期待しました";
355- }
356-
357- throw new AssertionFailedException($message);
358- }
359-
360- return $this;
361- }
362-
363- /**
364- * 値が特定のキーを持つ事をアサートする
365- *
366- * @param mixed $key チェックするキー
367- * @param array $array チェックする配列
368- * @param string|null $message 失敗時のオプションメッセージ
369- * @throws AssertionFailedException アサーションが失敗した場合
370- * @return Tester このインスタンス
371- */
372- public function assertArrayHasKey(mixed $key, array $array,
373- ?string $message = null): Tester {
374- if (!is_array($array) && !($array instanceof \ArrayAccess)) {
375- throw new AssertionFailedException(
376- '第2引数は配列又はArrayAccessを実装している必要があります');
377- }
378-
379- if (!array_key_exists($key, $array)) {
380- if ($message === null) {
381- $message = "配列がキー '{$key}' を持つ事を期待しました";
382- }
383-
384- throw new AssertionFailedException($message);
385- }
386-
387- return $this;
388- }
389-
390- /**
391- * 文字列がサブ文字列を含むことをアサートする
392- *
393- * @param string $needle 検索するサブ文字列
394- * @param string $haystack 検索対象の文字列
395- * @param string|null $message 失敗時のオプションメッセージ
396- * @throws AssertionFailedException アサーションが失敗した場合
397- * @return Tester このインスタンス
398- */
399- public function assertStringContains(string $needle, string $haystack,
400- ?string $message = null): Tester {
401- if (!is_string($needle) || !is_string($haystack)) {
402- throw new AssertionFailedException('両方の引数は文字列である必要があります');
403- }
404-
405- if (strpos($haystack, $needle) === false) {
406- if ($message === null) {
407- $message = "文字列 '{$haystack}' が '{$needle}' を含む事を期待しました";
408- }
409-
410- throw new AssertionFailedException($message);
411- }
412-
413- return $this;
414- }
415-
416- /**
417- * コールバックが例外をスローする事をアサートする
418- *
419- * @param callable $callback 実行するコールバック
420- * @param string $exceptionClass 期待される例外クラス
421- * @param string|null $message 失敗時のオプションメッセージ
422- * @throws AssertionFailedException アサーションが失敗した場合
423- * @return Tester このインスタンス
424- */
425- public function assertThrows(callable $callback, string $exceptionClass,
426- ?string $message = null): Tester {
427- try {
428- call_user_func($callback);
429-
430- if ($message === null) {
431- $message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした";
432- }
433-
434- throw new AssertionFailedException($message);
435- } catch (\Throwable $e) {
436- if (!($e instanceof $exceptionClass)) {
437- if ($message === null) {
438- $message = "'{$exceptionClass}' 型の例外を期待しましたが、"
439- .get_class($e)." が得られました";
440- }
441-
442- throw new AssertionFailedException($message);
443- }
444- }
445-
446- return $this;
447- }
448-
449- /**
450- * コレクションが指定された要素数を持つ事
451- *
452- * @param int $expoected 期待される要素数
453- * @param iterable $collection 配列または Traversable
454- * @param string|null $message 失敗時のカスタムメッセージ
455- * @return Tester
456- *
457- * @throws AssertionFailedException
458- */
459- public function assertCount(int $expected, iterable $collection, ?string $message = null): Tester {
460- $actual = is_countable($collection) ? count($collection) : iterator_count($collection);
461-
462- if ($actual !== $expected) {
463- $message ??= "要素数が {$expected} であることを期待しましたが、{$actual} でした";
464- throw new AssertionFailedException($message);
465- }
466-
467- return $this;
468- }
469-
470- /**
471- * 現在の describe ブロック内の残りすべての it() をスキップ
472- *
473- * @param string $reason スキップ理由(出力に表示)
474- * @return void
475- */
476- public function skipAll(string $reason = 'このテストケースはスキップされました'): void {
477- $this->skipCurrentDescribe = true;
478-
479- if ($this->verboseOutput) {
480- $this->output($this->colorize('cyan', " スキップAll: {$reason}"));
481- }
482- }
483-
484- /**
485- * テスト結果の詳細を表示する
486- *
487- * @return Tester
488- */
489- public function printSummary(): Tester {
490- $this->output('');
491- $this->output($this->colorize('bold', "テスト結果の概要:"));
492- $this->output(" テスト総数: {$this->testCount}");
493- $this->output(" ".$this->colorize('green', "合格: {$this->passCount}"));
494- $this->output(" ".$this->colorize('red', "失敗: {$this->failCount}"));
495- $this->output(" ".$this->colorize('yellow', "エラー: {$this->errorCount}"));
496- $this->output('');
497-
498- // 失敗を書き出す
499- if (count($this->failures) > 0) {
500- $this->output($this->colorize('bold', "失敗:"));
501-
502- foreach ($this->failures as $i => $f) {
503- $num = $i + 1;
504- $this->output(" {$num}) {$f['testCase']} → {$f['test']}");
505- $this->output(" ".$this->colorize('red', $f['message']));
506- $this->output('');
507- }
508- }
509-
510- // エラーを書き出す
511- if (count($this->errors) > 0) {
512- $this->output($this->colorize('bold', "エラー:"));
513-
514- foreach ($this->errors as $i => $e) {
515- $num = $i + 1;
516- $this->output(" {$num}) {$e['testCase']} → {$e['test']}");
517- $this->output(" ".$this->colorize('yellow', $e['message']));
518-
519- if (isset($e['trace'])) {
520- $this->output(" ".$this->colorize('yellow', "スタックトレース:"));
521- $this->output(" ".$this->colorize('yellow', $e['trace']));
522- }
523-
524- $this->output('');
525- }
526- }
527-
528- if ($this->failCount === 0 && $this->errorCount === 0) {
529- $this->output($this->colorize('green', "全てのテストに合格しました!"));
530- } else {
531- $this->output($this->colorize('red', "テストが失敗・エラーで完了しました。"));
532- }
533-
534- return $this;
535- }
536-
537- // 機能性メソッド
538-
539- /**
540- * コンソールにテキストを出力する
541- *
542- * @param string $text 出力するテキスト
543- * @param bool $newline 改行を追加するかどうか
544- * @return void
545- */
546- private function output(string $text, bool $newline = true): void {
547- echo $text.($newline ? PHP_EOL : '');
548- }
549-
550- /**
551- * 有効な場合はテキストに色を適用する
552- *
553- * @param string $color 色名
554- * @param string $text 色付けするテキスト
555- * @return string
556- */
557- private function colorize(string $color, string $text): string {
558- if (!$this->colorOutput || !isset($this->colors[$color])) {
559- return $text;
560- }
561-
562- return $this->colors[$color].$text.$this->colors['reset'];
563- }
564-
565- /**
566- * 値を表示用の文字列としてエクスポートする
567- *
568- * @param mixed $value エクスポートする値
569- * @return string
570- */
571- private function exportValue(mixed $value): string {
572- if (is_null($value)) return 'null';
573- if (is_bool($value)) return $value ? 'true' : 'false';
574- if (is_array($value)) return 'Array('.count($value).')';
575- if (is_object($value)) return get_class($value).' Object';
576-
577- if (is_string($value)) {
578- if (strlen($value) > 40) {
579- return "'".substr($value, 0, 37)."...'";
580- }
581-
582- return "'{$value}'";
583- }
584-
585- return (string)$value;
586- }
587-
588- /**
589- * テストの失敗を記録する
590- *
591- * @param string $message 失敗メッセージ
592- * @return void
593- */
594- private function recordFailure(string $message): void {
595- $this->failures[] = [
596- 'testCase' => $this->currentTestCase,
597- 'test' => $this->currentTest,
598- 'message' => $message,
599- ];
600- }
601-
602- /**
603- * テストのエラーを記録する
604- *
605- * @param string $message エラーメッセージ
606- * @param string|null $trace スタックトレース
607- * @return void
608- */
609- private function recordError(string $message, ?string $trace = null): void {
610- $this->errors[] = [
611- 'testCase' => $this->currentTestCase,
612- 'test' => $this->currentTest,
613- 'message' => $message,
614- 'trace' => $trace,
615- ];
616- }
617-}
@@ -0,0 +1,19 @@
1+<?php
2+
3+namespace Site\Test;
4+
5+require_once __DIR__.'/../../../autoload.php';
6+
7+use Site\Controller\User;
8+use Std\Lib\Tester;
9+
10+$test = new Tester([
11+ 'colorOutput' => true,
12+ 'verboseOutput' => true
13+]);
14+
15+$test->describe('', function($test): void {
16+ $test->it('', function($test): void {
17+ // TODO
18+ });
19+});
削除 src/Site/Test/LibActivitypub.php
@@ -1,79 +0,0 @@
1-<?php
2-namespace Site\Test;
3-
4-require_once __DIR__.'/../../../autoload.php';
5-
6-use Site\Lib\Tester;
7-use Site\Lib\Activitypub;
8-use Site\Controller\BlogPost;
9-
10-if (ACTIVITYPUB_ENABLED) {
11- $test = new Tester([
12- 'colorOutput' => true,
13- 'verboseOutput' => true
14- ]);
15-
16- /*
17- postInbox(array $activity): void
18- update(): string
19- sendActorUpdate(array $params): void
20- */
21-
22- $test->describe('ActivityPub情報の受け取り', function($test): void {
23- $test->it('このインスタンスのユーザーを受け取れるはず', function($test): void {
24- $ap = new Activitypub();
25- $res = $ap->getActor();
26- $test->assertNotNull($res);
27- $json = json_decode($res, true);
28-
29- $expect = 'Person';
30- $test->assertEquals($json['type'], $expect);
31-
32- $expect = 'http://127.0.0.1:8000';
33- $test->assertEquals($json['url'], $expect);
34-
35- $expect = 'http://127.0.0.1:8000/ap/actor';
36- $test->assertEquals($json['id'], $expect);
37-
38- $expect = 'http://127.0.0.1:8000/ap/inbox';
39- $test->assertEquals($json['inbox'], $expect);
40-
41- $expect = 'http://127.0.0.1:8000/ap/outbox';
42- $test->assertEquals($json['outbox'], $expect);
43-
44- $expect = 'http://127.0.0.1:8000/ap/followers';
45- $test->assertEquals($json['followers'], $expect);
46-
47- $expect = 'http://127.0.0.1:8000/ap/following';
48- $test->assertEquals($json['following'], $expect);
49- });
50-
51- $test->it('アクティビティを受け取れるはず', function($test): void {
52- $blog = new BlogPost;
53- $posts = $blog->getPosts('/blog/', null);
54- $ap = new Activitypub($posts);
55- $res = $ap->getActivity('a8c04518-4181-4ec6-9ef0-3f88f23b84b6'); // /blog/feature-test
56- $test->assertNotNull($res);
57- $json = json_decode($res, true);
58-
59- $expect = 'Create';
60- $test->assertEquals($json['type'], $expect);
61-
62- $expect = '【マークダウン】関数';
63- $test->assertEquals($json['object']['name'], $expect);
64-
65- $expect = 'https://www.w3.org/ns/activitystreams#Public';
66- $test->assertEquals($json['object']['to'][0], $expect);
67-
68- $expect = 'zBbw7K7L1dEwr6htZX5JevRv3oLtHwXQU4o5jT8jn9RKF';
69- $test->assertEquals($json['attachment'][0]['digestMultibase'], $expect);
70-
71- $expect = 'http://127.0.0.1:8000/static/article/o_53803618dc1691.28179609-orig.jpg';
72- $test->assertEquals($json['attachment'][0]['url'], $expect);
73- });
74- });
75-
76- // TODO: 未完了
77-
78- $test->printSummary();
79-}
削除 src/Site/Test/LibAuth.php
@@ -1,229 +0,0 @@
1-<?php
2-namespace Site\Test;
3-
4-require_once __DIR__.'/../../../autoload.php';
5-
6-use Site\Lib\Tester;
7-use Site\Lib\Auth;
8-
9-if (AUTH_ENABLED) {
10- $test = new Tester([
11- 'colorOutput' => true,
12- 'verboseOutput' => true
13- ]);
14-
15- if (AUTH_REGISTER_ENABLED) {
16- $test->describe('ユーザー登録', function($test): void {
17- $test->it('アカウントを作成出来るはず', function($test): void {
18- $user = "admin1234";
19- $pass = "testTEST1234!#$%@[]:;";
20- $passVerify = "testTEST1234!#$%@[]:;";
21- $email = "test@076.co.jp";
22-
23- $auth = new Auth();
24- $res = $auth->mkUser($user, $pass, $passVerify, $email);
25-
26- $expect = true;
27-
28- $test->assertNotNull($res);
29- $test->assertEquals($res->isSuccess, $expect);
30- });
31-
32- $test->it('空フィールドでアカウントを作成出来ないはず', function($test): void {
33- $user = "";
34- $pass = "testTEST1234!#$%@[]:;";
35- $passVerify = "testTEST1234!#$%@[]:;";
36- $email = "test@076.ne.jp";
37-
38- $auth = new Auth();
39- $res = $auth->mkUser($user, $pass, $passVerify, $email);
40-
41- $expect = 'エラー:ユーザー名をご入力下さい。'.'<br />';
42-
43- $test->assertNotNull($res);
44- $test->assertEquals($res->message, $expect);
45-
46- $user = "admin5678";
47- $pass = "";
48- $res = $auth->mkUser($user, $pass, $passVerify, $email);
49-
50- $expect = 'エラー:パスワードをご入力下さい。'.'<br />';
51-
52- $test->assertNotNull($res);
53- $test->assertEquals($res->message, $expect);
54-
55- $pass = "testTEST1234!#$%@[]:;";
56- $email = "";
57- $res = $auth->mkUser($user, $pass, $passVerify, $email);
58-
59- $expect = 'エラー:メールアドレスをご入力下さい。'.'<br />';
60-
61- $test->assertNotNull($res);
62- $test->assertEquals($res->message, $expect);
63- });
64-
65- $test->it('既にユーザー名やメールアドレスが存在するアカウントを作成出来ないはず', function($test): void {
66- $user = "admin1234";
67- $pass = "testTEST1234!#$%@[]:;";
68- $passVerify = "testTEST1234!#$%@[]:;";
69- $email = "test@076.ne.jp";
70-
71- $auth = new Auth();
72- $res = $auth->mkUser($user, $pass, $passVerify, $email);
73-
74- $expect = 'ユーザー「admin1234」は既に存在します。'.'<br />';
75-
76- $test->assertNotNull($res);
77- $test->assertEquals($res->message, $expect);
78-
79- $user = "admin5678";
80- $email = "test@076.co.jp";
81- $res = $auth->mkUser($user, $pass, $passVerify, $email);
82-
83- $expect = 'ユーザー「test@076.co.jp」は既に存在します。'.'<br />';
84-
85- $test->assertNotNull($res);
86- $test->assertEquals($res->message, $expect);
87- });
88-
89- $test->it('一致しない、短い、又は不安なパスワードでアカウントを作成出来ないはず', function($test): void {
90- $user = "admin5678";
91- $pass = "testtest1234!#$%@[]:;";
92- $passVerify = "testTEST1234!#$%@[]:;";
93- $email = "test@076.ne.jp";
94-
95- $auth = new Auth();
96- $res = $auth->mkUser($user, $pass, $passVerify, $email);
97-
98- $expect = 'エラー:パスワードは一致していません。'.'<br />';
99-
100- $test->assertNotNull($res);
101- $test->assertEquals($res->message, $expect);
102-
103- $pass = "testTEST1234";
104- $passVerify = "testTEST1234";
105- $res = $auth->mkUser($user, $pass, $passVerify, $email);
106-
107- $expect = 'エラー:パスワードは20以上をご入力下さい。'.'<br />';
108-
109- $test->assertNotNull($res);
110- $test->assertEquals($res->message, $expect);
111-
112- $pass = "testTEST1234testTEST";
113- $passVerify = "testTEST1234testTEST";
114- $res = $auth->mkUser($user, $pass, $passVerify, $email);
115-
116- $expect = 'エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。'.'<br />';
117-
118- $test->assertNotNull($res);
119- $test->assertEquals($res->message, $expect);
120- });
121-
122- $test->it('不正なメールアドレスでアカウントを作成出来ないはず', function($test): void {
123- $user = "admin5678";
124- $pass = "testTEST1234!#$%@[]:;";
125- $passVerify = "testTEST1234!#$%@[]:;";
126- $email = "test@076.niggerballs";
127-
128- $auth = new Auth();
129- $res = $auth->mkUser($user, $pass, $passVerify, $email);
130-
131- $expect = 'エラー:メールアドレスは不正です。'.'<br />';
132-
133- $test->assertNotNull($res);
134- $test->assertEquals($res->message, $expect);
135-
136- $email = "test@076";
137- $res = $auth->mkUser($user, $pass, $passVerify, $email);
138-
139- $expect = 'エラー:メールアドレスは不正です。'.'<br />';
140-
141- $test->assertNotNull($res);
142- $test->assertEquals($res->message, $expect);
143- });
144-
145- // ユーザーの削除
146- $users = scandir(ROOT.'/data/user');
147- $userFile = '';
148- foreach ($users as $u) {
149- if (str_contains($u, 'admin1234')) {
150- $userFile = $u;
151- break;
152- }
153- }
154- unlink(ROOT.'/data/user/'.$userFile);
155- rmdir(ROOT.'/public/static/user/admin1234');
156- });
157- }
158-
159- // TODO: CLIからクッキーを設定出来ないから、「Site\Lib\Auth::getUserData(): Return value must be of type stdClass, null returned」って発生する・・・
160- // $test->describe('ログインとログアウト', function($test): void {
161- // $user = "admin1234";
162- // $pass = "testTEST1234!#$%@[]:;";
163- // $passVerify = "testTEST1234!#$%@[]:;";
164- // $email = "test@076.co.jp";
165-
166- // $auth = new Auth();
167- // $res = $auth->mkUser($user, $pass, $passVerify, $email);
168- // unset($auth);
169-
170- // $test->it('正しい認証情報でログイン出来るはず', function($test) use ($user, $pass): void {
171- // $auth = new Auth();
172- // $res = $auth->setToken($user, $pass);
173-
174- // $test->assertTrue($res->isSuccess);
175- // $test->assertEquals('ログイン成功', $res->message);
176-
177- // $auth2 = new Auth();
178- // $usr = $auth2->getLoggedInUser();
179-
180- // $test->assertNotNull($usr);
181- // $test->assertEquals($user, $usr->username);
182- // });
183-
184- // $test->it('間違ったパスワードでログイン出来ないはず', function($test) use ($user): void {
185- // $auth = new Auth();
186- // $res = $auth->setToken($user, 'fuseipassword');
187-
188- // $test->assertFalse($res->isSuccess);
189- // $test->assertEquals('ユーザー名又はパスワードが一致していません', $res->message);
190- // });
191-
192- // $test->it('存在しないユーザーでログイン出来ないはず', function($test) use ($pass): void {
193- // $auth = new Auth();
194- // $res = $auth->setToken('fumeiuser', $pass);
195-
196- // $test->assertFalse($res->isSuccess);
197- // $test->assertEquals('ユーザー名又はパスワードが一致していません', $res->message);
198- // });
199-
200- // $test->it('ログアウト出来るはず', function($test) use ($user, $pass): void {
201- // $auth = new Auth();
202- // $auth->setToken($user, $pass);
203-
204- // $res = $auth->logout();
205-
206- // $test->assertTrue($res->isSuccess);
207- // $test->assertEquals('ログイン成功', $res->message);
208-
209- // $auth2 = new Auth();
210- // $usr = $auth2->getLoggedInUser();
211-
212- // $test->assertNull($usr);
213- // });
214-
215- // // ユーザーの削除
216- // $users = scandir(ROOT.'/data/user');
217- // $userFile = '';
218- // foreach ($users as $u) {
219- // if (str_contains($u, 'admin1234')) {
220- // $userFile = $u;
221- // break;
222- // }
223- // }
224- // unlink(ROOT.'/data/user/'.$userFile);
225- // rmdir(ROOT.'/public/static/user/admin1234');
226- // });
227-
228- $test->printSummary();
229-}
削除 src/Site/Test/LibCurl.php
@@ -1,94 +0,0 @@
1-<?php
2-namespace Site\Test;
3-
4-require_once __DIR__.'/../../../autoload.php';
5-
6-use Site\Lib\Tester;
7-use Site\Lib\Curl;
8-
9-if (CURL_ENABLED) {
10- $test = new Tester([
11- 'colorOutput' => true,
12- 'verboseOutput' => true
13- ]);
14-
15- $test->describe('Curlの基本的な機能性', function($test): void {
16- $test->it('URLで使って新しいインスタンスを作成するはず', function($test): void {
17- $curl = new Curl('https://076.moe');
18- $test->assertNotNull($curl);
19- });
20-
21- $test->it('メソッドでURLを設定出来るはす', function($test): void {
22- $curl = new Curl();
23- $curl->setUrl('https://076.moe');
24- $test->assertNotNull($curl);
25- });
26-
27- $test->it('ヘッダー文字を作成出来るはず', function($test): void {
28- $curl = new Curl('https://076.moe');
29- $curl->setHeaders([
30- 'Accept' => 'application/json',
31- 'User-Agent' => 'LoliTest/1.0'
32- ]);
33-
34- $reflectionClass = new \ReflectionClass($curl);
35- $method = $reflectionClass->getMethod('buildHeaderString');
36- $method->setAccessible(true);
37-
38- $headerString = $method->invoke($curl);
39- $test->assertStringContains('Accept: application/json', $headerString);
40- $test->assertStringContains('User-Agent: LoliTest/1.0', $headerString);
41- });
42-
43- $test->skip('移転を対応するはず', '作成中・・・');
44-
45- $test->it('メソッドチェーニングを対応するはず', function($test): void {
46- $curl = new Curl();
47- $result = $curl->setUrl('https://076.moe')
48- ->setMethod('GET')
49- ->setTimeout(30);
50-
51- $test->assertSame($curl, $result);
52- });
53- });
54-
55- $test->describe('Curl HTTP リクエスト', function($test): void {
56- $networkAvailable = true;
57-
58- if (!$networkAvailable) {
59- $test->skip('076.moeでGETリクエストの確認', 'ネットワークが無効です。');
60- $test->skip('postman-echo.comでPOSTリクエストの確認', 'ネットワークが無効です。');
61- return;
62- }
63-
64- $test->it('076.moeでGETリクエストの確認', function($test): void {
65- $curl = new Curl('https://076.moe');
66- $result = $curl->execute();
67-
68- $test->assertTrue($result->isSuccess);
69- $test->assertEquals(200, $curl->getResponseCode());
70- $test->assertNotNull($curl->getResponseBody());
71- $test->assertStringContains('<html', $curl->getResponseBody());
72- });
73-
74- $test->it('postman-echo.comでPOSTリクエストの確認', function($test): void {
75- $curl = new Curl();
76- $curl->setUrl('https://postman-echo.com/post')
77- ->setMethod('POST')
78- ->setPostFields([
79- 'name' => '山田太郎',
80- 'email' => 't.yamada@example.com'
81- ]);
82-
83- $result = $curl->execute();
84- $test->assertTrue($result->isSuccess);
85- $test->assertEquals(200, $curl->getResponseCode());
86-
87- $responseBody = $curl->getResponseBody();
88- $test->assertStringContains('山田太郎', $responseBody);
89- $test->assertStringContains('t.yamada@example.com', $responseBody);
90- });
91- });
92-
93- $test->printSummary();
94-}
削除 src/Site/Test/LibMarkdown.php
@@ -1,234 +0,0 @@
1-<?php
2-namespace Site\Test;
3-
4-require_once __DIR__.'/../../../autoload.php';
5-
6-use Site\Lib\Tester;
7-use Site\Lib\Markdown;
8-
9-$test = new Tester([
10- 'colorOutput' => true,
11- 'verboseOutput' => true
12-]);
13-
14-function mkfile(string $body): void {
15- $post = fopen(ROOT."/blog/dietrannynigger.md", "w");
16- fwrite($post, "title: 【】\n");
17- fwrite($post, "uuid: ".uuid()."\n");
18- fwrite($post, "author: 諏訪子\n");
19- fwrite($post, "date: ".date("Y-m-d H:i:s")."\n");
20- fwrite($post, "thumbnail: \n");
21- fwrite($post, "thumborient: center\n");
22- fwrite($post, "category: \n");
23- fwrite($post, "----\n");
24- fwrite($post, $body);
25- fclose($post);
26-}
27-
28-function rmfile(): void {
29- unlink(ROOT."/blog/dietrannynigger.md");
30-}
31-
32-class Constants {
33- public const START = "<p> ";
34- public const END = " </p>";
35-}
36-
37-$test->describe('マークダウン', function($test): void {
38-
39- $test->it('メタデータをパーシング出来るはず', function($test): void {
40- $md = new Markdown('feature-test', '/blog/');
41- $test->assertNotNull($md);
42-
43- $meta = $md->getMetadata();
44- $test->assertNotNull($meta);
45-
46- $expect = new \stdClass;
47- $expect->title = '【マークダウン】関数';
48- $expect->uuid = 'a8c04518-4181-4ec6-9ef0-3f88f23b84b6';
49- $expect->author = '諏訪子';
50- $expect->date = '2025-11-07 20:33:42';
51- $expect->thumbnail = 'o_53803618dc1691.28179609-orig.jpg';
52- $expect->thumborient = 'center';
53- $expect->category = ['test', 'おちんちん', 'でっかいおっぱい'];
54- $expect->css = ['code', 'blockquote', 'table', 'blink'];
55-
56- $test->assertEquals($expect, $meta);
57- });
58-
59- $test->it('横線はHTMLに交換出来るはず', function($test): void {
60- mkfile('----');
61-
62- $md = new Markdown('dietrannynigger', '/blog/');
63- $test->assertNotNull($md);
64-
65- $body = $md->parse();
66- $test->assertNotNull($body);
67-
68- $expect = "<hr />";
69- $actual = str_replace("\n", '', trim($body));
70-
71- $test->assertEquals($expect, $actual);
72- });
73-
74- $test->it('ヘッダーはHTMLに交換出来るはず', function($test): void {
75- mkfile('# ケロケロ');
76-
77- $md = new Markdown('dietrannynigger', '/blog/');
78- $test->assertNotNull($md);
79-
80- $body = $md->parse();
81- $test->assertNotNull($body);
82-
83- $expect = "<h1>ケロケロ</h1>";
84- $actual = trim($body);
85-
86- $test->assertEquals($expect, $actual);
87-
88- // ---------------
89-
90- mkfile('## ケロケロ');
91-
92- $md = new Markdown('dietrannynigger', '/blog/');
93- $test->assertNotNull($md);
94-
95- $body = $md->parse();
96- $test->assertNotNull($body);
97-
98- $expect = "<h2>ケロケロ</h2>";
99- $actual = trim($body);
100-
101- $test->assertEquals($expect, $actual);
102-
103- // ---------------
104-
105- mkfile('### ケロケロ');
106-
107- $md = new Markdown('dietrannynigger', '/blog/');
108- $test->assertNotNull($md);
109-
110- $body = $md->parse();
111- $test->assertNotNull($body);
112-
113- $expect = "<h3>ケロケロ</h3>";
114- $actual = trim($body);
115-
116- $test->assertEquals($expect, $actual);
117-
118- // ---------------
119-
120- mkfile('#### ケロケロ');
121-
122- $md = new Markdown('dietrannynigger', '/blog/');
123- $test->assertNotNull($md);
124-
125- $body = $md->parse();
126- $test->assertNotNull($body);
127-
128- $expect = "<h4>ケロケロ</h4>";
129- $actual = trim($body);
130-
131- $test->assertEquals($expect, $actual);
132-
133- // ---------------
134-
135- mkfile('##### ケロケロ');
136-
137- $md = new Markdown('dietrannynigger', '/blog/');
138- $test->assertNotNull($md);
139-
140- $body = $md->parse();
141- $test->assertNotNull($body);
142-
143- $expect = "<h5>ケロケロ</h5>";
144- $actual = trim($body);
145-
146- $test->assertEquals($expect, $actual);
147-
148- // ---------------
149-
150- mkfile('###### ケロケロ');
151-
152- $md = new Markdown('dietrannynigger', '/blog/');
153- $test->assertNotNull($md);
154-
155- $body = $md->parse();
156- $test->assertNotNull($body);
157-
158- $expect = "<h6>ケロケロ</h6>";
159- $actual = trim($body);
160-
161- $test->assertEquals($expect, $actual);
162- });
163-
164- $test->skip('コメントはHTMLに交換出来るはず', '作成中・・・');
165-
166- $test->it('画像、音楽、動画はHTMLに交換出来るはず', function($test): void {
167- mkfile("![](https://ass.technicalsuwako.moe/banner.png)\n
168-![ケロケロ](https://ass.technicalsuwako.moe/banner.png)\n
169-![ケロケロ#max-width: 200px;](https://ass.technicalsuwako.moe/banner.png)\n
170-$[audio/ogg](https://ass.technicalsuwako.moe/砕月着信音.ogg)\n
171-#[video/mp4](https://ass.technicalsuwako.moe/フリーズ.mp4)");
172-
173- $md = new Markdown('dietrannynigger', '/blog/');
174- $test->assertNotNull($md);
175-
176- $body = $md->parse();
177- $test->assertNotNull($body);
178-
179- $expect = '<p><img style="width: 100%; " src="https://ass.technicalsuwako.moe/banner.png" alt="" /> </p> '
180- . '<p><img style="width: 100%; " src="https://ass.technicalsuwako.moe/banner.png" alt="ケロケロ" /> </p> '
181- . '<p><img style="width: 100%; max-width: 200px;" src="https://ass.technicalsuwako.moe/banner.png" alt="ケロケロ" /> </p> '
182- . '<p><audio controls><source src="https://ass.technicalsuwako.moe/砕月着信音.ogg" type="audio/ogg" /></audio> </p> '
183- . '<p><video style="max-width: 100%;" controls><source src="https://ass.technicalsuwako.moe/フリーズ.mp4" type="video/mp4" /></video> </p>';
184- $actual = str_replace("\n", '', str_replace(' ', '', trim($body)));
185-
186- $test->assertEquals($expect, $actual);
187- });
188-
189- $test->it('基本的なマークダウンはHTMLに交換出来るはず', function($test): void {
190- mkfile("**太文字**\n
191-*イタリック*\n
192-_下線_\n
193-~取消線~\n
194-!:(5)明滅文字:!\n
195-^(72)フォントサイズ^\n
196-%(ff00ff)フォントカラー%");
197-
198- $md = new Markdown('dietrannynigger', '/blog/');
199- $test->assertNotNull($md);
200-
201- $body = $md->parse();
202- $test->assertNotNull($body);
203-
204- $expect = '<p> <strong>太文字</strong> </p> '
205- . '<p> <em>イタリック</em> </p> '
206- . '<p> <u>下線</u> </p> '
207- . '<p> <s>取消線</s> </p> '
208- . '<p> <span class="blink" style="animation-duration: 5s;">明滅文字</span> </p> '
209- . '<p> <span style="font-size: 72px;">フォントサイズ</span> </p> '
210- . '<p> <span style="color: #ff00ff;">フォントカラー</span> </p>';
211- $actual = str_replace("\n", '', trim($body));
212-
213- $test->assertEquals($expect, $actual);
214- });
215-
216- $test->it('複数マークダウンはHTMLに交換出来るはず', function($test): void {
217- mkfile("[^(500)<God>(すわこ)](https://techsuwako.jp/)^");
218-
219- $md = new Markdown('dietrannynigger', '/blog/');
220- $test->assertNotNull($md);
221-
222- $body = $md->parse();
223- $test->assertNotNull($body);
224-
225- $expect = Constants::START.'<a href="https://techsuwako.jp/"><span style="font-size: 500px;"><ruby>God<rp>(</rp><rt>すわこ</rt><rp>)</rp></ruby></a></span>'.Constants::END;
226- $actual = str_replace("\n", '', trim($body));
227-
228- $test->assertEquals($expect, $actual);
229- });
230-
231- rmfile();
232-});
233-
234-$test->printSummary();
削除 src/Site/Test/LibRoute.php
@@ -1,38 +0,0 @@
1-<?php
2-namespace Site\Test;
3-
4-require_once __DIR__.'/../../../autoload.php';
5-
6-use Site\Lib\Tester;
7-use Site\Lib\Route;
8-
9-$test = new Tester([
10- 'colorOutput' => true,
11- 'verboseOutput' => true
12-]);
13-
14-$test->describe('ルート', function($test): void {
15- $test->it('ルートに追加出来るはず', function($test): void {
16- class Webpage {
17- public function about(array $params): void {}
18- }
19-
20- $routes = [
21- Route::add('GET', 'about', Webpage::class.'@about'),
22- ];
23-
24- $expect = [
25- [
26- 'method' => 'GET',
27- 'path' => 'about',
28- 'class' => 'Site\Test\Webpage@about',
29- 'params' => [],
30- ]
31- ];
32-
33- $test->assertNotNull($routes);
34- $test->assertEquals($expect, $routes);
35- });
36-});
37-
38-$test->printSummary();
@@ -0,0 +1,557 @@
1+<?php
2+namespace Std\Lib;
3+
4+use Std\Lib\Curl;
5+
6+/**
7+ * ActivityPubプロトコルの実装クラス
8+ *
9+ * このクラスはActivityPubプロトコルを利用して分散型
10+ * ソーシャルネットワーキングを実装します。
11+ */
12+class ActivityPub {
13+ private string $domain;
14+ private string $actor;
15+ private string $actorNick;
16+ private string $desc;
17+ private string $icon;
18+ private array $posts = [];
19+ private string $https;
20+
21+ /**
22+ * コンストラクタ
23+ *
24+ * @param array $posts 投稿データの配列
25+ */
26+ public function __construct(array $posts = []) {
27+ if (!ACTIVITYPUB_ENABLED) return;
28+ $this->https = 'http'.((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 's' : '').'://';
29+ $this->domain = $this->https.($_SERVER['SERVER_NAME'] ?? '127.0.0.1:8000');
30+ $this->actor = FEDIINFO['actor'];
31+ $this->actorNick = FEDIINFO['actorNick'];
32+ $this->desc = FEDIINFO['desc'];
33+ $this->icon = "{$this->domain}".FEDIINFO['icon'];
34+ $this->posts = $posts;
35+ }
36+
37+ /**
38+ * ActivityPubアクタープロフィールを受け取る
39+ *
40+ * @return string アクターオブジェクト
41+ * @throws \Exception 公開鍵の読み込みに失敗した場合
42+ */
43+ public function getActor(): string {
44+ if (!ACTIVITYPUB_ENABLED) return '';
45+ $pubkey = file_get_contents(FEDIINFO['pubkey']);
46+ if ($pubkey === false) {
47+ throw new \Exception('公開鍵の受取に失敗。パス:'.FEDIINFO['pubkey']);
48+ }
49+
50+ $actor = [
51+ '@context' => [
52+ 'https://www.w3.org/ns/activitystreams',
53+ 'https://w3id.org/security/v1',
54+ ],
55+ 'id' => "{$this->domain}/ap/actor",
56+ 'name' => $this->actorNick,
57+ 'summary' => $this->desc,
58+ 'manuallyApprovesFollowers' => false,
59+ 'icon' => [
60+ 'type' => 'Image',
61+ 'mediaType' => 'image/png',
62+ 'url' => $this->icon,
63+ ],
64+ 'image' => [
65+ 'type' => 'Image',
66+ 'url' => "{$this->domain}/static/article/o_53803618dc1691.28179609.jpg",
67+ 'mediaType' => 'image/jpeg',
68+ ],
69+ 'type' => 'Person',
70+ 'url' => "{$this->domain}",
71+ 'preferredUsername' => $this->actor,
72+ 'inbox' => "{$this->domain}/ap/inbox",
73+ 'outbox' => "{$this->domain}/ap/outbox",
74+ 'followers' => "{$this->domain}/ap/followers",
75+ 'following' => "{$this->domain}/ap/following",
76+ 'published' => '2025-03-28T18:00:00Z',
77+ 'updated' => gmdate('c'),
78+ 'publicKey' => [
79+ 'id' => "{$this->domain}/ap/actor#main-key",
80+ 'owner' => "{$this->domain}/ap/actor",
81+ 'publicKeyPem' => $pubkey,
82+ ],
83+ ];
84+
85+ return json_encode($actor, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
86+ }
87+
88+ /**
89+ * 特定のUUIDに対応するActivityを取得する
90+ *
91+ * @param string $uuid 取得するアクティビティのUUID
92+ * @return string JSONエンコードされたアクティビティデータ
93+ */
94+ public function getActivity(string $uuid): string {
95+ if (!ACTIVITYPUB_ENABLED) return '';
96+ $items = [];
97+
98+ foreach ($this->posts as $post) {
99+ if ($post['uuid'] != $uuid) continue;
100+
101+ $items = [
102+ '@context' => [
103+ 'https://www.w3.org/ns/activitystreams',
104+ 'https://w3id.org/security/v1',
105+ [
106+ 'Emoji' => 'toot:Emoji',
107+ 'EmojiReact' => 'litepub:EmojiReact',
108+ 'Hashtag' => 'as:Hashtag',
109+ 'litepub' => 'http://litepub.social/ns#',
110+ 'sensitive' => 'as:sensitive',
111+ 'toot' => 'http://joinmastodon.org/ns#',
112+ ],
113+ ],
114+ 'id' => "{$this->domain}/ap/activities/create/{$post['uuid']}",
115+ 'type' => 'Create',
116+ 'actor' => "{$this->domain}/ap/actor",
117+ 'cc' => [
118+ "{$this->domain}/ap/followers",
119+ ],
120+ 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
121+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
122+ 'object' => [
123+ 'id' => "{$this->domain}/ap/objects/{$post['uuid']}",
124+ 'type' => 'Note',
125+ 'name' => $post['title'],
126+ 'attributedTo' => "{$this->domain}/ap/actor",
127+ 'cc' => [
128+ "{$this->domain}/ap/followers",
129+ ],
130+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
131+ 'content' => $post['preview']."<br /><br /><a href=\"{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
132+ 'url' => "{$this->domain}/blog/{$post['slug']}",
133+ 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
134+ 'replies' => "{$this->domain}/ap/objects/{$uuid}/replies",
135+ 'sensitive' => false,
136+ ],
137+ ];
138+
139+ if (isset($post['category']) && !empty($post['category'])) {
140+ $item['tag'] = [];
141+ foreach ($post['category'] as $cat) {
142+ $items['tag'][] = $cat;
143+ }
144+ }
145+
146+ if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
147+ $imgurl = "{$this->domain}/static/article/{$post['thumbnail']}";
148+ $imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
149+ $imgraw = file_get_contents($imgpath);
150+
151+ $items['attachment'] = [
152+ [
153+ 'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
154+ 'mediaType' => mime_content_type($imgpath),
155+ 'type' => "Image",
156+ 'url' => $imgurl,
157+ ],
158+ ];
159+ }
160+
161+ break;
162+ }
163+
164+ return json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
165+ }
166+
167+ /**
168+ * アウトボックスデータを取得する
169+ *
170+ * @return string JSONエンコードされたアウトボックスデータ
171+ */
172+ public function getOutbox(): string {
173+ if (!ACTIVITYPUB_ENABLED) return '';
174+ $items = [];
175+ $counter = 0;
176+
177+ foreach ($this->posts as $post) {
178+ $uid = $post['uuid'];
179+
180+ $items[] = [
181+ '@context' => [
182+ 'https://www.w3.org/ns/activitystreams',
183+ 'https://w3id.org/security/v1',
184+ [
185+ 'Emoji' => 'toot:Emoji',
186+ 'EmojiReact' => 'litepub:EmojiReact',
187+ 'Hashtag' => 'as:Hashtag',
188+ 'litepub' => 'http://litepub.social/ns#',
189+ 'sensitive' => 'as:sensitive',
190+ 'toot' => 'http://joinmastodon.org/ns#',
191+ ],
192+ ],
193+ 'id' => "{$this->domain}/ap/activities/create/{$uid}",
194+ 'type' => 'Create',
195+ 'actor' => "{$this->domain}/ap/actor",
196+ 'cc' => [
197+ "{$this->domain}/ap/followers",
198+ ],
199+ 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
200+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
201+ 'object' => [
202+ 'id' => "{$this->domain}/ap/objects/{$uid}",
203+ 'type' => 'Note',
204+ 'name' => $post['title'],
205+ 'attributedTo' => "{$this->domain}/ap/actor",
206+ 'cc' => [
207+ "{$this->domain}/ap/followers",
208+ ],
209+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
210+ 'content' => $post['preview']."<br /><br /><a href=\"{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
211+ 'url' => "{$this->domain}/blog/{$post['slug']}",
212+ 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
213+ 'replies' => "{$this->domain}/ap/objects/{$uid}/replies",
214+ 'sensitive' => false,
215+ ],
216+ ];
217+
218+ if (isset($post['category']) && !empty($post['category'])) {
219+ $items[$counter]['tag'] = [];
220+ foreach ($post['category'] as $cat) {
221+ $items[$counter]['tag'][] = $cat;
222+ }
223+ }
224+
225+ if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
226+ $imgurl = "{$this->domain}/static/article/{$post['thumbnail']}";
227+ $imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
228+ $imgraw = file_get_contents($imgpath);
229+
230+ $items[$counter]['attachment'] = [
231+ [
232+ 'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
233+ 'mediaType' => mime_content_type($imgpath),
234+ 'type' => "Image",
235+ 'url' => $imgurl,
236+ ],
237+ ];
238+ }
239+
240+ $counter++;
241+ }
242+
243+ $outbox = [
244+ '@context' => [
245+ 'https://www.w3.org/ns/activitystreams',
246+ 'https://w3id.org/security/v1',
247+ ],
248+ 'id' => "{$this->domain}/ap/outbox",
249+ 'type' => 'OrderedCollection',
250+ 'totalItems' => count($items),
251+ 'orderedItems' => $items,
252+ ];
253+
254+ return json_encode($outbox, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
255+ }
256+
257+ /**
258+ * WebFingerデータを取得する
259+ *
260+ * @return string JSONエンコードされたWebFingerデータ
261+ */
262+ public function getWebfinger(): string {
263+ if (!ACTIVITYPUB_ENABLED) return '';
264+ $domain = str_replace(['http://', 'https://'], '', $this->domain);
265+ $webfinger = [
266+ 'subject' => "acct:{$this->actor}@{$domain}",
267+ 'links' => [
268+ [
269+ 'rel' => 'self',
270+ 'type' => 'application/activity+json',
271+ 'href' => "{$domain}/ap/actor",
272+ ],
273+ ],
274+ ];
275+
276+ return json_encode($webfinger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
277+ }
278+
279+ /**
280+ * フォロワーのリストを取得する
281+ *
282+ * @return string JSONエンコードされたフォロワーのリスト
283+ */
284+ public function getFollowers(): string {
285+ if (!ACTIVITYPUB_ENABLED) return '';
286+ $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
287+
288+ $followers = [
289+ '@context' => [
290+ 'https://www.w3.org/ns/activitystreams',
291+ 'https://w3id.org/security/v1',
292+ ],
293+ 'id' => "{$this->domain}/ap/followers",
294+ 'type' => 'OrderredCollection',
295+ 'totalItems' => count($f),
296+ 'orderedItems' => $f,
297+ ];
298+
299+ return json_encode($followers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
300+ }
301+
302+ /**
303+ * フォローしているアカウントのリストを取得する
304+ *
305+ * @return string JSONエンコードされたフォローリスト
306+ */
307+ public function getFollowing(): string {
308+ if (!ACTIVITYPUB_ENABLED) return '';
309+ $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/following.txt')));
310+
311+ $following = [
312+ '@context' => [
313+ 'https://www.w3.org/ns/activitystreams',
314+ 'https://w3id.org/security/v1',
315+ ],
316+ 'id' => "{$this->domain}/ap/following",
317+ 'type' => 'OrderredCollection',
318+ 'totalItems' => count($f),
319+ 'orderedItems' => $f,
320+ ];
321+
322+ return json_encode($following, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
323+ }
324+
325+ /**
326+ * インボックスにアクティビティを投稿する
327+ *
328+ * @param array $activity 処理するアクティビティデータ
329+ * @return void
330+ */
331+ public function postInbox(array $activity): void {
332+ if (!ACTIVITYPUB_ENABLED) return;
333+ switch ($activity['type']) {
334+ case 'Follow':
335+ $this->acceptFollower($activity);
336+ break;
337+ default:
338+ header('HTTP/1.1 501 Not Implemented');
339+ header('Content-Type: application/activity+json');
340+ echo json_encode(['error' => '未対応なアクティビティタイプ: '.$activity['type']]);
341+ exit;
342+ }
343+
344+ header('HTTP/1.1 200 OK');
345+ header('Content-Type: application/activity+json');
346+ echo json_encode(['status' => 'OK'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
347+ exit;
348+ }
349+
350+ /**
351+ * アクタープロフィールの更新アクティビティを作成する
352+ *
353+ * @return string JSONエンコードされた更新アクティビティ
354+ */
355+ public function update(): string {
356+ if (!ACTIVITYPUB_ENABLED) return '';
357+ $update = [
358+ '@context' => [
359+ 'https://www.w3.org/ns/activitystreams',
360+ 'https://w3id.org/security/v1',
361+ ],
362+ 'id' => "{$this->domain}/ap/activities/update/".uuid(),
363+ 'type' => 'Update',
364+ 'actor' => "{$this->domain}/ap/actor",
365+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
366+ 'object' => json_decode($this->getActor(), true),
367+ ];
368+
369+ return json_encode($update, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
370+ }
371+
372+ /**
373+ * アクター更新をフォロワーに送信する
374+ *
375+ * @param array $params パラメータ配列
376+ * @return void
377+ */
378+ public function sendActorUpdate(array $params): void {
379+ if (!ACTIVITYPUB_ENABLED) return;
380+ $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
381+ $ap = new Activitypub();
382+ $inboxes = implode("\n", $f);
383+ $update = json_decode($ap->update(), true);
384+
385+ foreach ($f as $inbox) $this->sendActivity($inbox, $update);
386+ }
387+
388+ // 機能性メソッド
389+
390+ /**
391+ * 指定されたインボックスURLにアクティビティを送信する
392+ *
393+ * @param string $inboxUrl 送信先のインボックスURL
394+ * @param array $activity 送信するアクティビティデータ
395+ * @return void
396+ */
397+ private function sendActivity(string $inboxUrl, array $activity): void {
398+ $privFile = FEDIINFO['privkey'];
399+ $priv = file_get_contents($privFile);
400+ if ($priv === false) {
401+ logger(\LogType::ActivityPub, "エラー:秘密鍵「{$privFile}」の読込に失敗");
402+ header('HTTP/1.1 500 Internal Server Error');
403+ header('Content-Type: application/activity+json');
404+ echo json_encode(['error' => '秘密鍵の読込に失敗']);
405+ exit;
406+ }
407+
408+ $body = json_encode($activity, JSON_UNESCAPED_SLASHES);
409+ $digest = base64_encode(hash('sha256', $body, true));
410+ $date = gmdate('D, d M Y H:i:s \G\M\T');
411+ $host = parse_url($inboxUrl, PHP_URL_HOST);
412+
413+ $headers = [
414+ 'Host' => $host,
415+ 'Date' => $date,
416+ 'Content-Type' => 'application/activity+json',
417+ 'Digest' => "SHA-256=$digest",
418+ ];
419+
420+ $stringToSign = "host: {$headers['Host']}\n"."date: {$headers['Date']}\n"."digest: {$headers['Digest']}";
421+ logger(\LogType::ActivityPub, "署名対象: {$stringToSign}");
422+
423+ if (!openssl_sign($stringToSign, $signature, $priv, OPENSSL_ALGO_SHA256)) {
424+ $error = openssl_error_string();
425+ logger(\LogType::ActivityPub, "エラー:署名に失敗: {$error}");
426+ header('HTTP/1.1 500 Internal Server Error');
427+ header('Content-Type: application/activity+json');
428+ echo json_encode(['error' => '署名に失敗']);
429+ exit;
430+ }
431+
432+ $sigValue = base64_encode($signature);
433+ $headers['Signature'] = "keyId=\"{$this->domain}/ap/actor#main-key\",";
434+ $headers['Signature'] .= 'algorithm="rsa-sha256",';
435+ $headers['Signature'] .= 'headers="host date digest",';
436+ $headers['Signature'] .= 'signature="'.$sigValue.'"';
437+ logger(\LogType::ActivityPub, "署名: {$headers['Signature']}\n送信データ: {$body}");
438+
439+ $curl = new Curl($inboxUrl);
440+ $curl->setMethod('POST')
441+ ->setPostRaw($body)
442+ ->setHeaders(array_map(fn($k, $v) => "$k: $v", array_keys($headers), $headers))
443+ ->setCaInfo('/etc/ssl/cert.pem')
444+ ->setVerbose(true)
445+ ->setStderr(fopen(ROOT.'/log/ap_log.txt', 'a'));
446+
447+ $success = $curl->execute();
448+ $res = $curl->getResponseBody();
449+ $code = $curl->getResponseCode();
450+ $err = $curl->getError();
451+
452+ var_dump(print_r($res));
453+ logger(\LogType::ActivityPub, "アクティビティは「{$inboxUrl}」に送信しました: HTTP {$code}");
454+ logger(\LogType::ActivityPub, "エラー: {$err}");
455+ logger(\LogType::ActivityPub, "レスポンス: {$res}");
456+ }
457+
458+ /**
459+ * フォロワーを受け入れる
460+ *
461+ * @param array $activity フォローアクティビティデータ
462+ * @return void
463+ */
464+ private function acceptFollower(array $activity): void {
465+ $followerActor = $activity['actor'] ?? null;
466+ if (!$followerActor) {
467+ header('HTTP/1.1 400 Bad Request');
468+ header('Content-Type: application/activity+json');
469+ echo json_encode(['error' => 'アクターがない']);
470+ exit;
471+ }
472+
473+ $this->storeFollower($followerActor);
474+
475+ $inbox = $this->getInboxFromActor($followerActor);
476+ if (!$inbox) {
477+ header('HTTP/1.1 500 Internal Server Error');
478+ header('Content-Type: application/activity+json');
479+ echo json_encode(['error' => 'フォロワーの受付ボックスの受取に失敗']);
480+ exit;
481+ }
482+
483+ $accept = [
484+ '@context' => [
485+ 'https://www.w3.org/ns/activitystreams',
486+ 'https://w3id.org/security/v1',
487+ ],
488+ 'id' => "{$this->domain}/ap/activities/".uniqid(),
489+ 'type' => 'Accept',
490+ 'actor' => "{$this->domain}/ap/actor",
491+ 'object' => $activity,
492+ ];
493+
494+ $this->sendActivity($inbox, $accept);
495+ }
496+
497+ /**
498+ * フォロワーをストレージに保存する
499+ *
500+ * @param string $followerActor フォロワーのアクターURL
501+ * @return void
502+ */
503+ private function storeFollower(string $followerActor): void {
504+ $file = ROOT.'/data/followers.txt';
505+ if (!file_exists($file)) {
506+ touch($file);
507+ chmod($file, 0644);
508+ }
509+
510+ $followers = $this->getFollowersList();
511+ if (!in_array($followerActor, $followers)) file_put_contents($file, "$followerActor\n", FILE_APPEND);
512+ }
513+
514+ /**
515+ * フォロワーのリストを配列として取得する
516+ *
517+ * @return array フォロワーのURLの配列
518+ */
519+ private function getFollowersList(): array {
520+ $file = ROOT.'/data/followers.txt';
521+ $f = array_filter(explode("\n", file_get_contents($file)));
522+ return file_exists($file) ? array_filter(explode("\n", file_get_contents($file))) : [];
523+ }
524+
525+ /**
526+ * アクターのインボックスURLを取得する
527+ *
528+ * @param string $actor アクターのURL
529+ * @return string|null インボックスURL、取得に失敗した場合はnull
530+ */
531+ private function getInboxFromActor(string $actor): ?string {
532+ $curl = new Curl($actor);
533+ $curl->setHeaders(['Accept: application/activity+json'])
534+ ->setFollowRedirects(true)
535+ ->setMaxRedirects(5)
536+ ->setCaInfo('/etc/ssl/cert.pem');
537+
538+ logger(\LogType::ActivityPub, "アクターURLにリクエスト: {$actor}");
539+ $success = $curl->execute();
540+ if (!$success) {
541+ logger(\LogType::ActivityPub, "アクターリクエストに失敗: ".$curl->getError());
542+ return null;
543+ }
544+
545+ $res = $curl->getResponseBody();
546+ $code = $curl->getResponseCode();
547+ $err = $curl->getError();
548+
549+ if ($code !== 200) {
550+ logger(\LogType::ActivityPub, "アクター取得に失敗: HTTP {$code}, エラー: {$err}");
551+ return null;
552+ }
553+
554+ $data = json_decode($res, true);
555+ return $data['inbox'] ?? null;
556+ }
557+}
@@ -0,0 +1,451 @@
1+<?php
2+namespace Std\Lib;
3+
4+class Auth {
5+ private int $id;
6+
7+ private \stdClass $user;
8+ private \stdClass $pubUser;
9+ private ?string $token;
10+
11+ protected string $dataDir = ROOT."/data/user/";
12+ protected string $assDir = ROOT."/public/static/user/";
13+ protected int $minPassLen = 20;
14+ protected int $tokenDuration = 31536000; // 1年間
15+ protected string|int|null $algo = PASSWORD_ARGON2ID;
16+
17+ public function __construct(?string $username = null) {
18+ if (!AUTH_ENABLED) return;
19+ $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
20+ $this->token = \getcookie('kerozen');
21+ $this->id = $this->getUserId($username, $this->token);
22+ if ($this->id > 0) $this->user = $this->getUserData();
23+ }
24+
25+ public function getLoggedInUser(): \stdClass|null {
26+ if ($this->id === 0) return null;
27+ $this->pubUser = $this->user;
28+ unset($this->pubUser->password);
29+ unset($this->pubUser->tokens);
30+ return $this->pubUser;
31+ }
32+
33+ public function getUser(string $name): \stdClass|null {
34+ $user = $this->getUserDataFromName($name);
35+ if (!$user) return null;
36+ unset($user->password);
37+ unset($user->tokens);
38+ $myself = $this->getUserData();
39+ if (!$myself || ($myself->id != $user->id && $myself->role < \Roles::STAFF)) $user->email = '(秘密)';
40+ $user->name = namecolor($user);
41+ $user->regDate = date('Y年m月d日', $user->regDate);
42+ $user->gender = $user->gender === \Gender::MALE ? '男' : ($user->gender === \Gender::FEMALE ? '女' : '不明');
43+
44+ switch (true) {
45+ case ($user->role & \Roles::ADMIN):
46+ $user->role = '管理者';
47+ break;
48+ case ($user->role & \Roles::STAFF):
49+ $user->role = '076スタジオの会社員';
50+ break;
51+ case ($user->role & (\Roles::FSUBSCRIBER | \Roles::PSUBSCRIBER | \Roles::NSUBSCRIBER | \Roles::SUBSCRIBER)):
52+ $user->role = '支援者♡';
53+ break;
54+ case ($user->role & \Roles::BANNED):
55+ $user->role = 'BANされた';
56+ break;
57+ default:
58+ $user->role = 'ユーザー';
59+ break;
60+ }
61+
62+ $user->avatar = '/static/user/'.($user->avatar === '' ? 'noicon.png' : $user->username.'/'.$user->avatar);
63+ $user->altName = $user->displayname ?: $user->username;
64+ unset($user->username);
65+ unset($user->displayname);
66+ unset($user->namecolor);
67+ return $user;
68+ }
69+
70+ public function setToken(string $username, #[SensitiveParameter] string $password): \Result {
71+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
72+ $userData = $this->getUserData();
73+ if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
74+
75+ $verify = $this->verifyLogin($username, $password);
76+ if (!$verify->isSuccess) {
77+ return \Result::error($verify->message);
78+ }
79+
80+ $ip = $_SERVER['REMOTE_ADDR'];
81+ if (!empty($_SERVER['HTTP_X_FORMARDED_FOR'])) {
82+ $ipList = explode(',', $_SERVER['HTTP_X_FORMARDED_FOR']);
83+ $ip = trim($ipList[0]);
84+ }
85+
86+ $userData = $this->purgeOldTokens($userData);
87+ $token = bin2hex(random_bytes(64));
88+ $expire = time() + $this->tokenDuration;
89+ $newToken = [
90+ 'token' => $token,
91+ 'ip' => $ip,
92+ 'createDate' => time(),
93+ 'expDate' => $expire,
94+ 'lastDate' => time(),
95+ 'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '不明'
96+ ];
97+
98+ $userData->tokens[] = $newToken;
99+
100+ $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
101+ $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
102+
103+ if (file_put_contents($path, $json) === false) {
104+ logger(\LogType::Auth, '【setToken】JSONファイルにユーザーデータを保存出来なかった。パス:'.$path);
105+ return \Result::error('エラー:ユーザーデータの保存に失敗。');
106+ }
107+
108+ if (!setcookie('kerozen', $token, [
109+ 'expires' => $expire,
110+ 'path' => '/',
111+ 'domain' => $_SERVER['SERVER_NAME'],
112+ 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
113+ 'httponly' => true,
114+ 'samesite' => 'Strict'
115+ ])) {
116+ logger(\LogType::Auth, '【setToken】クッキーを設定出来なかった。トークン:'.$token);
117+ return \Result::error('エラー:クッキーを設定に失敗。');
118+ }
119+
120+ return \Result::success('ログイン成功');
121+ }
122+
123+ public function getToken(): string {
124+ if (!AUTH_ENABLED) return '';
125+ $userData = $this->getUserData();
126+ if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
127+ $userData = $this->purgeOldTokens($userData);
128+ }
129+
130+ public function logout(?string $token = null): \Result {
131+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
132+ $userData = $this->getUserData();
133+
134+ if (!$token) {
135+ $userData->tokens = array_filter($userData->tokens, function($t): bool {
136+ return ($t->expires ?? 0) > time();
137+ });
138+ }
139+
140+ setcookie('kerozen', null, [
141+ 'expires' => 0,
142+ 'path' => '/',
143+ 'domain' => $_SERVER['SERVER_NAME'],
144+ 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
145+ 'httponly' => true,
146+ 'samesite' => 'Strict'
147+ ]);
148+
149+ return \Result::success();
150+ }
151+
152+ public function isUserExist(?string $username = null): \Result {
153+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
154+ if (null === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。');
155+ $userList = scandir($this->dataDir);
156+
157+ foreach ($userList as $list) {
158+ if ($list === '.kara' || $list === '.' || $list === '..') continue;
159+ $file = str_replace('.json', '', $list);
160+ $user = explode('.', $file)[1];
161+ if ($username === $user) return \Result::Success("ユーザー「{$username}」は既に存在します。");
162+ }
163+
164+ return \Result::Error('');
165+ }
166+
167+ public function mkUser(?string $username = null, #[SensitiveParameter] ?string $password = null, ?string $passwordVerify = null, ?string $email = null): \Result {
168+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
169+ if (!AUTH_REGISTER_ENABLED) return \Result::Error('ユーザー登録は無効です。');
170+ $resUsr = $this->verifyUsername($username);
171+ $resPwd = $this->verifyPassword($password, $passwordVerify);
172+ $resEml = $this->verifyEmail($email);
173+
174+ $err = '';
175+
176+ if (!$resUsr->isSuccess) $err .= $resUsr->message."<br />";
177+ if (!$resPwd->isSuccess) $err .= $resPwd->message."<br />";
178+ if (!$resEml->isSuccess) $err .= $resEml->message."<br />";
179+
180+ if ($err != '') return \Result::Error($err);
181+ $err = '';
182+
183+ if (!mkdir($this->assDir.$username, 0755)) {
184+ logger(\LogType::Auth, '【mkUser】アセットディレクトリを作成出来なかった。chownをご確認下さい。パス:'.$this->assDir.$username);
185+ return \Result::Error('エラー:ユーザーのアイコンディレクトリの作成に失敗。');
186+ }
187+
188+ $file = scandir($this->dataDir);
189+ $lastId = 0;
190+ if ($file) {
191+ foreach ($file as $f) {
192+ if ($f === '.kara' || $f === '.' || $f === '..') continue;
193+ $base = substr($f, 0, -5); // .jsonの削除
194+ $dot = strpos($base, '.');
195+ if (!$dot) continue;
196+ $id = substr($base, 0, $dot);
197+ if (ctype_digit($id)) {
198+ if ((int)$id > $lastId) $lastId = $id;
199+ }
200+ }
201+ $lastId++;
202+ }
203+
204+ $user = new \stdClass;
205+ $user->id = $lastId;
206+ $user->username = $username;
207+ $user->password = password_hash($password, $this->algo);
208+ $user->avatar = '';
209+ $user->email = $email;
210+ $user->regDate = time();
211+ $user->namecolor = '';
212+ $user->displayname = '';
213+ $user->gender = \Gender::UNKNOWN;
214+ $user->role = \Roles::MEMBER;
215+ $user->tokens = [];
216+
217+ $path = "{$this->dataDir}{$lastId}.{$username}.json";
218+ $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
219+
220+ if (file_put_contents($path, $json) === false) {
221+ logger(\LogType::Auth, '【mkUser】JSONファイルを作成出来なかった。パス:'.$path);
222+ rmdir($this->assDir.$username);
223+ return \Result::Error('エラー:ユーザーデータの保存に失敗。');
224+ }
225+
226+ return \Result::Success();
227+ }
228+
229+ ////////////////////
230+
231+ private function verifyLogin(string $username, #[SensitiveParameter] string $password): \Result {
232+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
233+ $userData = $this->getUserData();
234+ if ($username !== $userData->username || !password_verify($password, $userData->password)) {
235+ return \Result::Error('エラー:ユーザー名又はパスワードが一致していません。');
236+ }
237+
238+ if (password_needs_rehash($userData->password, $this->algo)) {
239+ $userData->password = password_hash($password, $this->algo);
240+ $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
241+ $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
242+
243+ if (file_put_contents($path, $json) === false) {
244+ logger(\LogType::Auth, '【verifyLogin】JSONファイルを変更出来なかった。chownをご確認下さい。パス:'.$path);
245+ return \Result::Error('エラー:ユーザーデータの保存に失敗。');
246+ }
247+ }
248+
249+ return \Result::Success();
250+ }
251+
252+ private function purgeOldTokens(\stdClass $userData): \stdClass|\Result {
253+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
254+ // 有効期限切れたトークンの削除
255+ $out = $userData;
256+ $out->tokens = array_filter($userData->tokens, function($t): bool {
257+ return ($t->expDate ?? 0) > time();
258+ });
259+
260+ $path = $this->dataDir.$userData->id.'.'.$userData->username.'.json';
261+ $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
262+
263+ if (file_put_contents($path, $json) === false) {
264+ logger(\LogType::Auth, '【purgeOldTokens】JSONファイルを変更出来なかった。chownをご確認下さい。パス:'.$path);
265+ return \Result::Error('エラー:ユーザーデータの保存に失敗。');
266+ }
267+
268+ return $out;
269+ }
270+
271+ private function getUserId(?string $username = null, ?string $token = null): int {
272+ if (!AUTH_ENABLED) return 0;
273+ if (!$username && !$token) return 0;
274+ $file = scandir($this->dataDir);
275+ $matches = [];
276+ $id = 0;
277+
278+ foreach ($file as $f) {
279+ if ($f === '.kara' || $f === '.' || $f === '..') continue;
280+
281+ $path = "{$this->dataDir}{$f}";
282+ if (!is_file($path)) continue;
283+ $content = file_get_contents($path);
284+ if ($token && str_contains($content, $token)) {
285+ $matches[] = $path;
286+ break;
287+ } else if (!$token && str_contains($content, '"username": "'.$username.'",')) {
288+ $matches[] = $path;
289+ break;
290+ }
291+ }
292+ if (!isset($matches[0])) return 0;
293+ $id = (int)preg_replace('/^(.*?)\.(.*?)$/', "$1", str_replace($this->dataDir, '', $matches[0]));
294+
295+ return $id;
296+ }
297+
298+ private function getUserData(): ?\stdClass {
299+ if (!AUTH_ENABLED) return new \stdClass;
300+ $file = scandir($this->dataDir);
301+ $userFile = "";
302+ $matches = [];
303+
304+ foreach ($file as $f) {
305+ if ($f === '.kara' || $f === '.' || $f === '..') continue;
306+ if (preg_match('/^'.$this->id.'\.(.*?)\.json$/', $f, $matches)) {
307+ $userFile = $matches[0];
308+ break;
309+ }
310+ }
311+ $path = $this->dataDir.$userFile;
312+ unset($file, $userFile);
313+
314+ if (!file_exists($path) || !is_readable($path)) {
315+ kys('エラー');
316+ }
317+
318+ $fp = fopen($path, 'r');
319+ assert_not_null($fp);
320+ if (!$fp) kys('ユーザーが存在しない。');
321+ $lines = "";
322+ while (($buf = fgets($fp, 4096)) !== false) $lines .= $buf.PHP_EOL;
323+ if (!feof($fp)) kys("エラー:不明なエラー");
324+ fclose($fp);
325+ unset($path);
326+ return json_decode($lines);
327+ }
328+
329+ private function getUserDataFromName(string $name): \stdClass|null {
330+ if (!AUTH_ENABLED) return new \stdClass;
331+ $file = scandir($this->dataDir);
332+ $userFile = "";
333+ $matches = [];
334+
335+ foreach ($file as $f) {
336+ if ($f === '.kara' || $f === '.' || $f === '..') continue;
337+ if (preg_match('/^(.*?)\.(.*?)\.json$/', $f, $matches)) {
338+ if (isset($matches[2]) && $matches[2] === $name) {
339+ $userFile = $matches[0];
340+ break;
341+ }
342+ }
343+ }
344+ if (!$userFile) return null;
345+ $path = $this->dataDir.$userFile;
346+ unset($file, $userFile);
347+
348+ if (!file_exists($path) || !is_readable($path)) {
349+ kys('エラー');
350+ }
351+
352+ $fp = fopen($path, 'r');
353+ assert_not_null($fp);
354+ if (!$fp) kys('ユーザーが存在しない。');
355+ $lines = "";
356+ while (($buf = fgets($fp, 4096)) !== false) $lines .= $buf.PHP_EOL;
357+ if (!feof($fp)) kys("エラー:不明なエラー");
358+ fclose($fp);
359+ unset($path);
360+ return json_decode($lines);
361+ }
362+
363+ private function isEmailExist(?string $email = null): \Result {
364+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
365+ $userList = scandir($this->dataDir);
366+ $matches = [];
367+
368+ foreach ($userList as $f) {
369+ if ($f === '.kara' || $f === '.' || $f === '..') continue;
370+ $path = "{$this->dataDir}{$f}";
371+ if (!is_file($path)) continue;
372+ $content = file_get_contents($path);
373+ $file = str_replace('.json', '', $f);
374+ if (str_contains($content, '"email": "'.$email.'",')) {
375+ $matches[] = $email;
376+ }
377+ if (count($matches) > 0) return \Result::Error("ユーザー「{$email}」は既に存在します。");
378+ }
379+
380+ return \Result::Success('エラー:メールアドレスが存在しません。');
381+ }
382+
383+ private function verifyEmail(?string $email = null): \Result {
384+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
385+ if (null === $email || '' === $email) return \Result::Error('エラー:メールアドレスをご入力下さい。');
386+ if (strpos($email, '@') === false) return \Result::Error('エラー:メールアドレスは不正です。');
387+
388+ $domain = explode('@', $email)[1];
389+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
390+ return \Result::Error('エラー:メールアドレスは不正です。');
391+ }
392+
393+ if (!checkdnsrr($domain, 'MX')) {
394+ return \Result::Error('エラー:メールアドレスは不正です。');
395+ }
396+
397+ $res = $this->isEmailExist($email);
398+ if (!$res->isSuccess) return \Result::Error($res->message);
399+
400+ return \Result::Success();
401+ }
402+
403+ private function verifyUsername(?string $username): \Result {
404+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
405+ if (null === $username || '' === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。');
406+ if (strlen($username) < 6) return \Result::Error('エラー:ユーザー名は6文字以上をご入力下さい。');
407+
408+ $res = $this->isUserExist($username);
409+ if ($res->isSuccess) return \Result::Error($res->message);
410+
411+ $accepted = 'A-Za-z0-9';
412+ if (preg_match("/[^{$accepted}]/", $username)) {
413+ return \Result::Error('エラー:ユーザー名に不正な文字が含みます。');
414+ }
415+
416+ return \Result::Success();
417+ }
418+
419+ private function verifyPassword(#[SensitiveParameter] ?string $password = null, #[SensitiveParameter] ?string $passwordVerify = null): \Result {
420+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
421+ if (null === $password || '' === $password) return \Result::Error('エラー:パスワードをご入力下さい。');
422+ if ($password !== $passwordVerify) return \Result::Error('エラー:パスワードは一致していません。');
423+
424+ $res = $this->checkPasswordStandards($password);
425+ if (!$res->isSuccess) {
426+ return \Result::Error($res->message);
427+ }
428+
429+ return \Result::Success();
430+ }
431+
432+ private function checkPasswordStandards(#[SensitiveParameter] ?string $password = null): \Result {
433+ if (!AUTH_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
434+ if (strlen($password) < $this->minPassLen) return \Result::Error("エラー:パスワードは{$this->minPassLen}以上をご入力下さい。");
435+
436+ if (!\countmatch($password)) {
437+ return \Result::Error('エラー:パスワードに不正な文字が含みます。');
438+ }
439+
440+ $countUpper = preg_match_all('/[A-Z]/', $password) < 2;
441+ $countLower = preg_match_all('/[a-z]/', $password) < 2;
442+ $countDigit = preg_match_all('/[0-9]/', $password) < 2;
443+ $countSymbol = \count_special_chars($password) < 2;
444+
445+ if ($countUpper || $countLower || $countDigit || $countSymbol) {
446+ return \Result::Error('エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。');
447+ }
448+
449+ return \Result::Success();
450+ }
451+};
@@ -0,0 +1,693 @@
1+<?php
2+namespace Std\Lib;
3+
4+/**
5+ * php_curlへの依存を排除するための独自のCURL実装
6+ */
7+class Curl {
8+ // リクエスト関連のプロパティ
9+ private string $url = '';
10+ private string $method = 'GET';
11+ private int $timeout = 30;
12+ private array $headers = [];
13+ private array $cookies = [];
14+ private array $postFields = [];
15+ private string $postRaw = '';
16+ private string $userAgent = 'LittleBeast/1.0';
17+ private bool $followRedirects = true;
18+ private int $maxRedirects = 5;
19+ private bool $verbose = false;
20+ private $stderr = null;
21+ private string $caInfoPath = '';
22+ private bool $verifySSL = true;
23+ private string $username = '';
24+ private string $password = '';
25+ private string $referer = '';
26+
27+ // レスポンス関連のプロパティ
28+ private array $responseHeaders = [];
29+ private string $responseBody = '';
30+ private int $responseCode = 0;
31+ private string $responseError = '';
32+ private array $info = [];
33+
34+ /**
35+ * コンストラクタ
36+ *
37+ * @param string|null $url リクエスト先のURL
38+ */
39+ public function __construct(?string $url = null) {
40+ if (!CURL_ENABLED) return;
41+ if ($url !== null) {
42+ $this->url = $url;
43+ }
44+ }
45+
46+ /**
47+ * リクエスト先のURLを設定する
48+ *
49+ * @param string $url リクエスト先のURL
50+ * @return Curl このインスタンス(メソッドチェーン用)
51+ */
52+ public function setUrl(string $url): Curl {
53+ $this->url = $url;
54+ return $this;
55+ }
56+
57+ /**
58+ * リクエストメソッドを設定する
59+ *
60+ * @param string $method GE又はPOST等のHTTPメソッド
61+ * @return Curl このインスタンス(メソッドチェーン用)
62+ */
63+ public function setMethod(string $method): Curl {
64+ $this->method = strtoupper($method);
65+ return $this;
66+ }
67+
68+ /**
69+ * リクエストのタイムアウト秒数を設定する
70+ *
71+ * @param int $seconds タイムアウト秒数
72+ * @return Curl このインスタンス(メソッドチェーン用)
73+ */
74+ public function setTimeout(int $seconds): Curl {
75+ $this->timeout = (int)$seconds;
76+ return $this;
77+ }
78+
79+ /**
80+ * リクエストヘッダーを設定する
81+ *
82+ * @param array $headers リクエストヘッダーの配列
83+ * @return Curl このインスタンス(メソッドチェーン用)
84+ */
85+ public function setHeaders(array $headers): Curl {
86+ $this->headers = $headers;
87+ return $this;
88+ }
89+
90+ /**
91+ * 単一のヘッダーを追加する
92+ *
93+ * @param string $name ヘッダー名
94+ * @param mixed $value ヘッダー値
95+ * @return Curl このインスタンス(メソッドチェーン用)
96+ */
97+ public function addHeader(string $name, mixed $value): Curl {
98+ $this->headers[$name] = $value;
99+ return $this;
100+ }
101+
102+ /**
103+ * リクエストのクッキーを設定する
104+ *
105+ * @param array $cookies クッキーの配列
106+ * @return Curl このインスタンス(メソッドチェーン用)
107+ */
108+ public function setCookies(array $cookies): Curl {
109+ $this->cookies = $cookies;
110+ return $this;
111+ }
112+
113+ /**
114+ * 単一のクッキーを追加する
115+ *
116+ * @param string $name クッキー名
117+ * @param mixed $value クッキー値
118+ * @return Curl このインスタンス(メソッドチェーン用)
119+ */
120+ public function addCookie(string $name, mixed $value): Curl {
121+ $this->cookies[$name] = $value;
122+ return $this;
123+ }
124+
125+ /**
126+ * POSTフィールドを設定する
127+ *
128+ * @param array $fields POSTデータの配列
129+ * @return Curl このインスタンス(メソッドチェーン用)
130+ */
131+ public function setPostFields(array $fields): Curl {
132+ $this->postFields = $fields;
133+ return $this;
134+ }
135+
136+ /**
137+ * 生のPOSTデータを設定する
138+ *
139+ * @param string $data 生のPOSTデータ
140+ * @return Curl このインスタンス(メソッドチェーン用)
141+ */
142+ public function setPostRaw(string $data): Curl {
143+ $this->postRaw = $data;
144+ return $this;
145+ }
146+
147+ /**
148+ * ユーザーエージェントを設定する
149+ *
150+ * @param string $userAgent カスタムユーザーエージェント
151+ * @return Curl このインスタンス(メソッドチェーン用)
152+ */
153+ public function setUserAgent(string $userAgent): Curl {
154+ $this->userAgent = $userAgent;
155+ return $this;
156+ }
157+
158+ /**
159+ * リダイレクトを追跡するかどうかを設定する
160+ *
161+ * @param bool $follow 追跡するかどうか(デフォルトはtrue)
162+ * @return Curl このインスタンス(メソッドチェーン用)
163+ */
164+ public function setFollowRedirects(bool $follow): Curl {
165+ $this->followRedirects = (bool)$follow;
166+ return $this;
167+ }
168+
169+ /**
170+ * 追跡するリダイレクトの最大数を設定する
171+ *
172+ * @param int $max リダイレクトの最大数
173+ * @return Curl このインスタンス(メソッドチェーン用)
174+ */
175+ public function setMaxRedirects(int $max): Curl {
176+ $this->maxRedirects = (int)$max;
177+ return $this;
178+ }
179+
180+ /**
181+ * SSL証明書を検証するかどうかを設定する
182+ *
183+ * @param bool $verify SSL検証を行うかどうか(デフォルトはtrue)
184+ * @return Curl このインスタンス(メソッドチェーン用)
185+ */
186+ public function setVerifySSL(bool $verify): Curl {
187+ $this->verifySSL = (bool)$verify;
188+ return $this;
189+ }
190+
191+ /**
192+ * 基本認証の資格情報を設定する
193+ *
194+ * @param string $username ユーザー名
195+ * @param string $password パスワード
196+ * @return Curl このインスタンス(メソッドチェーン用)
197+ */
198+ public function setBasicAuth(string $username, string $password): Curl {
199+ $this->username = $username;
200+ $this->password = $password;
201+ return $this;
202+ }
203+
204+ /**
205+ * リファラーURLを設定する
206+ *
207+ * @param string $referer リファラーURL
208+ * @return Curl このインスタンス(メソッドチェーン用)
209+ */
210+ public function setReferer(string $referer): Curl {
211+ $this->referer = $referer;
212+ return $this;
213+ }
214+
215+ /**
216+ * 詳細ログを有効にする
217+ *
218+ * @param bool $verbose 詳細ログを有効にするかどうか
219+ * @return Curl このインスタンス(メソッドチェーン用)
220+ */
221+ public function setVerbose(bool $verbose): Curl {
222+ $this->verbose = (bool)$verbose;
223+ return $this;
224+ }
225+
226+ /**
227+ * エラー出力先を設定する
228+ *
229+ * @param resource $handle エラー出力先のファイルハンドル
230+ * @return Curl このインスタンス(メソッドチェーン用)
231+ */
232+ public function setStderr($handle): Curl {
233+ $this->stderr = $handle;
234+ return $this;
235+ }
236+
237+ /**
238+ * SSL証明書のファイルパスを設定する
239+ *
240+ * @param string $path 証明書ファイルのパス
241+ * @return Curl このインスタンス(メソッドチェーン用)
242+ */
243+ public function setCaInfo(string $path): Curl {
244+ $this->caInfoPath = $path;
245+ return $this;
246+ }
247+
248+ /**
249+ * リクエストを実行する
250+ *
251+ * @return Result 成功または失敗
252+ */
253+ public function execute(): \Result {
254+ if (!CURL_ENABLED) return \Result::Error('エラー:認証システムは無効です。');
255+ if (empty($this->url)) {
256+ $this->responseError = 'URLがありません';
257+ return \Result::Error($this->responseError);
258+ }
259+
260+ // レスポンスデータのリセット
261+ $this->responseHeaders = [];
262+ $this->responseBody = '';
263+ $this->responseCode = 0;
264+ $this->responseError = '';
265+ $this->info = [
266+ 'url' => $this->url,
267+ 'content_type' => '',
268+ 'http_code' => 0,
269+ 'header_size' => 0,
270+ 'request_size' => 0,
271+ 'total_time' => 0,
272+ 'redirect_count' => 0,
273+ 'redirect_url' => '',
274+ ];
275+
276+ $startTime = microtime(true);
277+
278+ // ソケットベースの実装を使用する
279+ $redirectCount = 0;
280+ $currentUrl = $this->url;
281+ $originalMethod = $this->method;
282+
283+ do {
284+ if ($this->verbose && $this->stderr) {
285+ fwrite($this->stderr, "* 接続中: {$currentUrl}\n");
286+ }
287+
288+ $parsed = parse_url($currentUrl);
289+ if (!$parsed) {
290+ $this->responseError = "無効なURL: {$currentUrl}";
291+ return \Result::Error($this->responseError);
292+ }
293+
294+ $scheme = isset($parsed['scheme']) ? strtolower($parsed['scheme']) : 'http';
295+ $host = $parsed['host'];
296+ $port = isset($parsed['port'])
297+ ? $parsed['port'] : ($scheme === 'https' ? 443 : 80);
298+ $path = isset($parsed['path']) ? $parsed['path'] : '/';
299+ if (isset($parsed['query'])) {
300+ $path .= '?'.$parsed['query'];
301+ }
302+
303+ // Basic認証
304+ $authHeader = '';
305+ if (!empty($this->username) && !empty($this->password)) {
306+ $authHeader = "Authorization: Basic "
307+ .base64_encode($this->username.':'.$this->password)."\r\n";
308+ } elseif (isset($parsed['user']) && isset($parsed['pass'])) {
309+ $authHeader = "Authorization: Basic "
310+ .base64_encode($parsed['user'].':'.$parsed['pass'])."\r\n";
311+ }
312+
313+ // 送信するHTTPリクエストを構築
314+ $method = $this->method;
315+ $httpData = '';
316+
317+ if ($method === 'POST' || $method === 'PUT') {
318+ if (!empty($this->postRaw)) {
319+ $httpData = $this->postRaw;
320+ } elseif (!empty($this->postFields)) {
321+ $httpData = http_build_query($this->postFields);
322+ if (!isset($this->headers['Content-Type'])) {
323+ $this->headers['Content-Type'] = 'application/x-www-form-urlencoded';
324+ }
325+ }
326+ $this->headers['Content-Length'] = strlen($httpData);
327+ }
328+
329+ // HTTPリクエストを構築
330+ $accept = 'Accept: */*';
331+ foreach ($this->headers as $h) {
332+ if (str_contains($h, 'Accept:')) $accept = $h;
333+ }
334+
335+ $request = "{$method} {$path} HTTP/1.1\r\n";
336+ $request .= "Host: {$host}\r\n";
337+ $request .= "User-Agent: {$this->userAgent}\r\n";
338+ $request .= "{$accept}\r\n";
339+ $request .= "Connection: close\r\n";
340+
341+ if (!empty($authHeader)) {
342+ $request .= $authHeader;
343+ }
344+
345+ // ヘッダーを追加
346+ foreach ($this->headers as $name => $value) {
347+ $request .= "{$name}: {$value}\r\n";
348+ }
349+
350+ // リファラーが設定されていれば追加
351+ if (!empty($this->referer) && !isset($this->headers['Referer'])) {
352+ $request .= "Referer: {$this->referer}\r\n";
353+ }
354+
355+ // クッキーヘッダーを追加
356+ if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
357+ $cookieStrings = [];
358+ foreach ($this->cookies as $name => $value) {
359+ $cookieStrings[] = $name.'='.urlencode($value);
360+ }
361+ $request .= 'Cookie: '.implode('; ', $cookieStrings)."\r\n";
362+ }
363+
364+ $request .= "\r\n";
365+
366+ // POSTデータを追加
367+ if ($method === 'POST' || $method === 'PUT') {
368+ $request .= $httpData;
369+ }
370+
371+ if ($this->verbose && $this->stderr) {
372+ fwrite($this->stderr, "* リクエストヘッダー:\n{$request}\n");
373+ }
374+
375+ // ソケット接続を確立
376+ $errno = 0;
377+ $errstr = '';
378+
379+ if ($scheme === 'https') {
380+ $sslOptions = [
381+ 'verify_peer' => $this->verifySSL,
382+ 'verify_peer_name' => $this->verifySSL
383+ ];
384+
385+ if (!empty($this->caInfoPath) && file_exists($this->caInfoPath)) {
386+ $sslOptions['cafile'] = $this->caInfoPath;
387+ }
388+
389+ $context = stream_context_create(['ssl' => $sslOptions]);
390+
391+ $socket = @stream_socket_client(
392+ "tls://{$host}:{$port}",
393+ $errno,
394+ $errstr,
395+ $this->timeout,
396+ STREAM_CLIENT_CONNECT,
397+ $context
398+ );
399+ } else {
400+ $socket = @fsockopen($host, $port, $errno, $errstr, $this->timeout);
401+ }
402+
403+ if (!$socket) {
404+ $this->responseError = "接続出来ません: {$errstr} ({$errno})";
405+ if ($this->verbose && $this->stderr) {
406+ fwrite($this->stderr, "* エラー: {$this->responseError}\n");
407+ }
408+ return \Result::Error($this->responseError);
409+ }
410+
411+ // タイムアウトを設定
412+ stream_set_timeout($socket, $this->timeout);
413+
414+ // リクエストを送信
415+ fwrite($socket, $request);
416+
417+ // レスポンスを読み込む
418+ $rawResponse = '';
419+ $headers = '';
420+ $body = '';
421+ $headersComplete = false;
422+
423+ // ヘッダーとボディを分けて読み込む
424+ while (!feof($socket)) {
425+ $line = fgets($socket);
426+ if ($line === false) {
427+ break;
428+ }
429+
430+ $rawResponse .= $line;
431+
432+ if (!$headersComplete) {
433+ if (trim($line) === '') {
434+ $headersComplete = true;
435+ } else {
436+ $headers .= $line;
437+ }
438+ } else {
439+ $body .= $line;
440+ }
441+ }
442+
443+ fclose($socket);
444+
445+ // レスポンスヘッダーを解析
446+ $headerLines = explode("\r\n", $headers);
447+
448+ // ステータスコードを取得
449+ $statusLine = isset($headerLines[0]) ? $headerLines[0] : '';
450+ $statusParts = explode(' ', $statusLine, 3);
451+ $this->responseCode = isset($statusParts[1]) ? (int)$statusParts[1] : 0;
452+ $this->info['http_code'] = $this->responseCode;
453+
454+ // ヘッダーを解析
455+ $this->responseHeaders = [];
456+ $redirectUrl = '';
457+
458+ foreach ($headerLines as $index => $header) {
459+ if ($index === 0) continue;
460+
461+ if (strpos($header, ':') !== false) {
462+ list($name, $value) = explode(':', $header, 2);
463+ $name = trim($name);
464+ $value = trim($value);
465+ $this->responseHeaders[$name] = $value;
466+
467+ if (strtolower($name) === 'content-type') {
468+ $this->info['content_type'] = $value;
469+ }
470+
471+ // リダイレクトをチェック
472+ if ($this->followRedirects &&
473+ strtolower($name) === 'location' &&
474+ $this->responseCode >= 300 &&
475+ $this->responseCode < 400) {
476+
477+ $redirectUrl = $value;
478+
479+ // 相対URLを絶対URLに変換
480+ if (strpos($redirectUrl, 'http') !== 0) {
481+ if ($redirectUrl[0] === '/') {
482+ $redirectUrl = "{$scheme}://{$host}"
483+ .($port != 80 && $port != 443 ? ":{$port}" : '').$redirectUrl;
484+ } else {
485+ $redirectUrl = "{$scheme}://{$host}"
486+ .($port != 80 && $port != 443 ? ":{$port}" : '')
487+ .dirname($path).'/'.$redirectUrl;
488+ }
489+ }
490+
491+ $this->info['redirect_url'] = $redirectUrl;
492+ }
493+ }
494+ }
495+
496+ $this->info['header_size'] += strlen($headers);
497+ $this->responseBody .= $body;
498+
499+ if ($this->verbose && $this->stderr) {
500+ fwrite($this->stderr, "* レスポンスコード: {$this->responseCode}\n");
501+ fwrite($this->stderr, "* レスポンスヘッダー:\n{$headers}\n");
502+ if (!empty($redirectUrl)) {
503+ fwrite($this->stderr, "* リダイレクト先: {$redirectUrl}\n");
504+ }
505+ }
506+
507+ // リダイレクトが必要な場合
508+ if (!empty($redirectUrl) && $redirectCount < $this->maxRedirects) {
509+ $currentUrl = $redirectUrl;
510+ $redirectCount++;
511+ $this->info['redirect_count'] = $redirectCount;
512+
513+ // 302や303リダイレクトはGETにメソッドを変更
514+ if ($this->responseCode == 302 || $this->responseCode == 303) {
515+ $this->method = 'GET';
516+ $this->postRaw = '';
517+ $this->postFields = [];
518+ }
519+ } else {
520+ break;
521+ }
522+ } while (true);
523+
524+ // リクエスト完了後、元のメソッドに戻す
525+ $this->method = $originalMethod;
526+ $this->info['total_time'] = microtime(true) - $startTime;
527+
528+ return \Result::Success();
529+ }
530+
531+ /**
532+ * レスポンスボディを取得する
533+ *
534+ * @return string レスポンスボディ
535+ */
536+ public function getResponseBody(): string {
537+ return $this->responseBody;
538+ }
539+
540+ /**
541+ * レスポンスヘッダーを取得する
542+ *
543+ * @return array レスポンスヘッダーの配列
544+ */
545+ public function getResponseHeaders(): array {
546+ return $this->responseHeaders;
547+ }
548+
549+ /**
550+ * HTTPレスポンスコードを取得する
551+ *
552+ * @return int HTTPレスポンスコード
553+ */
554+ public function getResponseCode(): int {
555+ return $this->responseCode;
556+ }
557+
558+ /**
559+ * エラーメッセージがあれば取得する
560+ *
561+ * @return string エラーメッセージ
562+ */
563+ public function getError(): string {
564+ return $this->responseError;
565+ }
566+
567+ /**
568+ * リクエスト/レスポンス情報を取得する
569+ *
570+ * @return array 情報の配列
571+ */
572+ public function getInfo(): array {
573+ return $this->info;
574+ }
575+
576+ // 機能性メソッド
577+
578+ /**
579+ * リダイレクトURLを確認する
580+ *
581+ * @param string $name ヘッダー名
582+ * @param string $value ヘッダー値
583+ * @param string $currentUrl 現在のURL
584+ * @return string リダイレクトURL、リダイレクトがない場合は空文字
585+ */
586+ private function checkReds(string $name, string $value, string $currentUrl): string {
587+ $redirectUrl = '';
588+
589+ if ($this->followRedirects && (strtolower($name) === 'location'
590+ && $this->responseCode >= 300 && $this->responseCode < 400)) {
591+ $redirectUrl = $value;
592+
593+ if (strpos($redirectUrl, 'http') !== 0) {
594+ if ($redirectUrl[0] === '/') {
595+ $parsed = parse_url($currentUrl);
596+ $redirectUrl = $parsed['scheme'].'://'.$parsed['host']
597+ .(isset($parsed['port']) ? ':'.$parsed['port'] : '')
598+ .$redirectUrl;
599+ } else {
600+ $redirectUrl = dirname($currentUrl).'/'.$redirectUrl;
601+ }
602+ }
603+ }
604+
605+ return $redirectUrl;
606+ }
607+
608+ /**
609+ * ヘッダー文字列を構築する
610+ *
611+ * @return string 構築されたヘッダー文字列
612+ */
613+ private function buildHeaderString(): string {
614+ $headers = [];
615+
616+ // ユーザー指定のヘッダーを追加
617+ foreach ($this->headers as $name => $value) {
618+ $headers[] = "{$name}: {$value}";
619+ }
620+
621+ // リファラーが設定されていれば追加
622+ if (!empty($this->referer) && !isset($this->headers['Referer'])) {
623+ $headers[] = "Referer: {$this->referer}";
624+ }
625+
626+ // 必要に応じてクッキーヘッダーを追加
627+ if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
628+ $cookieStrings = [];
629+ foreach ($this->cookies as $name => $value) {
630+ $cookieStrings[] = $name.'='.urlencode($value);
631+ }
632+
633+ $headers[] = 'Cookie: '.implode('; ', $cookieStrings);
634+ }
635+
636+ return implode("\r\n", $headers)."\r\n";
637+ }
638+
639+ /**
640+ * レスポンスを解析してヘッダーとボディに分割する
641+ *
642+ * @param string $response 完全なHTTPレスポンス
643+ * @return array [ヘッダー配列, ボディ文字列]
644+ */
645+ private function parseResponse(string $response): array {
646+ $parts = explode("\r\n\r\n", $response, 2);
647+
648+ if (count($parts) < 2) {
649+ return [[], ''];
650+ }
651+
652+ $headers = explode("\r\n", $parts[0]);
653+ $body = $parts[1];
654+
655+ // チャンク転送エンコーディングを処理
656+ if (isset($this->responseHeaders['Transfer-Encoding']) &&
657+ strtolower($this->responseHeaders['Transfer-Encoding']) === 'chunked') {
658+ $body = $this->decodeChunkedBody($body);
659+ }
660+
661+ return [$headers, $body];
662+ }
663+
664+ /**
665+ * チャンク転送エンコーディングされたボディをデコードする
666+ *
667+ * @param string $body チャンクエンコードされたボディ
668+ * @return string デコードされたボディ
669+ */
670+ private function decodeChunkedBody(string $body): string {
671+ $decodedBody = '';
672+ $position = 0;
673+
674+ while ($position < strlen($body)) {
675+ $lineEnd = strpos($body, "\r\n", $position);
676+ if ($lineEnd === false) {
677+ break;
678+ }
679+
680+ $chunkSize = hexdec(substr($body, $position, $lineEnd - $position));
681+
682+ if ($chunkSize === 0) {
683+ break;
684+ }
685+
686+ $position = $lineEnd + 2;
687+ $decodedBody .= substr($body, $position, $chunkSize);
688+ $position += $chunkSize + 2; // チャンクサイズ + CRLF
689+ }
690+
691+ return $decodedBody;
692+ }
693+}
@@ -0,0 +1,130 @@
1+<?php
2+namespace Std\Lib;
3+
4+class DiffViewer {
5+ private $diffContent;
6+
7+ public function __construct(string $filePath) {
8+ if (!file_exists($filePath)) {
9+ throw new \Exception("Diff file not found: $filePath");
10+ }
11+ $this->diffContent = file_get_contents($filePath);
12+ }
13+
14+ public function displaySideBySide(): string {
15+ $lines = explode("\n", $this->diffContent);
16+ $fileDiffs = [];
17+ $currentFile = null;
18+ $hunk = [];
19+ $lineNumbers = ['left' => 0, 'right' => 0];
20+ $currentLeftLines = [];
21+ $currentRightLines = [];
22+
23+ foreach ($lines as $line) {
24+ // ファイルヘッダーの処理
25+ if (preg_match('/^---\s+(.+)/', $line, $matches)) {
26+ // ファイルを処理する場合、データを保存する
27+ if ($currentFile !== null) {
28+ $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
29+ $fileDiffs[$currentFile] = [
30+ 'leftLines' => $currentLeftLines,
31+ 'rightLines' => $currentRightLines
32+ ];
33+ $hunk = [];
34+ $currentLeftLines = [];
35+ $currentRightLines = [];
36+ $lineNumbers = ['left' => 0, 'right' => 0];
37+ }
38+ $currentFile = $matches[1];
39+ continue;
40+ }
41+ if (preg_match('/^\+\+\+\s+(.+)/', $line)) {
42+ continue;
43+ }
44+
45+ // ハンクヘッダーの処理 (例:@@ -10,6 +10,7 @@)
46+ if (preg_match('/^@@\s+-(\d+),\d+\s+\+(\d+),\d+\s+@@/', $line, $matches)) {
47+ $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
48+ $hunk = [];
49+ $lineNumbers['left'] = (int)$matches[1];
50+ $lineNumbers['right'] = (int)$matches[2];
51+ continue;
52+ }
53+
54+ // ハンクでの行列の集まり
55+ if (substr($line, 0, 1) === '-' || substr($line, 0, 1) === '+' || substr($line, 0, 1) === ' ') {
56+ $hunk[] = $line;
57+ }
58+ }
59+
60+ // 最後のハンク・ファイルの処理
61+ if ($currentFile !== null) {
62+ $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
63+ $fileDiffs[$currentFile] = [
64+ 'leftLines' => $currentLeftLines,
65+ 'rightLines' => $currentRightLines
66+ ];
67+ }
68+
69+ // 各ファイルにHTMLの出力の作成
70+ $html = '';
71+ foreach ($fileDiffs as $fileName => $diff) {
72+ $html .= "\n <h2>ファイル: ".htmlspecialchars($fileName)."</h2>\n";
73+ $html .= $this->generateHtml($diff['leftLines'], $diff['rightLines']);
74+ }
75+
76+ return $html;
77+ }
78+
79+ private function processHunk(array $hunk, array &$leftLines, array &$rightLines, array &$lineNumbers): void {
80+ foreach ($hunk as $line) {
81+ $prefix = substr($line, 0, 1);
82+ $content = substr($line, 1);
83+
84+ if ($prefix === '-') {
85+ $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'removed', 'line' => $lineNumbers['left']];
86+ $lineNumbers['left']++;
87+ } elseif ($prefix === '+') {
88+ $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'added', 'line' => $lineNumbers['right']];
89+ $lineNumbers['right']++;
90+ } elseif ($prefix === ' ') {
91+ // 両側のコンテキストは同じ行列があるかの確認
92+ while ($lineNumbers['left'] < $lineNumbers['right']) {
93+ $leftLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['left']];
94+ $lineNumbers['left']++;
95+ }
96+ while ($lineNumbers['right'] < $lineNumbers['left']) {
97+ $rightLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['right']];
98+ $lineNumbers['right']++;
99+ }
100+ $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['left']];
101+ $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['right']];
102+ $lineNumbers['left']++;
103+ $lineNumbers['right']++;
104+ }
105+ }
106+ }
107+
108+ private function generateHtml(array $leftLines, array $rightLines): string {
109+ $html = " <p>\n <table class=\"diff-table\">\n";
110+ $html .= " <tr class=\"diff-header\"><th colspan=\"2\">前</th><th colspan=\"2\">新</th></tr>\n";
111+
112+ $maxLines = max(count($leftLines), count($rightLines));
113+ for ($i = 0; $i < $maxLines; $i++) {
114+ $left = isset($leftLines[$i]) ? $leftLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
115+ $right = isset($rightLines[$i]) ? $rightLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
116+
117+ $html .= " <tr>\n";
118+ // 左(変更前)
119+ $html .= " <td class=\"line-number\">".($left['line'] ?: '&nbsp;')."</td>\n";
120+ $html .= " <td class=\"{$left['type']}\">".($left['content'] ?: '&nbsp;')."</td>\n";
121+ // 右(変更後)
122+ $html .= " <td class=\"line-number\">".($right['line'] ?: '&nbsp;')."</td>\n";
123+ $html .= " <td class=\"{$right['type']}\">".($right['content'] ?: '&nbsp;')."</td>\n";
124+ $html .= " </tr>\n";
125+ }
126+
127+ $html .= " </table>\n </p>\n";
128+ return $html;
129+ }
130+}
@@ -0,0 +1,79 @@
1+<?php
2+namespace Std\Lib;
3+
4+use Std\Lib\Image\ImageInterface;
5+use Std\Lib\Image\Gif;
6+use Std\Lib\Image\Jpeg;
7+use Std\Lib\Image\Png;
8+use Std\Lib\Image\Targa;
9+
10+class RGB {
11+ public int $r;
12+ public int $g;
13+ public int $b;
14+ public array $rgb;
15+
16+ public function __construct(int $r, int $g, int $b) {
17+ $this->r = $r;
18+ $this->g = $g;
19+ $this->b = $b;
20+ $this->rgb = ['r' => $r, 'g' => $g, 'b' => $b];
21+ }
22+}
23+
24+class Image {
25+ public string $filename = 'UNKNOWN';
26+ public string $extension = 'UNKNOWN';
27+ public string $type = 'UNSUPPORTED';
28+ public string $size = '0 B';
29+ public int $bytes = 0;
30+ public \stdClass $fullInfo;
31+
32+ private array $supported = [
33+ 'image/gif' => Gif::class,
34+ 'image/jpeg' => Jpeg::class,
35+ 'image/png' => Png::class,
36+ 'image/x-tga' => Targa::class,
37+ ];
38+
39+ public function __construct(string $file) {
40+ $file = ROOT.$file;
41+ // $file = ROOT.'/public/static/article/mock-screenshot.tga';
42+ if (!file_exists($file)) return;
43+ $fp = fopen($file, 'rb');
44+ if (!$fp) return;
45+ $extBytes = fread($fp, 15);
46+ fclose($fp);
47+
48+ $this->bytes = filesize($file);
49+ $this->size = $this->formatBytes($this->bytes);
50+ $this->extension = pathinfo($file, PATHINFO_EXTENSION);
51+ $this->filename = str_replace('.'.$this->extension, '', array_last(explode('/', $file)));
52+
53+ if (substr($extBytes, 0, 3) === "\xff\xd8\xff") $this->type = 'image/jpeg';
54+ else if (substr($extBytes, 0, 15) === "\x52\x49\x46\x46\x1a\x28\x00\x00\x57\x45\x42\x50\x56\x50\x38") $this->type = 'image/webp';
55+ else if (substr($extBytes, 0, 3) === "\x42\x4d\x36") $this->type = 'image/bmp';
56+ else if (substr($extBytes, 0, 6) === "\x89\x50\x4e\x47\x0d\x0a") $this->type = 'image/png';
57+ else if (substr($extBytes, 0, 6) === "\x47\x49\x46\x38\x37\x61" || substr($extBytes, 0, 6) === "\x47\x49\x46\x38\x39\x61") $this->type = 'image/gif';
58+ else if (str_ends_with($file, '.tga')) $this->type = 'image/x-tga';
59+
60+ $this->fullInfo = $this->createHandler($this->type)->parse($file);
61+ $this->fullInfo->type = $this->type;
62+ $this->fullInfo->size = $this->size;
63+ $this->fullInfo->bytes = $this->bytes;
64+ }
65+
66+ ///////////////
67+
68+ private function createHandler(string $ext): ImageInterface {
69+ $class = $this->supported[$ext] ?? null;
70+ if (!$class) throw new \RuntimeException("形式のハンドルが存在しない: {$ext}");
71+ return new $class();
72+ }
73+
74+ private function formatBytes(int $size, int $precision = 2): string {
75+ $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
76+ for ($i = 0; $size > 1024 && $i < count($units) - 1; ++$i) $size /= 1024;
77+ return round($size, $precision).' '.$units[$i];
78+ }
79+}
@@ -0,0 +1,91 @@
1+<?php
2+namespace Std\Lib\Image;
3+
4+use Std\Lib\Image\ImageInterface;
5+
6+class Gif implements ImageInterface {
7+ public int $width = 0;
8+ public int $height = 0;
9+ public int $bitDepth = 0;
10+ public int $numColors = 0;
11+ public string $compression = 'NONE';
12+ public int $interlace = 0;
13+ public int $bgColor = 0;
14+ public float $delayTime = 0;
15+ public array $globalColorTable = [];
16+ public int $numTransparentPixels = 0;
17+ public int $lzwMinCodeSize = 0;
18+ public string $imageData = '';
19+
20+ /**
21+ * TODO: 現在、インタレースした画像が未対応です。
22+ */
23+ public function parse(string $file): \stdClass {
24+ $gif = new \stdClass();
25+ $fp = fopen($file, 'rb');
26+ $magic = fread($fp, 6); // マジックのスキップ
27+
28+ // v = リトルエンディアン(2ビット)
29+ $this->width = unpack('v', fread($fp, 2))[1];
30+ $this->height = unpack('v', fread($fp, 2))[1];
31+ $packed = ord(fread($fp, 1));
32+ $this->bitDepth = ($packed & 0x07) + 1;
33+ $this->numColors = 1 << $this->bitDepth;
34+ $hasGct = $this->bitDepth === 8;
35+
36+ $gif->width = $this->width;
37+ $gif->height = $this->height;
38+ $gif->bitDepth = $this->bitDepth;
39+ $gif->numColors = $this->numColors;
40+ if ($magic === 'GIF87a' || !$hasGct) return $gif; // それ以外情報がない
41+
42+ $this->bgColor = (int)bin2hex(fread($fp, 1));
43+ $gif->bgColor = $this->bgColor;
44+ bin2hex(fread($fp, 1)); // いつでも 0:0?
45+
46+ $gceLen = 0;
47+ while (!feof($fp)) {
48+ $skip = bin2hex(fread($fp, 3));
49+ $color = new RGB((int)($skip[0].$skip[1]), (int)($skip[2].$skip[3]), (int)($skip[4].$skip[5]));
50+ $this->globalColorTable[] = $color->rgb;
51+ if ($skip[0].$skip[1] === '21' && $skip[2].$skip[3] === 'f9') {
52+ $gceLen = (int)($skip[4].$skip[5]);
53+ break;
54+ }
55+ }
56+
57+ // GCE開始
58+ $this->hasAlpha = (ord(fread($fp, 1)) & 0x01) === 0x01;
59+ $this->delayTime = (unpack('v', fread($fp, 2))[1]) / 100.0;
60+ $this->numTransparentPixels = ord(fread($fp, 1));
61+ $gif->hasAlpha = $this->hasAlpha;
62+ $gif->delayTime = $this->delayTime;
63+ $gif->numTransparentPixels = $this->numTransparentPixels;
64+ fread($fp, 1); // GCE終了
65+ fread($fp, 1); // 画像指定子開始
66+ fread($fp, 4); // 画面のXとY、いつでも(0, 0)
67+ fread($fp, 4); // 画像のサイズ、関数の開始で既に保存した
68+ $pack = ord(fread($fp, 1));
69+ $this->interlace = ($pack & 0x40); // 画像のサイズ、関数の開始で既に保存した
70+
71+ $this->lzwMinCodeSize = ord(fread($fp, 1)); // 画像データ開始
72+ $gif->lzwMinCodeSize = $this->lzwMinCodeSize;
73+ $imageData = '';
74+ while (true) {
75+ $blockSize = ord(fread($fp, 1));
76+ if ($blockSize === 0) break;
77+ $imageData .= fread($fp, 1);
78+ }
79+
80+ $this->imageData = base64_encode($imageData);
81+ $this->colorType = 3;
82+ $this->compression = 'LZW';
83+ $gif->colorType = $this->colorType;
84+ $gif->compression = $this->compression;
85+ $gif->globalColorTable = $this->globalColorTable;
86+ $gif->imageData = $this->imageData;
87+
88+ fclose($fp);
89+ return $gif;
90+ }
91+}
@@ -0,0 +1,6 @@
1+<?php
2+namespace Std\Lib\Image;
3+
4+interface ImageInterface {
5+ public function parse(string $file): \stdClass;
6+}
@@ -0,0 +1,54 @@
1+<?php
2+namespace Std\Lib\Image;
3+
4+use Std\Lib\Image\ImageInterface;
5+
6+class Jpeg implements ImageInterface {
7+ public int $width = 0;
8+ public int $height = 0;
9+ public int $bpp = 0;
10+ public string $imageData = '';
11+
12+ /**
13+ * TODO: 現在、IHDRヘッダーのみを受け取る
14+ */
15+ public function parse(string $file): \stdClass {
16+ $jpg = new \stdClass;
17+ $fp = fopen($file, 'rb');
18+
19+ $i = 0;
20+ while (!feof($fp)) {
21+ $byte = fread($fp, 1);
22+ if ($byte !== "\xFF") continue;
23+
24+ $marker = ord(fread($fp, 1));
25+ if ($marker >= 0xC0 && $marker <= 0xC3) { // 0xC0 = SOF0, 0xC1 = SOF1, 0xC2 = SOF2, 0xC3 = SOF3
26+ fread($fp, 2);
27+ $this->bpp = ord(fread($fp, 1));
28+ // n = ビッグエンディアン(2ビット)
29+ $this->height = unpack('n', fread($fp, 2))[1];
30+ $this->width = unpack('n', fread($fp, 2))[1];
31+ $jpg->width = $this->width;
32+ $jpg->height = $this->height;
33+ $jpg->bpp = $this->bpp;
34+ } else if ($marker == 0xC4) { // DHT (Huffmanテーブル(複数可))
35+ //
36+ } else if ($marker == 0xDB) { // DQT (量子化テーブル(複数可))
37+ //
38+ } else if ($marker == 0xDD) { // DRI (リセット期間)
39+ } else if ($marker == 0xDA) { // SOS(画像開始)
40+ } else if ($marker >= 0xD0 && $marker <= 0xD7) { // RST
41+ } else if ($marker >= 0xE1 && $marker <= 0xEF) { // APP
42+ } else if ($marker == 0xFE) { // COM
43+ } else if ($marker == 0xD9) { // EOI(画像終了)
44+ break;
45+ }
46+ }
47+
48+ // kys($jpg);
49+
50+ $i++;
51+ fclose($fp);
52+ return $jpg;
53+ }
54+}
@@ -0,0 +1,616 @@
1+<?php
2+namespace Std\Lib\Image;
3+
4+use Std\Lib\Image\ImageInterface;
5+
6+class Png implements ImageInterface {
7+ public \stdClass $IHDR; // 画像ヘッダー
8+ // 横(4バイト)
9+ // 丈(4バイト)
10+ // ビット深さ(1バイト)
11+ // 色類(1バイト)
12+ //// | PNG画像タイプ | 色類 | 許可したビット深さ | 解釈 |
13+ //// |--------------------------|------|--------------------|----------------------------------------------- |
14+ //// | グレースケール | 0 | 1,2,4,8,16 | 各ピクセルはグレースケールのサンプル |
15+ //// | トゥルーカラー | 2 | 8,16 | 各ピクセルはR,G,Bトリプル |
16+ //// | 指標した色 | 3 | 1,2,4,8 | 各ピクセルはパレット指標(PLTEチャンクあり) |
17+ //// | グレースケール+アルファ | 4 | 8,16 | 各ピクセルはグレースケール+アルファのサンプル |
18+ //// | トゥルーカラー+アルファ | 6 | 8,16 | 各ピクセルはR,G,B,Aトリプル |
19+ // 圧縮関数(1バイト)
20+ //// 0 (DEFLATE)しかない
21+ // フィルター関数(1バイト)
22+ //// 0(アダプティブ・フィルタリング)しかない
23+ // インタレース関数(1バイト)
24+ //// 0 = なし
25+ //// 1 = Adam7
26+ public \stdClass $PLTE; // パレット(赤、緑、青)
27+ // 色類3 = 必須
28+ // 色類2と6 = 任意
29+ public \stdClass $IDAT; // 画像データ
30+ public \stdClass $IEND; // 画像終了
31+
32+ public \stdClass $tRNS; // 透明情報
33+ // 色類0 = グレーサンプル値(2バイト)
34+ // 色類2 = RGBサンプル値(2x2x2バイト)
35+ // 色類3 = パレット指標のアルファ(各1バイト)
36+ public \stdClass $cHRM; // 主な色度とホワイトポイント
37+ // 白X(4バイト)
38+ // 白Y(4バイト)
39+ // 赤X(4バイト)
40+ // 赤Y(4バイト)
41+ // 緑X(4バイト)
42+ // 緑Y(4バイト)
43+ // 青X(4バイト)
44+ // 青Y(4バイト)
45+ public \stdClass $gAMA; // ガンマ(4バイト)
46+ public \stdClass $iCCP; // 組み込みしたICCプロファイル
47+ // プロファイル名(1~79バイト)
48+ // NULL分離子(1バイト)
49+ // 圧縮関数(1バイト)
50+ // 圧縮プロファイル(nバイト)
51+ public \stdClass $sBIT; // 上位ビット(4バイト)
52+ // 色類0 = グレーサンプルビット(1バイト)
53+ // 色類2, 3 = RGBサンプルビット(1x1x1バイト)
54+ // 色類4 = グレーサンプルビット(1x1バイト)
55+ // 色類6 = RGBサンプルビット(1x1x1x1バイト)
56+ public \stdClass $sRGB; // レンダリングインテント(1バイト)
57+ // 0 = 知覚
58+ // 1 = 相対カラー・メトリック
59+ // 2 = 彩度
60+ // 3 = 絶対カラー・メトリック
61+ public \stdClass $cICP; // (1バイト)
62+ public \stdClass $mDCV; // (1バイト)
63+ public \stdClass $cLLI; // (1バイト)
64+ public \stdClass $tEXt; // 文字データ
65+ public \stdClass $iTXt; // 圧縮した文字データ
66+ public \stdClass $zTXt; // 国際文字データ
67+ public \stdClass $bKGD; // 拝啓色
68+ public \stdClass $hIST; // 画像ヒストグラム
69+ public \stdClass $pHYs; // 物理的なぷくセルのサイズ
70+ // 単位当たりのピクセル数(PPU)のX軸(4バイト)
71+ // 単位当たりのピクセル数(PPU)のY軸(4バイト)
72+ // ユニット指定子(1バイト)
73+ //// 0 = 不明
74+ //// 1 = メートル
75+ public \stdClass $sPLT; // 提案されている配色パターン
76+ // パレット名(1~79バイト)
77+ // NULL分離子(1バイト)
78+ // 赤(1か2バイト)
79+ // 緑(1か2バイト)
80+ // 青(1か2バイト)
81+ // アルファ(1か2バイト)
82+ // 周波数(2バイト)
83+ // 等
84+ public \stdClass $eXIf; // Exifプロファイル
85+ public \stdClass $tIME; // 画像の最後変更日
86+ // 年(2バイト)
87+ // 月(1バイト:1-12)
88+ // 日(1バイト:1-31)
89+ // 時(1バイト:0-23)
90+ // 分(1バイト:0-59)
91+ // 秒(1バイト:0-60)
92+ public \stdClass $acTL; // アニメーション・コントロール
93+ // フレーム数(4バイト)
94+ // 再生数(4バイト、0 = 無限)
95+
96+ public array $frames = [];
97+ private ?array $currentFrame = null;
98+
99+ // TODO: 残り: sBIT, iTXt, hIST, sPLT, tIME
100+ public function parse(string $file): \stdClass {
101+ $png = new \stdClass();
102+ $fp = fopen($file, 'rb');
103+
104+ $magic = bin2hex(fread($fp, 8)); // マジックのスキップ
105+ $length = unpack('N', fread($fp, 4))[1];
106+ $header = fread($fp, 4);
107+ if ($header !== 'IHDR') {
108+ fclose($fp);
109+ return $png;
110+ }
111+
112+ // IHDRデータ
113+ $data = fread($fp, $length);
114+
115+ // N = ビッグエンディアン(4ビット)
116+ $this->IHDR = new \stdClass;
117+ $this->IHDR->name = 'IHDR';
118+ $this->IHDR->length = $length;
119+ $this->IHDR->width = unpack('N', substr($data, 0, 4))[1];
120+ $this->IHDR->height = unpack('N', substr($data, 4, 4))[1];
121+ $this->IHDR->bitDepth = ord($data[8]); // 1, 2, 4, 8, 16
122+ $this->IHDR->numColors = 1 << $this->IHDR->bitDepth;
123+ $this->IHDR->colorType = ord($data[9]); // 0, 2, 3, 4, 6
124+ $this->IHDR->compressionType = ord($data[10]);
125+ $this->IHDR->compression = 'DEFLATE';
126+ $this->IHDR->filter = ord($data[11]);
127+ $this->IHDR->interlace = ord($data[12]); // 0 = なし, 1 = Adam7
128+ $this->IHDR->bpp = $this->IHDR->bitDepth * $this->getSamplesPerPixel($this->IHDR->colorType);
129+ $this->IHDR->hasAlpha = ($this->IHDR->colorType === 4 || $this->IHDR->colorType === 6);
130+ $crc = fread($fp, 4);
131+ $this->IHDR->crc = $crc;
132+ $this->IHDR->crc32 = unpack('N', $crc)[1];
133+ $this->IHDR->crcHex = bin2hex($crc);
134+
135+ $png->header = $this->IHDR;
136+
137+next_chunk:
138+ $lengthBytes = fread($fp, 4);
139+ if (strlen($lengthBytes) < 4) {
140+ logger(LogType::Image, 'バイト長さが悪い。ファイル:'.$file.'、バイト長さ:'.'$lengthBytes');
141+ fclose($fp);
142+ return $png;
143+ }
144+
145+ $length = unpack('N', $lengthBytes)[1];
146+ $nextChunk = fread($fp, 4);
147+ $data = $length > 0 ? fread($fp, $length) : '';
148+ $crc = fread($fp, 4);
149+
150+ // PLTEデータ
151+ if ($nextChunk === "\x50\x4c\x54\x45") { // PLTE
152+ $palette = [];
153+ for ($i = 0; $i < $length; $i += 3) {
154+ $colors = new \Std\Lib\RGB(ord($data[$i]), ord($data[$i+1]), ord($data[$i+2]));
155+ $palette[] = $colors->rgb;
156+ }
157+
158+ $this->PLTE = new \stdClass;
159+ $this->PLTE->name = 'PLTE';
160+ $this->PLTE->length = $length;
161+ $this->PLTE->palette = $palette;
162+ $this->PLTE->crc = $crc;
163+ $this->PLTE->crc32 = unpack('N', $crc)[1];
164+ $this->PLTE->crcHex = bin2hex($crc);
165+
166+ $png->palette = $this->PLTE;
167+ goto next_chunk;
168+ } else if ($nextChunk === "\x74\x52\x4e\x53") { // tRNS
169+ $this->tRNS = new \stdClass;
170+ $this->tRNS->name = 'tRNS';
171+ $this->tRNS->length = $length;
172+ $vals = [];
173+ $bytes = $this->IHDR->colorType === 2 ? 3 : 1;
174+ for ($i = 0; $i < $length; $i += $bytes) {
175+ if ($this->IHDR->colorType === 2) {
176+ $colors = new \Std\Lib\RGB(ord($data[$i]), ord($data[$i+1]), ord($data[$i+2]));
177+ $vals[] = $colors->rgb;
178+ } else {
179+ $vals[] = ord($data[$i]);
180+ }
181+ }
182+ $this->tRNS->values = $vals;
183+ $this->tRNS->crc = $crc;
184+ $this->tRNS->crc32 = unpack('N', $crc)[1];
185+ $this->tRNS->crcHex = bin2hex($crc);
186+ $png->transparency = $this->tRNS;
187+ goto next_chunk;
188+ } else if ($nextChunk === "\x63\x48\x52\x4d") { // cHRM
189+ $this->cHRM = new \stdClass;
190+ $this->cHRM->name = 'cHRM';
191+ $this->cHRM->length = $length;
192+ $this->cHRM->whitePointX = unpack('N', substr($data, 0, 4))[1];
193+ $this->cHRM->whitePointY = unpack('N', substr($data, 4, 4))[1];
194+ $this->cHRM->redX = unpack('N', substr($data, 8, 4))[1];
195+ $this->cHRM->redY = unpack('N', substr($data, 12, 4))[1];
196+ $this->cHRM->greenX = unpack('N', substr($data, 16, 4))[1];
197+ $this->cHRM->greenY = unpack('N', substr($data, 20, 4))[1];
198+ $this->cHRM->blueX = unpack('N', substr($data, 24, 4))[1];
199+ $this->cHRM->blueY = unpack('N', substr($data, 28, 4))[1];
200+ $this->cHRM->crc = $crc;
201+ $this->cHRM->crc32 = unpack('N', $crc)[1];
202+ $this->cHRM->crcHex = bin2hex($crc);
203+ $png->chromaticities = $this->cHRM;
204+ goto next_chunk;
205+ } else if ($nextChunk === "\x67\x41\x4d\x41") { // gAMA
206+ $this->gAMA = new \stdClass;
207+ $this->gAMA->name = 'gAMA';
208+ $this->gAMA->length = $length;
209+ $this->gAMA->value = unpack('N', $data)[1];
210+ $this->gAMA->crc = $crc;
211+ $this->gAMA->crc32 = unpack('N', $crc)[1];
212+ $this->gAMA->crcHex = bin2hex($crc);
213+ $png->gamma = $this->gAMA;
214+ goto next_chunk;
215+ } else if ($nextChunk === "\x69\x43\x43\x50") { // iCCP
216+ $this->iCCP = new \stdClass;
217+ $this->iCCP->name = 'iCCP';
218+ $this->iCCP->length = $length;
219+
220+ $nullPos = strpos($data, "\0");
221+ if ($nullPos === false || $nullPos < 1 || $nullPos > 79) {
222+ $this->iCCP->valid = false;
223+ $this->iCCP->error = "Missing or invalid profile name terminator";
224+ goto iccp_crc;
225+ }
226+
227+ $this->iCCP->profileName = substr($data, 0, $nullPos);
228+ $compressionMethod = ord($data[$nullPos + 1]);
229+ $this->iCCP->compressionMethod = $compressionMethod;
230+
231+ if ($compressionMethod !== 0) {
232+ $this->iCCP->valid = false;
233+ $this->iCCP->error = "Unsupported compression method: {$compressionMethod}";
234+ goto iccp_crc;
235+ }
236+
237+ $compressedData = substr($data, $nullPos + 2);
238+ $this->iCCP->compressedData = $compressedData;
239+ $this->iCCP->compressedSize = strlen($compressedData);
240+ $this->iCCP->valid = true;
241+
242+iccp_crc:
243+ $this->iCCP->crc = $crc;
244+ $this->iCCP->crc32 = unpack('N', $crc)[1];
245+ $this->iCCP->crcHex = bin2hex($crc);
246+ $png->iccProfile = $this->iCCP;
247+ goto next_chunk;
248+ } else if ($nextChunk === "\x73\x42\x49\x54") { // sBIT
249+ $this->sBIT = new \stdClass;
250+ $this->sBIT->name = 'sBIT';
251+ $this->sBIT->length = $length;
252+ kys(['sBIT', $length, bin2hex($data)]);
253+ $this->sBIT->crc = $crc;
254+ $this->sBIT->crc32 = unpack('N', $crc)[1];
255+ $this->sBIT->crcHex = bin2hex($crc);
256+ $png->significantBits = $this->sBIT;
257+ goto next_chunk;
258+ } else if ($nextChunk === "\x73\x52\x47\x42") { // sRGB
259+ $this->sRGB = new \stdClass;
260+ $this->sRGB->name = 'sRGB';
261+ $this->sRGB->length = $length;
262+ $this->sRGB->intent = ord($data);
263+ $name = [
264+ 0 => 'Perceptual',
265+ 1 => 'Relative colorimetric',
266+ 2 => 'Saturation',
267+ 3 => 'Absolute colorimetric',
268+ ];
269+ $this->sRGB->intentName = $name[$this->sRGB->intent];
270+ $this->sRGB->crc = $crc;
271+ $this->sRGB->crc32 = unpack('N', $crc)[1];
272+ $this->sRGB->crcHex = bin2hex($crc);
273+ $png->rgbColorSpace = $this->sRGB;
274+ goto next_chunk;
275+ } else if ($nextChunk === "\x63\x49\x43\x50") { // cICP
276+ $this->cICP = new \stdClass;
277+ $this->cICP->name = 'cICP';
278+ $this->cICP->length = $length;
279+ $this->cICP->colorPrimaries = ord($data[0]);
280+ $this->cICP->transferFunction = ord($data[1]);
281+ $this->cICP->matrixCoefficients = ord($data[2]);
282+ $this->cICP->videoFullRangeFlag = ord($data[3]);
283+ $this->cICP->crc = $crc;
284+ $this->cICP->crc32 = unpack('N', $crc)[1];
285+ $this->cICP->crcHex = bin2hex($crc);
286+ $png->videoSignalTypeId = $this->cICP;
287+ goto next_chunk;
288+ } else if ($nextChunk === "\x6d\x44\x43\x56") { // mDCV
289+ $this->mDCV = new \stdClass;
290+ $this->mDCV->name = 'mDCV';
291+ $this->mDCV->length = $length;
292+
293+ $u16 = fn($off) => unpack('n', substr($data, $off, 2))[1];
294+ $u32 = fn($off) => unpack('N', substr($data, $off, 4))[1];
295+ $this->mDCV->primaries = [
296+ 'red' => ['x' => $u16(0), 'y' => $u16(2)],
297+ 'green' => ['x' => $u16(4), 'y' => $u16(6)],
298+ 'blue' => ['x' => $u16(8), 'y' => $u16(10)],
299+ ];
300+ $this->mDCV->whitePoint = [
301+ 'x' => $u16(12),
302+ 'y' => $u16(14),
303+ ];
304+ $this->mDCV->luminance = [
305+ 'max' => $u16(16) * 0.0001,
306+ 'min' => $u16(20) * 0.0001,
307+ 'maxRaw' => $u16(16),
308+ 'minRaw' => $u16(20),
309+ ];
310+ $this->mDCV->crc = $crc;
311+ $this->mDCV->crc32 = unpack('N', $crc)[1];
312+ $this->mDCV->crcHex = bin2hex($crc);
313+ $png->masterDisplayColorVolume = $this->mDCV;
314+ goto next_chunk;
315+ } else if ($nextChunk === "\x63\x4c\x4c\x49") { // cLLI
316+ $this->cLLI = new \stdClass;
317+ $this->cLLI->name = 'cLLI';
318+ $this->cLLI->length = $length;
319+ $this->cLLI->maxCCL = unpack('N', substr($data, 0, 4))[1] * 0.0001;
320+ $this->cLLI->maxCCLRaw = unpack('N', substr($data, 0, 4))[1];
321+ $this->cLLI->maxFALL = unpack('N', substr($data, 4, 4))[1] * 0.0001;
322+ $this->cLLI->maxFALLRaw = unpack('N', substr($data, 4, 4))[1];
323+ $this->cLLI->crc = $crc;
324+ $this->cLLI->crc32 = unpack('N', $crc)[1];
325+ $this->cLLI->crcHex = bin2hex($crc);
326+ $png->contentLightLevelInfo = $this->cLLI;
327+ goto next_chunk;
328+ } else if ($nextChunk === "\x74\x45\x58\x74") { // tEXt
329+ if (!isset($png->textualData)) $png->textualData = [];
330+ $this->tEXt = new \stdClass;
331+ $this->tEXt->name = 'tEXt';
332+ $this->tEXt->length = $length;
333+ $this->tEXt->text = $data;
334+ $this->tEXt->crc = $crc;
335+ $this->tEXt->crc32 = unpack('N', $crc)[1];
336+ $this->tEXt->crcHex = bin2hex($crc);
337+ $png->textualData[] = $this->tEXt;
338+ goto next_chunk;
339+ } else if ($nextChunk === "\x69\x54\x58\x74") { // iTXt
340+ if (!isset($png->internationalTextualData)) $png->textualData = [];
341+ $this->iTXt = new \stdClass;
342+ $this->iTXt->name = 'iTXt';
343+ $this->iTXt->length = $length;
344+ kys(['iTXt', $length, bin2hex($data)]);
345+ $this->iTXt->crc = $crc;
346+ $this->iTXt->crc32 = unpack('N', $crc)[1];
347+ $this->iTXt->crcHex = bin2hex($crc);
348+ $png->internationalTextualData[] = $this->iTXt;
349+ kys($png);
350+ goto next_chunk;
351+ } else if ($nextChunk === "\x7a\x54\x58\x74") { // zTXt
352+ if (!isset($png->compressedTextualData)) $png->textualData = [];
353+ $this->zTXt = new \stdClass;
354+ $this->zTXt->name = 'zTXt';
355+ $this->zTXt->length = $length;
356+
357+ $nullPos = strpos($data, "\0");
358+ if ($nullPos === false || $nullPos < 1 || $nullPos > 79) {
359+ $this->zTXt->valid = false;
360+ $this->zTXt->error = "Missing or invalid profile name terminator";
361+ goto zTXt_crc;
362+ }
363+
364+ $this->zTXt->keyword = rtrim(substr($data, 0, $nullPos));
365+ $compressionMethod = ord($data[$nullPos + 1] ?? "\xFF");
366+
367+ $this->zTXt->compressionMethod = $compressionMethod;
368+
369+ if ($compressionMethod !== 0) {
370+ $this->zTXt->valid = false;
371+ $this->zTXt->error = "Unsupported compression method";
372+ goto zTXt_crc;
373+ }
374+
375+ $compressedText = substr($data, $nullPos + 2);
376+ fread($fp, 13); // ????????????????????????
377+ $this->zTXt->compressedText = $compressedText;
378+ $this->zTXt->compressedLength = strlen($compressedText);
379+
380+ $text = zlib_decode($compressedText);
381+
382+ if ($text === false) {
383+ $this->zTXt->valid = false;
384+ $this->zTXt->decompressionError = true;
385+ $this->zTXt->text = null;
386+ goto zTXt_crc;
387+ } else {
388+ $this->zTXt->text = $text;
389+ $this->zTXt->textUtf8 = mb_convert_encoding($text, 'UTF-8', 'ISO-8859-1');
390+ }
391+
392+ $this->zTXt->valid = true;
393+
394+zTXt_crc:
395+ $this->zTXt->crc = $crc;
396+ $this->zTXt->crc32 = unpack('N', $crc)[1];
397+ $this->zTXt->crcHex = bin2hex($crc);
398+ $png->compressedTextualData[] = $this->zTXt;
399+ goto next_chunk;
400+ } else if ($nextChunk === "\x62\x4b\x47\x44") { // bKGD
401+ $this->bKGD = new \stdClass;
402+ $this->bKGD->name = 'bKGD';
403+ $this->bKGD->length = $length;
404+ if ($this->IHDR->colorType === 2 || $this->IHDR->colorType === 6) {
405+ $r = (ord($data[0]) << 8) | ord($data[1]);
406+ $g = (ord($data[2]) << 8) | ord($data[3]);
407+ $b = (ord($data[4]) << 8) | ord($data[5]);
408+
409+ $this->bKGD->type = 'rgb16';
410+ $this->bKGD->r = $r;
411+ $this->bKGD->g = $g;
412+ $this->bKGD->b = $b;
413+
414+ $this->bKGD->r8 = $r >> 8;
415+ $this->bKGD->g8 = $g >> 8;
416+ $this->bKGD->b8 = $b >> 8;
417+ $this->bKGD->webColor = sprintf("#%02x%02x%02x", $this->bKGD->r8, $this->bKGD->g8, $this->bKGD->b8);
418+ } else if ($this->IHDR->colorType === 2 || $this->IHDR->colorType === 6) {
419+ $gray = (ord($data[0]) << 8) | ord($data[1]);
420+ $this->bKGD->type = 'gray16';
421+ $this->bKGD->gray = $gray;
422+ $this->bKGD->gray8 = $gray >> 8;
423+ } else if ($this->IHDR->colorType === 3) {
424+ $this->bKGD->type = 'palette_index';
425+ $this->bKGD->value = ord($data);
426+ } else {
427+ $this->bKGD->type = 'unknown';
428+ $this->bKGD->error = 'Invalid color type for bKGD';
429+ }
430+
431+ $this->bKGD->crc = $crc;
432+ $this->bKGD->crc32 = unpack('N', $crc)[1];
433+ $this->bKGD->crcHex = bin2hex($crc);
434+ $png->backgroundColor = $this->bKGD;
435+ goto next_chunk;
436+ } else if ($nextChunk === "\x68\x49\x53\x54") { // hIST
437+ $this->hIST = new \stdClass;
438+ $this->hIST->name = 'hIST';
439+ $this->hIST->length = $length;
440+ kys(['hIST', $length, bin2hex($data)]);
441+ $this->hIST->crc = $crc;
442+ $this->hIST->crc32 = unpack('N', $crc)[1];
443+ $this->hIST->crcHex = bin2hex($crc);
444+ $png->imageHistogram = $this->hIST;
445+ kys($png);
446+ goto next_chunk;
447+ } else if ($nextChunk === "\x70\x48\x59\x73") { // pHYs
448+ $this->pHYs = new \stdClass;
449+ $this->pHYs->name = 'pHYs';
450+ $this->pHYs->length = $length;
451+ $this->pHYs->x = unpack('N', substr($data, 0, 4))[1];
452+ $this->pHYs->y = unpack('N', substr($data, 4, 4))[1];
453+ $unit = [
454+ 0 => 'unit is unknown',
455+ 1 => 'unit is the metre',
456+ ];
457+ $this->pHYs->unitId = ord($data[8]);
458+ $this->pHYs->unit = $unit[$this->pHYs->unitId];
459+ $this->pHYs->crc = $crc;
460+ $this->pHYs->crc32 = unpack('N', $crc)[1];
461+ $this->pHYs->crcHex = bin2hex($crc);
462+ $png->physicalPixelDimensions = $this->pHYs;
463+ goto next_chunk;
464+ } else if ($nextChunk === "\x73\x50\x4c\x54") { // sPLT
465+ $this->sPLT = new \stdClass;
466+ $this->sPLT->name = 'sPLT';
467+ $this->sPLT->length = $length;
468+ kys(['sPLT', $length, bin2hex($data)]);
469+ $this->sPLT->crc = $crc;
470+ $this->sPLT->crc32 = unpack('N', $crc)[1];
471+ $this->sPLT->crcHex = bin2hex($crc);
472+ $png->suggestedPalette = $this->sPLT;
473+ kys($png);
474+ goto next_chunk;
475+ } else if ($nextChunk === "\x65\x58\x49\x66") { // eXIf
476+ $this->eXIf = new \stdClass;
477+ $this->eXIf->name = 'eXIf';
478+ $this->eXIf->length = $length;
479+
480+ if (strlen($data) < 8) {
481+ $this->eXIf->valid = false;
482+ $this->eXIf->error = "eXIf chunk too short";
483+ goto exif_crc;
484+ }
485+
486+ $byteOrder = substr($data, 0, 2);
487+ $magic = substr($data, 2, 2);
488+
489+ $this->eXIf->rawData = $data;
490+ $this->eXIf->rawLength = $length;
491+ $this->eXIf->byteOrder = $byteOrder; // II = リトルエンディアン、MM = ビッグエンディアン
492+ $this->eXIf->isBigEndian = ($byteOrder === 'MM');
493+ $this->eXIf->magic = unpack('n', $magic)[1]; // いつでも42ではず
494+
495+ if (strlen($data) < 8) {
496+ $this->eXIf->valid = false;
497+ $this->eXIf->error = "Invalid TIFF magic number";
498+ goto exif_crc;
499+ }
500+
501+ $this->eXIf->valid = true;
502+
503+ $ifdOffset = unpack($this->eXIf->isBigEndian ? 'N' : 'V', substr($data, 4, 4))[1];
504+ $this->eXIf->firstIfdOffset = $ifdOffset;
505+
506+exif_crc:
507+ $this->eXIf->crc = $crc;
508+ $this->eXIf->crc32 = unpack('N', $crc)[1];
509+ $this->eXIf->crcHex = bin2hex($crc);
510+ $png->exifProfile = $this->eXIf;
511+ goto next_chunk;
512+ } else if ($nextChunk === "\x74\x49\x4d\x45") { // tIME
513+ $this->tIME = new \stdClass;
514+ $this->tIME->name = 'tIME';
515+ $this->tIME->length = $length;
516+ kys(['tIME', $length, bin2hex($data)]);
517+ $this->tIME->crc = $crc;
518+ $this->tIME->crc32 = unpack('N', $crc)[1];
519+ $this->tIME->crcHex = bin2hex($crc);
520+ $png->lastModificationTime = $this->tIME;
521+ kys($png);
522+ goto next_chunk;
523+ } else if ($nextChunk === "\x61\x63\x54\x4c") { // acTL
524+ $this->acTL = new \stdClass;
525+ $this->acTL->name = 'acTL';
526+ $this->acTL->length = $length;
527+ $this->acTL->numFrames = unpack('N', substr($data, 0, 4))[1];
528+ $this->acTL->numPlays = unpack('N', substr($data, 4, 4))[1];
529+ $this->acTL->crc = $crc;
530+ $this->acTL->crc32 = unpack('N', $crc)[1];
531+ $this->acTL->crcHex = bin2hex($crc);
532+ $png->aniationControl = $this->acTL;
533+ goto next_chunk;
534+ } else if ($nextChunk === "\x66\x63\x54\x4c") { // fcTL
535+ if ($this->currentFrame !== null) $this->frames[] = (object)$this->currentFrame;
536+
537+ $this->currentFrame = [
538+ 'name' => 'fcTL',
539+ 'length' => $length,
540+ 'sequenceNumber' => unpack('N', substr($data, 0, 4))[1],
541+ 'width' => unpack('N', substr($data, 4, 4))[1],
542+ 'height' => unpack('N', substr($data, 8, 4))[1],
543+ 'xOffset' => unpack('N', substr($data, 12, 4))[1],
544+ 'yOffset' => unpack('N', substr($data, 16, 4))[1],
545+ 'delayNum' => unpack('n', substr($data, 20, 2))[1],
546+ 'delayDen' => unpack('n', substr($data, 22, 2))[1],
547+ 'disposeOp' => ord($data[24]),
548+ 'blendOp' => ord($data[25]),
549+ 'data' => null,
550+ 'crc' => $crc,
551+ 'crc32' => unpack('N', $crc)[1],
552+ 'crcHex' => bin2hex($crc),
553+ ];
554+
555+ goto next_chunk;
556+ } else if ($nextChunk === "\x49\x44\x41\x54" || $nextChunk === "\x66\x64\x41\x54") { // IDAT || fdAT
557+ if ($this->currentFrame === null) {
558+ $this->currentFrame = [
559+ 'sequenceNumber' => 0,
560+ 'isFirstFrame' => true,
561+ 'width' => $this->IHDR->width,
562+ 'height' => $this->IHDR->height,
563+ 'data' => null,
564+ ];
565+ }
566+
567+ $compressedData = ($nextChunk === "\x66\x64\x41\x54") ? substr($data, 4) : $data;
568+ $this->currentFrame['name'] = 'fdAT';
569+ $this->currentFrame['length'] = $length;
570+ $this->currentFrame['data'] = $compressedData;
571+ $this->currentFrame['dataBase64'] = base64_encode($compressedData);
572+ $this->currentFrame['dataHex'] = bin2hex($compressedData);
573+ $this->currentFrame['dataLength'] = strlen($compressedData);
574+ $this->currentFrame['dataChunkType'] = $nextChunk;
575+ $this->currentFrame['crc'] = $crc;
576+ $this->currentFrame['crcBase64'] = unpack('N', $crc)[1];
577+ $this->currentFrame['crcHex'] = bin2hex($crc);
578+
579+ if ($nextChunk === "\x49\x44\x41\x54") {
580+ $this->currentFrame['name'] = 'IDAT';
581+ $this->frames[] = (object)$this->currentFrame;
582+ $this->currentFrame = null;
583+ }
584+
585+ $png->data = $this->frames;
586+ goto next_chunk;
587+ } else if ($nextChunk === "\x49\x45\x4e\x44") { // IEND
588+ if ($this->currentFrame !== null) {
589+ $this->frames[] = (object)$this->currentFrame;
590+ }
591+ $png->end = (object)['crc' => $crc, 'crc32' => unpack('N', $crc)[1], 'crcHex' => bin2hex($crc)];
592+ } else {
593+ fclose($fp);
594+ $err = '不明なチャンク:'.$nextChunk.'、HEX:'.bin2hex($nextChunk);
595+ kys($err);
596+ logger(\LogType::Image, '【PNG】'.$err);
597+ return $png;
598+ }
599+
600+ fclose($fp);
601+ return $png;
602+ }
603+
604+ ///////////////
605+
606+ private function getSamplesPerPixel(int $colorType): int {
607+ return match($colorType) {
608+ 0 => 1, // グレースケール
609+ 2 => 3, // RGB
610+ 3 => 1, // 指標した色(ペレット)
611+ 4 => 2, // グレースケール+アルファ
612+ 6 => 4, // RGBA
613+ default => 1,
614+ };
615+ }
616+}
@@ -0,0 +1,115 @@
1+<?php
2+namespace Std\Lib\Image;
3+
4+use Std\Lib\Image\ImageInterface;
5+
6+class Targa implements ImageInterface {
7+ public int $id = 0;
8+ public int $colorMapType = 0;
9+ public int $imageTypeByte = 0;
10+ public string $imageType = '';
11+ public int $colorMapEntryIndex = 0;
12+ public int $colorMapLength = 0;
13+ public int $colorMapBits = 0;
14+ public int $x = 0;
15+ public int $y = 0;
16+ public int $width = 0;
17+ public int $height = 0;
18+ public int $bitDepth = 0;
19+ public int $bpp = 0;
20+ public int $interlace = 0;
21+ public int $isRightToLeft = 0; // 0 = 左→右, 1 = 右→左
22+ public int $isTopToBottom = 0; // 0 = 下→上, 1 = 上→下
23+ public int $alphaBits = 0;
24+ public bool $hasAlpha = false;
25+ //--------
26+ public string $imageId = '';
27+ public string $colorMapData = '';
28+ public string $imageData = '';
29+
30+ private array $imageTypes = [
31+ 0 => 'NO DATA',
32+ 1 => 'UNCOMPRESSED COLOR-MAPPED IMAGE',
33+ 2 => 'UNCOMPRESSED TRUE COLOR IMAGE',
34+ 3 => 'UNCOMPRESSED GRAYSCALE IMAGE',
35+ 9 => 'RUN-LENGTH ENCODED COLOR-MAPPED IMAGE',
36+ 10 => 'RUN-LENGTH ENCODED TRUE-COLOR IMAGE',
37+ 11 => 'RUN-LENGTH ENCODED GRAYSCALE IMAGE',
38+ 32 => 'HUFFMAN-DELTA-RUN-LENGTH ENCODED COLOR-MAPPED IMAGE',
39+ 33 => 'HUFFMAN-DELTA-RUN-LENGTH-4-PASS-QUADTREE-TYPE PROCESS ENCODED COLOR-MAPPED IMAGE',
40+ ];
41+
42+ public function parse(string $file): \stdClass {
43+ $tga = new \stdClass();
44+ $fp = fopen($file, 'rb');
45+
46+ $this->id = ord(fread($fp, 1));
47+ $this->colorMapType = ord(fread($fp, 1)); // 0 = なし, 1 = あり, 2~127 = Truevision向け, 128~255 = 開発者向け
48+ $this->imageTypeByte = ord(fread($fp, 1));
49+ $this->imageType = $this->imageTypes[$this->imageTypeByte];
50+ $tga->id = $this->id;
51+ $tga->colorMapType = $this->colorMapType;
52+ $tga->imageTypeByte = $this->imageTypeByte;
53+ $tga->imageType = $this->imageType;
54+
55+ $this->colorMapEntryIndex = unpack('v', fread($fp, 2))[1];
56+ $this->colorMapLength = unpack('v', fread($fp, 2))[1];
57+ $this->colorMapBits = ord(fread($fp, 1));
58+ $tga->colorMapEntryIndex = $this->colorMapEntryIndex;
59+ $tga->colorMapLength = $this->colorMapLength;
60+ $tga->colorMapBits = $this->colorMapBits;
61+
62+ $this->x = unpack('v', fread($fp, 2))[1];
63+ $this->y = unpack('v', fread($fp, 2))[1];
64+ $this->width = unpack('v', fread($fp, 2))[1];
65+ $this->height = unpack('v', fread($fp, 2))[1];
66+ $this->bitDepth = ord(fread($fp, 1));
67+ $tga->x = $this->x;
68+ $tga->y = $this->y;
69+ $tga->width = $this->width;
70+ $tga->height = $this->height;
71+ $tga->bitDepth = $this->bitDepth;
72+
73+ $desc = ord(fread($fp, 1));
74+ $this->interlace = ($desc & 0xC0) >> 6;
75+ $this->isTopToBottom = ($desc & 0x20) !== 0;
76+ $this->isRightToLeft = ($desc & 0x10) !== 0;
77+ $this->alphaBits = $desc & 0x0F;
78+ $this->hasAlpha = ($this->alphaBits == 8) && in_array($this->imageTypeByte == [2, 3, 10, 11]);
79+ $tga->interlace = $this->interlace;
80+ $tga->isRightToLeft = $this->isRightToLeft;
81+ $tga->isTopToBottom = $this->isTopToBottom;
82+ $tga->alphaBits = $this->alphaBits;
83+ $tga->hasAlpha = $this->hasAlpha;
84+
85+ $needsXFlip = $tga->isRightToLeft;
86+ $needsYFlip = !$tga->isTopToBottom;
87+ // 0010 0000
88+
89+ //---------
90+ $imageIdBytes = $this->id;
91+ $colorMapBytes = 0;
92+ if ($this->colorMapType === 1 && $this->colorMapLength > 0) {
93+ $bytesPerEntry = (int)($this->colorMapBits / 8);
94+ $colorMapBytes = $this->colorMapLength * $bytesPerEntry;
95+ }
96+
97+ $this->imageId = $imageIdBytes > 0 ? fread($fp, $imageIdBytes) : '';
98+ $this->colorMapData = $colorMapBytes > 0 ? fread($fp, $colorMapBytes) : '';
99+ $tga->imageId = $this->imageId;
100+ $tga->colorMapData = $this->colorMapData;
101+
102+ if (in_array($this->imageTypeByte, [9, 10, 11])) { // RLE
103+ // TODO
104+ } else if (in_array($this->imageTypeByte, [1, 2, 3])) { // 非圧縮化
105+ $this->bpp = (int)($this->bitDepth / 8);
106+ $bytes = $this->width * $this->height * $this->bpp;
107+ $this->imageData = fread($fp, $bytes);
108+ $tga->bpp = $this->bpp;
109+ $tga->imageData = $this->imageData;
110+ }
111+
112+ fclose($fp);
113+ return $tga;
114+ }
115+}
@@ -0,0 +1,238 @@
1+<?php
2+namespace Std\Lib;
3+
4+class Mailer {
5+ private $socket;
6+ private string $host;
7+ private int $port;
8+ private ?string $user;
9+ private ?string $pass;
10+
11+ private ?string $pgpKey;
12+ private ?string $pgpPass;
13+
14+ /**
15+ * コンストラクタ
16+ */
17+ public function __construct() {
18+ if (!MAILER_ENABLED) {
19+ throw new \Exception("メーラーは無効です。");
20+ }
21+
22+ $this->host = MAILINFO['host'];
23+ $this->port = MAILINFO['port'];
24+ $this->user = MAILINFO['user'];
25+ $this->pass = MAILINFO['pass'];
26+ }
27+
28+ /**
29+ * ソケット接続を開く。
30+ *
31+ * @return void
32+ */
33+ public function connect(): void {
34+ $this->socket = fsockopen($this->host, $this->port, $errno, $err, 30);
35+ if (!$this->socket) {
36+ $msg = "接続に失敗: {$err} ({$errno})";
37+ logger(\LogType::Mailer, $msg);
38+ throw new \Exception($msg);
39+ }
40+
41+ $this->readResponse();
42+ }
43+
44+ /**
45+ * ソケット接続を切断する。
46+ *
47+ * @return void
48+ */
49+ public function disconnect(): void {
50+ $this->sendCommand('QUIT', 221);
51+ fclose($this->socket);
52+ }
53+
54+ /**
55+ * サーバーで認証する。
56+ * ユーザー名とパスワードが指定されている場合はAUTH LOGINを使用する。
57+ *
58+ * @return void
59+ */
60+ public function authenticate(): void {
61+ $ehloRes = $this->sendCommand('EHLO '.$this->host, 250);
62+
63+ // STARTTLSは対応するかどうか確認
64+ if (strpos($ehloRes, 'STARTTLS') !== false) {
65+ $this->sendCommand('STARTTLS', 220);
66+
67+ // TLS暗号化
68+ if (!stream_socket_enable_crypto(
69+ $this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
70+ $msg = "TLSハンドシェイクに失敗";
71+ logger(\LogType::Mailer, $msg);
72+ throw new \Exception($msg);
73+ }
74+
75+ $this->sendCommand('EHLO '.gethostname(), 250);
76+ }
77+
78+ if ($this->user && $this->pass) {
79+ $this->sendCommand('AUTH LOGIN', 334);
80+ $this->sendCommand(base64_encode($this->user), 334);
81+ $this->sendCommand(base64_encode($this->pass), 235);
82+ }
83+ }
84+
85+ /**
86+ * TLSハンドシェイクに失敗する。
87+ *
88+ * @param string $to 受信者のメールアドレス。
89+ * @param string $subject メールの件名。
90+ * @param string $body メールの本文。
91+ * @param string|null $toName 受信者の名前。
92+ * @param string|null $replyTo 返信先メールアドレス。
93+ * @param string|null $replyToName 返信先の名前。
94+ * @param bool $pgpSign PGPで署名するかどうか(現在は正常に動作していません)。
95+ *
96+ * @return void
97+ */
98+ public function send(
99+ string $to,
100+ string $subject,
101+ string $body,
102+ ?string $toName = null,
103+ ?string $replyTo = null,
104+ ?string $replyToName = null,
105+ bool $pgpSign = false
106+ ): void {
107+ $from = MAILINFO['from'];
108+
109+ $this->sendCommand("MAIL FROM: <{$from}>", 250);
110+ $this->sendCommand("RCPT TO: <{$to}>", 250);
111+ $this->sendCommand('DATA', 354);
112+
113+ $headers = "Date: ".date('r')."\r\n"; // RFC 2822
114+
115+ $encSubject = mb_encode_mimeheader($subject, 'UTF-8', 'Q');
116+ $headers .= "Subject: {$encSubject}\r\n";
117+
118+ $fromHeader = mb_encode_mimeheader(SITEINFO['title'], 'UTF-8', 'Q')." <{$from}>";
119+ $headers .= "From: {$fromHeader}\r\n";
120+
121+ $toHeader = $toName
122+ ? mb_encode_mimeheader($toName, 'UTF-8', 'Q')." <{$to}>" : $to;
123+ $headers .= "To: {$toHeader}\r\n";
124+
125+ if ($replyTo) {
126+ $replyToHeader = $replyToName
127+ ? mb_encode_mimeheader($replyToName, 'UTF-8', 'Q')." <{$replyTo}>" : $replyTo;
128+ $headers .= "Reply-To: {$replyToHeader}\r\n";
129+ }
130+
131+ $headers .= "MIME-Version: 1.0\r\n";
132+
133+ if ($pgpSign) {
134+ $boundary = uniqid('BOUNDARY_');
135+ $headers .= "Content-Type: multipart/signed;\r\n";
136+ $headers .= " protocol=\"application/pgp-signature\";\r\n";
137+ $headers .= " micalg=php-sha512;\r\n";
138+ $headers .= " boundary=\"{$boundary}\"\r\n";
139+ } else {
140+ $headers .= "Content-Type: text/plain; charset=utf-8\r\n";
141+ $headers .= "Content-Transfer-Encoding: quoted-printable\r\n";
142+ }
143+
144+ $headers .= "X-Mailer: 076 Little Beast\r\n";
145+
146+ $encBody = quoted_printable_encode($body);
147+ $data = $headers."\r\n".$encBody;
148+
149+ if ($pgpSign) {
150+ $signature = $this->signMessage($data);
151+ $data = "--{$boundary}\r\n"
152+ .$data."\r\n"
153+ ."Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n"
154+ ."Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n"
155+ .$signature."\r\n"
156+ ."--{$boundary}--\r\n";
157+ }
158+
159+ $data .= "\r\n.\r\n";
160+ fwrite($this->socket, $data);
161+
162+ $response = $this->readResponse();
163+ if (substr($response, 0, 3) != '250') {
164+ $msg = "メール送信に失敗: {$response}";
165+ logger(\LogType::Mailer, $msg);
166+ throw new \Exception($msg);
167+ }
168+ }
169+
170+ /**
171+ * お好みのタイムゾーンを設定する。
172+ * 設定しない場合、php.iniで設定されたタイムゾーンがデフォルトになる。
173+ * それも設定されていない場合、GMTタイムゾーンがデフォルトになる。
174+ *
175+ * @param string $zone IANAタイムゾーンデータベース標準のタイムゾーン(例:Asia/Tokyo)
176+ * @return void
177+ */
178+ public function setTimezone(string $zone): void {
179+ date_default_timezone_set($zone);
180+ }
181+
182+ /**
183+ * PGPキーとオプションでパスフレーズを設定する。
184+ *
185+ * @param string $keypath PGP署名に使用する秘密鍵へのパス。
186+ * @param string|null $passphrase 設定されている場合、署名用のパスフレーズ。
187+ *
188+ * @return void
189+ */
190+ public function enablePGP(string $keypath, ?string $passphrase = null): void {
191+ $this->pgpKey = file_get_contents($keypath);
192+ $this->pgpPass = $passphrase;
193+ }
194+
195+ // 機能性メソッド
196+
197+ private function sendCommand(string $command, int $retcode): string {
198+ fwrite($this->socket, $command."\r\n");
199+ $res = $this->readResponse();
200+ if (substr($res, 0, 3) != $retcode) {
201+ $msg = "「{$command}」に対する予期しないレスポンス: {$res}";
202+ logger(\LogType::Mailer, $msg);
203+ throw new \Exception($msg);
204+ }
205+
206+ return $res;
207+ }
208+
209+ private function readResponse(): string {
210+ $res = '';
211+
212+ while ($line = fgets($this->socket, 515)) {
213+ $res .= $line;
214+ if (substr($line, 3, 1) == ' ') break; // レスポンスの終了だ
215+ }
216+
217+ return $res;
218+ }
219+
220+ private function signMessage(string $message): string {
221+ if (extension_loaded('gnupg')) {// gnupg延長は有効の場合
222+ $gpg = new \gnupg();
223+ $gpg->addsignkey($this->pgpKey, $this->pgpPass);
224+ $gpg->setsignmode(\gnupg::SIG_MODE_DETACH);
225+ return $gpg->sign($message);
226+ } else { // なければ、CLIツールを使う(gnupgをインストールは必須)
227+ $tmp = tempnam(sys_get_temp_dir(), 'pgpmsg');
228+ file_put_contents($tmp, $message);
229+ $sig = shell_exec(
230+ "gpg --batch "
231+ .($this->pgpPass ? "--passphrase {$this->pgpPass} " : '')
232+ ."--detach-sign --armor {$tmp} 2>&1"
233+ );
234+ unlink($tmp);
235+ return $sig;
236+ }
237+ }
238+}
@@ -0,0 +1,648 @@
1+<?php
2+namespace Std\Lib;
3+
4+class Markdown {
5+ private string $content;
6+ private array $html;
7+ private string $path;
8+ private bool $inCodeBlock = false;
9+ private string $codeBlockLanguage = '';
10+ private array $codeBlockContent = [];
11+ private const METADATA_LINE = "----";
12+ private array $algebraicPlaceholder = [];
13+
14+ public function __construct(string $path, string $section, ?string $lang = null) {
15+ $this->html = [];
16+ if ($lang) $this->path = ROOT.$section.$lang.'/'.$path.'.md';
17+ else $this->path = ROOT.$section.$path.'.md';
18+
19+ if (!file_exists($this->path)) {
20+ header('Location: /404');
21+ exit();
22+ }
23+ }
24+
25+ /**
26+ * メタデータを取得する
27+ *
28+ * @return \stdClass メタデータオブジェクト
29+ */
30+ public function getMetadata(): \stdClass {
31+ $content = file_get_contents($this->path);
32+ $metadata = new \stdClass();
33+
34+ $parts = explode(self::METADATA_LINE, $content, 2);
35+ if (count($parts) < 2) return $metadata;
36+
37+ $lines = explode("\n", trim($parts[0]));
38+ foreach ($lines as $line) {
39+ $line = trim($line);
40+ if (empty($line)) continue;
41+
42+ $colonPos = strpos($line, ':');
43+ if ($colonPos === false) continue;
44+
45+ $key = trim(substr($line, 0, $colonPos));
46+ $value = trim(substr($line, $colonPos + 1));
47+ $value = trim($value, '"\'');
48+
49+ if ($key == 'category' || $key == 'css') {
50+ $cat = explode(',', $value);
51+ $value = $cat;
52+ }
53+
54+ $metadata->$key = $value;
55+ }
56+
57+ return $metadata;
58+ }
59+
60+ /**
61+ * Markdownをパースする
62+ *
63+ * @return string HTMLとしてレンダリングされたコンテンツ
64+ */
65+ public function parse(): string {
66+ $content = file_get_contents($this->path);
67+ $parts = explode(self::METADATA_LINE, $content, 2);
68+ $this->content = count($parts) > 1 ? trim($parts[1]): trim($content);
69+ $this->html = [];
70+
71+ $lines = explode("\n", $this->content);
72+ $currentParagraph = [];
73+
74+ $inList = false;
75+ $listItems = [];
76+ $listLevel = 0;
77+ $inBlockquote = false;
78+ $blockquoteContent = [];
79+ $tableHeaders = [];
80+ $tableRows = [];
81+ $inTable = false;
82+ $i = 0;
83+ $headerCount = 1;
84+
85+ foreach ($lines as $line) {
86+ $i++;
87+ $hasBR = substr($line, -1) === '\\';
88+ $line = rtrim($line, " \t\r\n\\");
89+
90+ // コメント
91+ // if (str_starts_with($line, '//')) {
92+ // if ($hasBR && !empty($currentParagraph)) {
93+ // $currentParagraph[] = '';
94+ // }
95+ // continue;
96+ // }
97+
98+ // コードブロック
99+ if (preg_match('/^```(\w*)$/', $line, $matches)) {
100+ if (!$this->inCodeBlock) {
101+ if (!empty($currentParagraph)) {
102+ $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
103+ $currentParagraph = [];
104+ }
105+ $this->inCodeBlock = true;
106+ $this->codeBlockLanguage = $matches[1];
107+ continue;
108+ } else {
109+ $this->html[] = $this->createCodeBlock();
110+ $this->inCodeBlock = false;
111+ $this->codeBlockLanguage = '';
112+ $this->codeBlockContent = [];
113+ continue;
114+ }
115+ }
116+
117+ if ($this->inCodeBlock) {
118+ $this->codeBlockContent[] = $line;
119+ continue;
120+ }
121+
122+ // テーブルの処理
123+ if (preg_match('/^\|(.+)\|$/', $line)) {
124+ if (!empty($currentParagraph)) {
125+ $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
126+ $currentParagraph = [];
127+ }
128+ $cells = array_map('trim', explode('|', trim($line, '|')));
129+
130+ if (!$inTable) {
131+ $tableHeaders = $cells;
132+ $inTable = true;
133+ } elseif (preg_match('/^\|(\s*:?-+:?\s*\|)+$/', $line)) {
134+ continue;
135+ } else {
136+ $tableRows[] = $cells;
137+ }
138+ continue;
139+ } elseif ($inTable) {
140+ $this->html[] = $this->createTable($tableHeaders, $tableRows);
141+ $tableHeaders = [];
142+ $tableRows = [];
143+ $inTable = false;
144+ }
145+
146+ // 水平線の処理
147+ if (preg_match('/^([\-\*\_])\1{2,}$/', $line)) {
148+ if (!empty($currentParagraph)) {
149+ $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
150+ $currentParagraph = [];
151+ }
152+ $this->html[] = " <hr />\n";
153+ continue;
154+ }
155+
156+ // 引用ブロックの処理
157+ if (preg_match('/^>\s(.+)/', $line, $matches)) {
158+ if (!empty($currentParagraph)) {
159+ $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
160+ $currentParagraph = [];
161+ }
162+ $inBlockquote = true;
163+ $blockquoteContent[] = $this->parseInline($matches[1]);
164+ continue;
165+ } elseif ($inBlockquote && empty($line)) {
166+ $this->html[] = $this->createBlockquote($blockquoteContent);
167+ $blockquoteContent = [];
168+ $inBlockquote = false;
169+ continue;
170+ }
171+
172+ // 空行をスキップ
173+ if (empty($line)) {
174+ if ($inList) {
175+ $this->html[] = $this->createList($listItems);
176+ $listItems = [];
177+ $inList = false;
178+ }
179+ if (!empty($currentParagraph)) {
180+ $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
181+ $currentParagraph = [];
182+ }
183+
184+ continue;
185+ }
186+
187+ // ヘッダー
188+ if (preg_match('/^(#{1,6})\s(.+)/', $line, $m)) {
189+ if ($inList) {
190+ $this->html[] = $this->createList($listItems);
191+ $listItems = [];
192+ $inList = false;
193+ }
194+ if (!empty($currentParagraph)) {
195+ $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
196+ $currentParagraph = [];
197+ }
198+
199+ $level = strlen($m[1]);
200+ $this->html[] = " <h{$level} id=\"section-{$headerCount}\">".$this->parseInline($m[2])."</h{$level}>";
201+ $headerCount++;
202+ continue;
203+ }
204+
205+ // 箇条書きリスト
206+ if (preg_match('/^(\s*)([\*\-])\s(.+)/', $line, $m)) {
207+ if (!empty($currentParagraph)) {
208+ $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
209+ $currentParagraph = [];
210+ }
211+ $inList = true;
212+ $currentLevel = strlen($m[1]) / 2;
213+ $listLevel = max($listLevel, $currentLevel);
214+ $listItems[] = [
215+ 'content' => $this->parseInline($m[3]),
216+ 'level' => $currentLevel,
217+ 'type' => 'ul',
218+ ];
219+
220+ continue;
221+ }
222+
223+ // 番号付きリスト
224+ if (preg_match('/^(\s*)\d+\.\s(.+)/', $line, $m)) {
225+ if (!empty($currentParagraph)) {
226+ $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
227+ $currentParagraph = [];
228+ }
229+ $inList = true;
230+ $currentLevel = strlen($m[1]) / 2;
231+ $listLevel = max($listLevel, $currentLevel);
232+ $listItems[] = [
233+ 'content' => $this->parseInline($m[2]),
234+ 'level' => $currentLevel,
235+ 'type' => 'ol',
236+ ];
237+
238+ continue;
239+ }
240+
241+ if ($inList) {
242+ $this->html[] = $this->createList($listItems);
243+ $listItems = [];
244+ $inList = false;
245+ $listLevel = 0;
246+ }
247+
248+ $parsedLine = $this->parseInline($line);
249+ $currentParagraph[] = $parsedLine;
250+ if ($hasBR) {
251+ $currentParagraph[] = "<br />\n ";
252+ }
253+ }
254+
255+ if ($inList) $this->html[] = $this->createList($listItems);
256+ if ($inBlockquote) $this->html[] = $this->createBlockquote($blockquoteContent);
257+ if ($inTable) $this->html[] = $this->createTable($tableHeaders, $tableRows);
258+ if (!empty($currentParagraph))
259+ $this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
260+
261+ return implode("\n", $this->html);
262+ }
263+
264+ // 機能性メソッド
265+
266+ /**
267+ * インラインのMarkdown記法をパースする
268+ *
269+ * @param string $text パースするテキスト
270+ * @return string HTMLとしてレンダリングされたテキスト
271+ */
272+ private function parseInline(string $text): string {
273+ $this->algebraicPlaceholder = [];
274+ $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
275+
276+ $patterns = [
277+ // 数式
278+ '/\$\$([^$]+)\$\$/u' => function($matches): string {
279+ $placeholder = "{{ALG".count($this->algebraicPlaceholder).'ALG}}';
280+ $this->algebraicPlaceholder[$placeholder] = $matches[1];
281+ return $placeholder;
282+ },
283+ // 太字
284+ '/\*\*(.+?)\*\*/u' => '<strong>$1</strong>',
285+ // 斜体
286+ '/\*(.+?)\*/u' => '<em>$1</em>',
287+ // 下線
288+ '/\_(.+?)\_/u' => '<u>$1</u>',
289+ // 取り消し線
290+ '/\~(.+?)\~/u' => '<s>$1</s>',
291+ // Blink (with speed)
292+ '/!:\((.+?)\)(.+?):!/u' => '<span class="blink" style="animation-duration: $1s;">$2</span>',
293+ // Blink
294+ '/!:(.+?):!/u' => '<span class="blink">$1</span>',
295+ // フォントの大きさ
296+ '/\^\((.+?)\)(.+?)\^/u' => '<span style="font-size: $1px;">$2</span>',
297+ // フォントカラー
298+ '/\%\((.+?)\)(.+?)\%/u' => '<span style="color: #$1;">$2</span>',
299+ // 画像
300+ '/\!\[(.*?)(?:#([^\]]*))?\]\((.+?)\)/u' => '<img style="width: 100%; $2" src="$3" alt="$1" />',
301+ // 音楽
302+ '/\$\[([^\]]+)\]\(([^\)]+)\)/u' => '<audio controls><source src="$2" type="$1" /></audio>',
303+ // 動画
304+ '/\#\[([^\]]+)\]\(([^\)]+)\)/u' => '<video style="max-width: 100%;" controls><source src="$2" type="$1" /></video>',
305+ // リンク
306+ '/\[(.+?)\]\((.+?)\)/u' => '<a href="$2">$1</a>',
307+ // 振り仮名
308+ '/\&lt;(.+?)\&gt;\((.+?)\)/u' => '<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>',
309+ // インラインコード
310+ '/`(.+?)`/u' => '<code>$1</code>',
311+ ];
312+
313+ foreach ($patterns as $pattern => $replacement) {
314+ if (is_callable($replacement)) {
315+ $text = preg_replace_callback($pattern, $replacement, $text);
316+ } else {
317+ $result = preg_replace($pattern, $replacement, $text);
318+ if ($result === null) continue;
319+ $text = $result;
320+ }
321+ }
322+
323+ // プレースホルダーの数式に交換
324+ foreach ($this->algebraicPlaceholder as $placeholder => $expr) {
325+ $text = str_replace($placeholder, $this->parseAlgebraic($expr), $text);
326+ }
327+
328+ return $text;
329+ }
330+
331+ /**
332+ * 数式記法をパースする
333+ *
334+ * @param string $expression パースする数式
335+ * @return string MathJax用にフォーマットされた数式
336+ */
337+ private function parseAlgebraic(string $expression): string {
338+ // HTMLエスケープ
339+ $expression = htmlspecialchars_decode($expression, ENT_QUOTES);
340+
341+ // ふぃりしゃもじのマッピング
342+ $greekLetters = [
343+ 'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ', 'delta' => 'δ',
344+ 'epsilon' => 'ε', 'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ',
345+ 'iota' => 'ι', 'kappa' => 'κ', 'lambda' => 'λ', 'mu' => 'μ',
346+ 'nu' => 'ν', 'xi' => 'ξ', 'omicron' => 'ο', 'pi' => 'π',
347+ 'rho' => 'ρ', 'sigma' => 'σ', 'tau' => 'τ', 'upsilon' => 'υ',
348+ 'phi' => 'φ', 'chi' => 'χ', 'psi' => 'ψ', 'omega' => 'ω',
349+ 'Alpha' => 'Α', 'Beta' => 'Β', 'Gamma' => 'Γ', 'Delta' => 'Δ',
350+ 'Epsilon' => 'Ε', 'Zeta' => 'Ζ', 'Eta' => 'Η', 'Theta' => 'Θ',
351+ 'Iota' => 'Ι', 'Kappa' => 'Κ', 'Lambda' => 'Λ', 'Mu' => 'Μ',
352+ 'Nu' => 'Ν', 'Xi' => 'Ξ', 'Omicron' => 'Ο', 'Pi' => 'Π',
353+ 'Rho' => 'Ρ', 'Sigma' => 'Σ', 'Tau' => 'Τ', 'Upsilon' => 'Υ',
354+ 'Phi' => 'Φ', 'Chi' => 'Χ', 'Psi' => 'Ψ', 'Omega' => 'Ω',
355+ ];
356+
357+ // ギリシャ文字を置換
358+ foreach ($greekLetters as $text => $symbol) {
359+ $expression = preg_replace("/\b$text\b/", $symbol, $expression);
360+ }
361+
362+ // 上付き文字(例: x^2)
363+ $expression = preg_replace_callback('/(\w+)\^(\{?(\d+|\w+)\}?)/', function($matches) {
364+ $base = $matches[1];
365+ $exponent = $matches[2];
366+ if ($exponent[0] === '{') $exponent = substr($exponent, 1, -1);
367+
368+ $superscripts = str_split($exponent);
369+ $supMap = [
370+ '0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴',
371+ '5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹',
372+ 'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ',
373+ 'i' => 'ⁱ', 'n' => 'ⁿ',
374+ ];
375+ $converted = '';
376+
377+ foreach ($superscripts as $char) $converted .= $supMap[$char] ?? $char;
378+
379+ return $base.'<sup>'.$converted.'</sup>';
380+ }, $expression);
381+
382+ // 下付き文字(例: x_1)
383+ $expression = preg_replace_callback('/(\w+)_((\d+|\w+)\}?)/', function($matches) {
384+ $base = $matches[1];
385+ $subscript = $matches[2];
386+
387+ if ($subscript[0] === '{') $subscript = substr($subscript, 1, -1);
388+
389+ $subscripts = str_split($subscript);
390+ $subMap = [
391+ '0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄',
392+ '5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉',
393+ ];
394+ $converted = '';
395+
396+ foreach ($subscripts as $char) {
397+ $converted .= $subMap[$char] ?? $char;
398+ }
399+
400+ return $base.'<sub>'.$converted.'</sub>';
401+ }, $expression);
402+
403+ // 分数(例: 4/5x, a/b)
404+ $expression = preg_replace('/(\d+|\w+)\/(\d*\w+)/',
405+ '<span class="fraction"><span class="numerator">$1</span><span class="denominator">$2</span></span>',
406+ $expression);
407+
408+ // 演算子
409+ $operators = [
410+ '\*' => '・',
411+ '!=' => '≠',
412+ '>=' => '≥',
413+ '<=' => '≤',
414+ 'sqrt' => '√',
415+ ];
416+
417+ foreach ($operators as $op => $symbol) $expression = preg_replace("/\b$op\b/", $symbol, $expression);
418+
419+ // 数式全体をspanで囲む
420+ return '<span class="algebraic">'.$expression.'</span>';
421+ }
422+
423+ /**
424+ * リストを作成する
425+ *
426+ * @param array $items リストアイテムの配列
427+ * @param int $maxLevel 最大ネストレベル
428+ * @return string HTMLのリスト
429+ */
430+ private function createList(array $items, int $maxLevel = 1): string {
431+ if ($items === []) return '';
432+
433+ $html = '';
434+ $currentLevel = 0;
435+ $listStack = [];
436+ $currentType = '';
437+
438+ foreach ($items as $item) {
439+ $level = isset($item['level']) ? $item['level'] : $currentLevel;
440+
441+ while ($currentLevel > $level)
442+ $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
443+
444+ while ($currentLevel < $level) {
445+ $currentLevel++;
446+ $listStack[] = $item['type'];
447+ $html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
448+ }
449+
450+ if ($currentType != $item['type'] && $currentLevel == $item['level']) {
451+ if (!empty($listStack)) {
452+ $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
453+ $listStack[] = $item['type'];
454+ $html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
455+ }
456+ }
457+
458+ $currentType = $item['type'];
459+ }
460+
461+ $html .= " <p>\n <{$currentType}>\n";
462+ foreach ($items as $item) {
463+ $html .= str_repeat(' ', $currentLevel)." <li>".$item['content']."</li>\n";
464+ }
465+ $html .= " </{$currentType}>\n </p>";
466+
467+ while (!empty($listStack)) {
468+ $html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
469+ $currentLevel--;
470+ }
471+
472+ return rtrim($html);
473+ }
474+
475+ /**
476+ * コードブロックを作成する
477+ *
478+ * @return string HTMLのコードブロック
479+ */
480+ private function createCodeBlock(): string {
481+ $code = htmlspecialchars(implode("\n", $this->codeBlockContent));
482+ $class = $this->codeBlockLanguage ? " class=\"language-{$this->codeBlockLanguage}\"" : '';
483+ return " <p>\n <pre><code{$class}>{$code}</code></pre>";
484+ // $raw = implode("\n", $this->codeBlockContent);
485+ // $lang = $this->codeBlockLanguage ?: 'txt';
486+ // $class = $lang === 'txt' ? '' : " class=\"language-{$lang}\"";
487+ // $highlighted = $this->highlightCode($raw, $lang);
488+ // return "<pre><code{$class}>{$highlighted}</code></pre>";
489+ }
490+
491+ private function createBlockquote(array $content): string {
492+ return " <p>\n <blockquote>\n ".implode("<br />\n ", $content)."\n </blockquote>\n </p>";
493+ }
494+
495+ /**
496+ * テーブルを作成する
497+ *
498+ * @param array $headers ヘッダー配列
499+ * @param array $rows 行データの配列
500+ * @return string HTMLのテーブル
501+ */
502+ private function createTable(array $headers, array $rows): string {
503+ $html = " <p>\n <table>\n";
504+
505+ // ヘッダーを追加
506+ if (!empty($headers)) {
507+ $html .= " <thead>\n <tr>\n";
508+ foreach ($headers as $header) {
509+ $html .= " <th>".$this->parseInline($header)."</th>\n";
510+ }
511+ $html .= " </tr>\n </thead>\n";
512+ }
513+
514+ // 行を追加
515+ if (!empty($rows)) {
516+ $html .= " <tbody>\n";
517+ foreach ($rows as $row) {
518+ $html .= " <tr>\n";
519+ foreach ($row as $cell) {
520+ $html .= " <td>".$this->parseInline($cell)."</td>\n";
521+ }
522+ $html .= " </tr>\n";
523+ }
524+ $html .= " </tbody>\n";
525+ }
526+
527+ $html .= " </table>\n </p>";
528+ return $html;
529+ }
530+
531+ /**
532+ * Pure-PHP syntax highlighter
533+ *
534+ * @param string $code Raw source code
535+ * @param string $lang Language identifier (html, css, js, php, …)
536+ * @return string HTML with <span class="hl-…"> tokens
537+ */
538+ private function highlightCode(string $code, string $lang): string
539+ {
540+ $lang = strtolower(trim($lang));
541+ $rules = [];
542+
543+ // ---- COMMON -------------------------------------------------------
544+ $rules['comment'] = [
545+ // /* … */ (multi-line)
546+ ['/\/\*(?:.|\n)*?\*\//', 'hl-comment'],
547+ // // …\n
548+ ['/\/\/.*(?=\n|$)/', 'hl-comment'],
549+ ];
550+
551+ $rules['string'] = [
552+ // "…" and '…' (allow escaped quotes)
553+ ['/"(?:\\.|[^"\\\n])*"/', 'hl-string'],
554+ ["/'(?:\\.|[^'\\\n])*'/", 'hl-string'],
555+ ];
556+
557+ $rules['number'] = [
558+ ['/\b(?:0x[0-9a-fA-F]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/', 'hl-number'],
559+ ];
560+
561+ // ---- HTML ---------------------------------------------------------
562+ if ($lang === 'html') {
563+ $rules['tag'] = [['/&lt;\/?[a-zA-Z0-9\-]+/', 'hl-tag']];
564+ $rules['attr'] = [['/\s[a-zA-Z0-9\-]+(?==)/', 'hl-attr']];
565+ $rules['value'] = [['/(?<==)"(?:\\.|[^"\\\n])*"(?=[>\s])/', 'hl-value']];
566+ }
567+
568+ // ---- CSS ----------------------------------------------------------
569+ if ($lang === 'css') {
570+ $rules['selector'] = [['/^[\t ]*[a-zA-Z0-9#\.\-\:$$ $$\=\*\>\+\~\^]+(?=\s*\{)/m', 'hl-selector']];
571+ $rules['property'] = [['/\b[a-z\-]+(?=\s*:)/', 'hl-property']];
572+ $rules['value'] = [['/(?<=\:)\s*[^;]+(?=;)/', 'hl-value']];
573+ $rules['unit'] = [['/\b\d+(px|em|rem|%|vh|vw|pt|pc|cm|mm|in)\b/i', 'hl-unit']];
574+ }
575+
576+ // ---- PHP ----------------------------------------------------------
577+ if ($lang === 'php') {
578+ $keywords = 'abstract|and|array|as|break|callable|case|catch|class|clone|'
579+ . 'const|continue|declare|default|die|do|echo|else|elseif|empty|'
580+ . 'enddeclare|endfor|endforeach|endif|endswitch|endwhile|eval|exit|'
581+ . 'extends|final|finally|for|foreach|function|global|goto|if|'
582+ . 'implements|include|include_once|instanceof|insteadof|interface|'
583+ . 'isset|list|namespace|new|or|print|private|protected|public|'
584+ . 'require|require_once|return|static|switch|throw|trait|try|unset|'
585+ . 'use|var|while|xor|yield';
586+ $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
587+ $rules['function']= [['/\b([a-zA-Z_\x7f-\xff][\w\x7f-\xff]*)(?=\s*\()/', 'hl-function']];
588+ $rules['variable']= [['/\$[a-zA-Z_\x7f-\xff][\w\x7f-\xff]*/', 'hl-variable']];
589+ }
590+
591+ // ---- Shell --------------------------------------------------------
592+ if ($lang === 'sh') {
593+ $keywords = 'if|then|else|elif|fi|case|esac|for|select|while|until|do|done|in';
594+ $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
595+ $rules['variable']= [['/\$\{?[a-zA-Z_][\w]*\}?/', 'hl-variable']];
596+ }
597+
598+ // ---- JSON ---------------------------------------------------------
599+ if ($lang === 'json') {
600+ $rules['property'] = [['/"([^"]+)":/', '<span class="hl-property">$1</span>:']];
601+ }
602+
603+ // ---- C / C++ -----------------------------------------------
604+ if (in_array($lang, ['c', 'cpp', 'cs'])) {
605+ $keywords = 'abstract|assert|boolean|break|byte|case|catch|char|class|const|'
606+ . 'continue|default|do|double|else|enum|extends|false|final|finally|'
607+ . 'float|for|goto|if|implements|import|instanceof|int|interface|long|'
608+ . 'native|new|null|package|private|protected|public|return|short|static|'
609+ . 'strictfp|super|switch|synchronized|this|throw|throws|transient|true|'
610+ . 'try|void|volatile|while';
611+ $rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
612+ $rules['type'] = [['/\b(?:int|float|double|char|bool|void|string)\b/', 'hl-type']];
613+ $rules['define'] = [['/(#include)|(#define)/', 'hl-define']];
614+ }
615+
616+ if ($lang === 'markdown') {
617+ $rules['header'] = [['/^(#{1,6})\s+(.+)$/', '<span class="hl-header">$1 $2</span>']];
618+ $rules['link'] = [['/\[([^\]]+)\]\(([^)]+) $$/', '<span class="hl-link">[$1]($2)</span>']];
619+ }
620+
621+ $html = htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8');
622+
623+ $order = ['comment', 'string', 'number'];
624+ foreach (['tag','attr','value','selector','property','unit','keyword','function','variable','type','header','link'] as $k) {
625+ if (isset($rules[$k])) $order[] = $k;
626+ }
627+
628+ foreach ($order as $type) {
629+ if (!isset($rules[$type])) continue;
630+ foreach ($rules[$type] as $rule) {
631+ [$pattern, $class] = $rule;
632+ $html = preg_replace_callback(
633+ $pattern,
634+ function ($m) use ($class) {
635+ // For JSON property we already built the <span>
636+ if (strpos($m[0], '<span') === 0) return $m[0];
637+ $inner = $m[0];
638+ // Preserve already escaped entities inside strings/comments
639+ return '<span class="'.$class.'">'.$inner.'</span>';
640+ },
641+ $html
642+ );
643+ }
644+ }
645+
646+ return $html;
647+ }
648+}
@@ -0,0 +1,163 @@
1+<?php
2+namespace Std\Lib;
3+
4+class Route {
5+ protected static array $routes = [];
6+ protected static array $fallback = [];
7+
8+ /**
9+ * ルート設定を言語固有のハンドラで初期化する
10+ *
11+ * @param array $routes ルート設定の配列
12+ * @return void
13+ */
14+ public static function init(array $routes): void {
15+ self::$routes = $routes;
16+ }
17+
18+ /**
19+ * ルートを追加する
20+ *
21+ * @param string $method HTTPメソッド
22+ * @param string $path URLパス
23+ * @param string|callable $class ハンドラクラスとメソッド、またはコールバック
24+ * @param array $params オプションのパラメータ
25+ * @return array ルート設定
26+ */
27+ public static function add(string $method, string $path, string|callable $class,
28+ array $params = []): array {
29+ $route = [
30+ 'method' => $method,
31+ 'path' => $path,
32+ 'class' => $class,
33+ 'params' => $params,
34+ ];
35+
36+ self::$routes[] = $route;
37+ return $route;
38+ }
39+
40+ /**
41+ * 404処理用のフォールバックルートを設定する
42+ *
43+ * @param array|string|callable $class
44+ * @return void
45+ */
46+ public static function setFallback(array|string|callable $class): void {
47+ self::$fallback = [
48+ 'class' => $class,
49+ 'params' => [],
50+ ];
51+ }
52+
53+ /**
54+ * 適切なルートをマッチさせて実行する
55+ *
56+ * @param string $uri リクエストURI
57+ * @return void
58+ */
59+ public static function dispatch(string $uri): void {
60+ // URIをパスとクエリ文字列に分割
61+ $uriParts = explode('?', $uri, 2);
62+ $path = trim($uriParts[0], " \t\n\r\0\x0B/");
63+
64+ // ルートパスの処理(/?page=2のようなクエリパラメータを含む場合も処理)
65+ if ($path === '') {
66+ self::executeClass([
67+ 'class' => [new \Site\Controller\Home(), 'show'],
68+ 'params' => ['lang' => 'ja'],
69+ ]);
70+ return;
71+ }
72+
73+ if ($path === 'en') {
74+ self::executeClass([
75+ 'class' => [new \Site\Controller\Home(), 'show'],
76+ 'params' => ['lang' => 'en'],
77+ ]);
78+ return;
79+ }
80+
81+ // パスに対してルートをマッチングする
82+ foreach (self::$routes as $route) {
83+ $matches = [];
84+
85+ if (self::matchRoute($route['path'], $path, $matches)) {
86+ $params = self::extractParams($route['path'], $path);
87+ $params = array_merge($route['params'], $params);
88+
89+ if (is_string($route['class'])) {
90+ [ $class, $method ] = explode('@', $route['class']);
91+ $controller = new $class();
92+ self::executeClass([
93+ 'class' => [ $controller, $method ],
94+ 'params' => $params,
95+ ]);
96+
97+ return;
98+ } elseif (is_callable($route['class'])) {
99+ self::executeClass([
100+ 'class' => $route['class'],
101+ 'params' => $params,
102+ ]);
103+
104+ return;
105+ }
106+ }
107+ }
108+
109+ // マッチするルートがない場合、フォールバックを実行
110+ self::executeClass(self::$fallback);
111+ }
112+
113+ /**
114+ * ルートパターンとパスをマッチングする
115+ *
116+ * @param string $pattern ルートパターン
117+ * @param string $path 現在のパス
118+ * @param array $matches マッチを格納する参照
119+ * @return bool
120+ */
121+ protected static function matchRoute(string $pattern, string $path,
122+ array &$matches = []): bool {
123+ // ルートパターンを正規表現パターンに変換
124+ $pattern = preg_replace('/\{([^:}]+)(?::([^}]+))?\}/', '(?P<$1>[^/]+)', $pattern);
125+ $pattern = str_replace('/', '\/', $pattern);
126+ return (bool)preg_match('/^'.$pattern.'$/', $path, $matches);
127+ }
128+
129+ /**
130+ * パターンに基づいてパスから名前付きパラメータを抽出する
131+ *
132+ * @param string $pattern ルートパターン
133+ * @param string $path 現在のパス
134+ * @return array
135+ */
136+ protected static function extractParams(string $pattern, string $path): array {
137+ $params = [];
138+ $patternParts = explode('/', $pattern);
139+ $pathParts = explode('/', $path);
140+
141+ foreach ($patternParts as $k => $v) {
142+ if (preg_match('/\{([^:}]+)(?::([^}]+))?\}/', $v, $matches)) {
143+ if (isset($pathParts[$k])) {
144+ $params[$matches[1]] = $pathParts[$k];
145+ }
146+ }
147+ }
148+
149+ return $params;
150+ }
151+
152+ /**
153+ * ルートクラスを実行する
154+ *
155+ * @param array $route ルート設定
156+ * @return void
157+ */
158+ protected static function executeClass(array $route): void {
159+ if (is_callable($route['class'])) {
160+ call_user_func($route['class'], $route['params'] ?? []);
161+ }
162+ }
163+}
@@ -0,0 +1,243 @@
1+<?php
2+namespace Std\Lib;
3+
4+class Template {
5+ private string $tmplExt = '.maron';
6+ private string $tmplPath;
7+ private array $vars = [];
8+ private array $blocks = [];
9+ private array $custFunc = [];
10+ private array $custCss = [];
11+
12+ /**
13+ * テンプレートクラスのコンストラクタ
14+ *
15+ * @param string $tmplPath テンプレートのパス
16+ * @return void
17+ */
18+ public function __construct(string $tmplPath) {
19+ $this->tmplPath = rtrim($tmplPath, '/');
20+
21+ if (substr($this->tmplPath, 0, 1) !== '/') {
22+ $this->tmplPath = '/'.$this->tmplPath;
23+ }
24+ }
25+
26+ /**
27+ * テンプレート変数に値を割り当てる
28+ *
29+ * @param string $name 変数名
30+ * @param mixed $value 値
31+ * @return void
32+ */
33+ public function assign(string $name, mixed $value): void {
34+ $this->vars[$name] = $value;
35+ }
36+
37+ /**
38+ * カスタムCSSファイルを追加する
39+ *
40+ * @param string $name CSSファイル名
41+ * @return void
42+ */
43+ public function addCss(string $name): void {
44+ $this->custCss[] =
45+ "<link rel=\"stylesheet\" type=\"text/css\" href=\"/static/style-{$name}.css\" />\n";
46+ $this->assign('custCss', $this->custCss);
47+ }
48+
49+ /**
50+ * カスタム関数を登録する
51+ *
52+ * @param string $name 関数名
53+ * @param callable $callback コールバック関数
54+ * @return void
55+ */
56+ public function registerFunction(string $name, callable $callback): void {
57+ $this->custFunc[$name] = $callback;
58+ }
59+
60+ /**
61+ * テンプレートブロックを定義する
62+ *
63+ * @param string $name ブロック名
64+ * @param string $content ブロック内容
65+ * @return void
66+ */
67+ public function defineBlock(string $name, string $content): void {
68+ if (!isset($this->blocks[$name]))
69+ $this->blocks[$name] = $content;
70+ }
71+
72+ /**
73+ * テンプレートをレンダリングする
74+ *
75+ * @param string $tmplName テンプレート名
76+ * @return void
77+ */
78+ public function render(string $tmplName): void {
79+ $tmplPath = ROOT.'/view'.$this->tmplPath.'/'.$tmplName.$this->tmplExt;
80+ if (!file_exists($tmplPath))
81+ throw new \RuntimeException("テンプレートファイルを見つけません:{$tmplPath}");
82+
83+ extract($this->vars);
84+
85+ $content = file_get_contents($tmplPath);
86+
87+ // インクルードディレクティブを処理
88+ while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
89+ $content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string {
90+ $inclPath = ROOT.'/view/'.trim($m[1], "'\" ").$this->tmplExt;
91+ if (!file_exists($inclPath))
92+ throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
93+ return file_get_contents($inclPath);
94+ }, $content);
95+ }
96+
97+ $content = $this->procDirs($content);
98+ $content = $this->procVars($content);
99+ $content = $this->procFuncs($content);
100+
101+ $tmpFile = tempnam(sys_get_temp_dir(), 'tmpl_');
102+ file_put_contents($tmpFile, $content);
103+
104+ include $tmpFile;
105+ unlink($tmpFile);
106+ }
107+
108+ // 機能性メソッド
109+
110+ /**
111+ * テンプレートディレクティブを処理する
112+ *
113+ * @param string $content テンプレート内容
114+ * @return string|null 処理後の内容
115+ */
116+ private function procDirs(string $content): string|null {
117+ // includeディレクティブの処理
118+ while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
119+ $content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string {
120+ $inclPath = ROOT.'/view/'.trim($m[1], "'\" ").'.php';
121+ if (!file_exists($inclPath))
122+ throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
123+ return file_get_contents($inclPath);
124+ }, $content);
125+ }
126+
127+ $content = preg_replace('/\{@\s*if\s*\((.*?)\):\s*@\}/', '{@ if ($1) @}', $content);
128+ $content = preg_replace('/\{@\s*endif;\s*@\}/', '{@ endif @}', $content);
129+
130+ $processDirectives = function($c): array|string|null {
131+ // kysディレクティブの処理
132+ $c = preg_replace_callback('/\{@\s*kys\((.*?)\)\s*@\}/s', function($m): string {
133+ return "<?php echo '<pre>'; print_r({$m[1]}); echo '</pre>'; die(); ?>";
134+ }, $c);
135+
136+ // foreachループとネストした内容の処理
137+ $c = preg_replace_callback('/\{@\s*foreach\s*\((.*?)\)\s*@\}/s', function($m): string {
138+ return "<?php foreach({$m[1]}): ?>";
139+ }, $c);
140+
141+ $c = preg_replace_callback('/\{@\s*endforeach\s*@\}/s', function($m): string {
142+ return "<?php endforeach; ?>";
143+ }, $c);
144+
145+ // forループの処理
146+ $c = preg_replace_callback('/\{@\s*for\s*\((.*?)\)\s*@\}/s', function($m): string {
147+ return "<?php for({$m[1]}): ?>";
148+ }, $c);
149+
150+ $c = preg_replace_callback('/\{@\s*endfor\s*@\}/s', function($m): string {
151+ return "<?php endfor; ?>";
152+ }, $c);
153+
154+ // if-elif-else-endifの処理
155+ $c = preg_replace_callback('/\{@\s*if\s*\((.*?)\)\s*@\}/s', function($m): string {
156+ return "<?php if ({$m[1]}): ?>";
157+ }, $c);
158+
159+ $c = preg_replace_callback('/\{@\s*elif\s*\((.*?)\)\s*@\}/s', function($m): string {
160+ return "<?php elseif ({$m[1]}): ?>";
161+ }, $c);
162+
163+ $c = preg_replace_callback('/\{@\s*else\s*@\}/s', function($m): string {
164+ return "<?php else: ?>";
165+ }, $c);
166+
167+ $c = preg_replace_callback('/\{@\s*endif\s*@\}/s', function($m): string {
168+ return "<?php endif; ?>";
169+ }, $c);
170+
171+ return $c;
172+ };
173+
174+ $previousContent = '';
175+ $maxIterations = 10;
176+ $iterations = 0;
177+
178+ while ($previousContent !== $content && $iterations < $maxIterations) {
179+ $previousContent = $content;
180+ $content = $processDirectives($content);
181+ $iterations++;
182+ }
183+
184+ return $content;
185+ }
186+
187+ /**
188+ * テンプレート変数を処理する
189+ *
190+ * @param string $content テンプレート内容
191+ * @return string 処理後の内容
192+ */
193+ private function procVars(string $content): string {
194+ // 変数の出力(エスケープ処理なし)
195+ $content = preg_replace_callback('/\{\{\{s*(.*?)\s*\}\}\}/', function($m): string {
196+ return '<?= '.trim($m[1]).' ?>';
197+ }, $content);
198+
199+ // 変数の出力(エスケープ処理あり)
200+ $content = preg_replace_callback('/\{\{\s*(.*?)\s*\}\}/', function($m): string {
201+ return '<?= htmlspecialchars('.trim($m[1]).', ENT_QUOTES, \'UTF-8\') ?>';
202+ }, $content);
203+
204+ // 変数の代入
205+ $content = preg_replace_callback('/\{\$\s*(.*?)\s*\$\}/', function($m): string {
206+ $parts = explode('=', $m[1], 2);
207+ if (count($parts) !== 2)
208+ throw new \RuntimeException("不正な値の形式");
209+ return '<?php '.trim($parts[0]).' = '.trim($parts[1]).'; ?>';
210+ }, $content);
211+
212+ // コメント
213+ $content = preg_replace_callback('/\{#\s*(.*?)\s*#\}/', function($m): string {
214+ return '<?php /*'.trim($m[1]).'*/ ?>';
215+ }, $content);
216+
217+ // PHPコードの実行
218+ $content = preg_replace_callback('/\{\!\s*(.*?)\s*\!\}/', function($m): string {
219+ return '<?php '.trim($m[1]).' ?>';
220+ }, $content);
221+
222+ return $content;
223+ }
224+
225+ /**
226+ * カスタム関数を処理する
227+ *
228+ * @param string $content テンプレート内容
229+ * @return string 処理後の内容
230+ */
231+ private function procFuncs(string $content): string {
232+ foreach ($this->custFunc as $name => $cb) {
233+ $pattern = "/\{@\s*{$name}\((.*?)\)\s*@\}/";
234+ $content = preg_replace_callback($pattern, function($m) use ($cb): mixed {
235+ $args = explode(',', $m[1]);
236+ $args = array_map('trim', $args);
237+ return call_user_func_array($cb, $args);
238+ }, $content);
239+ }
240+
241+ return $content;
242+ }
243+}
@@ -0,0 +1,617 @@
1+<?php
2+namespace Std\Lib;
3+
4+/**
5+ * アサーション失敗用のカスタム例外
6+ */
7+class AssertionFailedException extends \Exception {
8+}
9+
10+/**
11+ * 軽量なユニットテストフレームワーク
12+ */
13+class Tester {
14+ // テスト統計
15+ private int $testCount = 0;
16+ private int $passCount = 0;
17+ private int $failCount = 0;
18+ private int $errorCount = 0;
19+
20+ // 現在のテストケース情報
21+ private string $currentTestCase = '';
22+ private string $currentTest = '';
23+
24+ // テストスイート設定
25+ private bool $colorOutput = true;
26+ private bool $verboseOutput = true;
27+ private bool $stopOnFailure = false;
28+ private bool $skipCurrentDescribe = false;
29+ private array $beforeEachCallbacks = [];
30+ private array $afterEachCallbacks = [];
31+ private array $beforeAllCallbacks = [];
32+ private array $afterAllCallbacks = [];
33+
34+ // ターミナル出力用の色
35+ private array $colors = [
36+ 'reset' => "\033[0m",
37+ 'red' => "\033[31m",
38+ 'green' => "\033[32m",
39+ 'yellow' => "\033[33m",
40+ 'blue' => "\033[34m",
41+ 'magenta' => "\033[35m",
42+ 'cyan' => "\033[36m",
43+ 'white' => "\033[37m",
44+ 'bold' => "\033[1m",
45+ ];
46+
47+ private array $failures = [];
48+ private array $errors = [];
49+
50+ /**
51+ * コンストラクタ
52+ *
53+ * @param array $options 設定オプション
54+ */
55+ public function __construct(array $options = []) {
56+ // オプションを設定
57+ if (isset($options['colorOutput'])) {
58+ $this->colorOutput = (bool)$options['colorOutput'];
59+ }
60+
61+ if (isset($options['verboseOutput'])) {
62+ $this->verboseOutput = (bool)$options['verboseOutput'];
63+ }
64+
65+ if (isset($options['stopOnFailure'])) {
66+ $this->stopOnFailure = (bool)$options['stopOnFailure'];
67+ }
68+
69+ // サポートされていない場合は色を無効にする
70+ if (PHP_SAPI !== 'cli' || strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' && !getenv('ANSICON')) {
71+ $this->colorOutput = false;
72+ }
73+ }
74+
75+ /**
76+ * 各テストの前に実行する関数を登録する
77+ *
78+ * @param callable $callback コールバック関数
79+ * @return Tester このインスタンス
80+ */
81+ public function beforeEach(callable $callback): Tester {
82+ $this->beforeEachCallbacks[] = $callback;
83+ return $this;
84+ }
85+
86+ /**
87+ * 各テストの後に実行する関数を登録する
88+ *
89+ * @param callable $callback コールバック関数
90+ * @return Tester このインスタンス
91+ */
92+ public function afterEach(callable $callback): Tester {
93+ $this->afterEachCallbacks[] = $callback;
94+ return $this;
95+ }
96+
97+ /**
98+ * すべてのテストの前に実行する関数を登録する
99+ *
100+ * @param callable $callback コールバック関数
101+ * @return Tester このインスタンス
102+ */
103+ public function beforeAll(callable $callback): Tester {
104+ $this->beforeAllCallbacks[] = $callback;
105+ return $this;
106+ }
107+
108+ /**
109+ * すべてのテストの後に実行する関数を登録する
110+ *
111+ * @param callable $callback コールバック関数
112+ * @return Tester このインスタンス
113+ */
114+ public function afterAll(callable $callback): Tester {
115+ $this->afterAllCallbacks[] = $callback;
116+ return $this;
117+ }
118+
119+ /**
120+ * テストケースを定義する
121+ *
122+ * @param string $description テストケースの説明
123+ * @param callable $callback テストケース関数
124+ * @return Tester このインスタンス
125+ */
126+ public function describe(string $description, callable $callback): Tester {
127+ $this->currentTestCase = $description;
128+ $this->skipCurrentDescribe = false;
129+ $this->output($this->colorize('bold', "テストケース: {$description}"));
130+
131+ try {
132+ foreach ($this->beforeAllCallbacks as $before) {
133+ call_user_func($before);
134+ }
135+
136+ call_user_func($callback, $this);
137+
138+ foreach ($this->afterAllCallbacks as $after) {
139+ call_user_func($after);
140+ }
141+ } catch (\Throwable $e) {
142+ $this->recordError(
143+ "テストケースのセットアップ/ティアダウンでエラー: ".$e->getMessage(),
144+ $e->getTraceAsString());
145+ }
146+
147+ $this->output('');
148+ return $this;
149+ }
150+
151+ /**
152+ * 単一のテストを実行する
153+ *
154+ * @param string $description テストの説明
155+ * @param callable $callback テスト関数
156+ * @return Tester このインスタンス
157+ */
158+ public function it(string $description, callable $callback): Tester {
159+ if ($this->skipCurrentDescribe) {
160+ $this->skip($description, 'skipAll() が呼び出されました');
161+ return $this;
162+ }
163+
164+ $this->currentTest = $description;
165+ $this->testCount++;
166+
167+ if ($this->verboseOutput) {
168+ $this->output(" ⋄ テスト中: {$description}... ", false);
169+ }
170+
171+ try {
172+ foreach ($this->beforeEachCallbacks as $before) {
173+ call_user_func($before);
174+ }
175+
176+ call_user_func($callback, $this);
177+
178+ foreach ($this->afterEachCallbacks as $after) {
179+ call_user_func($after);
180+ }
181+
182+ // Test has passed.
183+ $this->passCount++;
184+
185+ if ($this->verboseOutput) {
186+ $this->output($this->colorize('green', "合格"));
187+ }
188+ } catch (AssertionFailedException $e) {
189+ $this->failCount++;
190+
191+ if ($this->verboseOutput) {
192+ $this->output($this->colorize('red', "失敗"));
193+ $this->output($this->colorize('red', " → ".$e->getMessage()));
194+ }
195+
196+ $this->recordFailure($e->getMessage());
197+
198+ if ($this->stopOnFailure) {
199+ $this->printSummary();
200+ exit(1);
201+ }
202+ } catch (\Throwable $e) {
203+ $this->errorCount++;
204+
205+ if ($this->verboseOutput) {
206+ $this->output($this->colorize('yellow', "エラー"));
207+ $this->output($this->colorize('yellow', " → ".$e->getMessage()));
208+ }
209+
210+ $this->recordError($e->getMessage(), $e->getTraceAsString());
211+
212+ if ($this->stopOnFailure) {
213+ $this->printSummary();
214+ exit(1);
215+ }
216+ }
217+
218+ return $this;
219+ }
220+
221+ /**
222+ * テストをスキップする
223+ *
224+ * @param string $description テストの説明
225+ * @param string $reason スキップする理由。デフォルト: "まだ実装されていません"
226+ * @return Tester このインスタンス
227+ */
228+ public function skip(string $description,
229+ string $reason = 'まだ実装されていません'): Tester {
230+ if ($this->verboseOutput) {
231+ $this->output(" ⋄ スキップ: {$description}... "
232+ .$this->colorize('cyan', "スキップ"));
233+ $this->output($this->colorize('cyan', " → {$reason}"));
234+ }
235+
236+ return $this;
237+ }
238+
239+ /**
240+ * 条件がtrueである事をアサートする
241+ *
242+ * @param bool $condition チェックする条件
243+ * @param string $message 失敗時のオプションメッセージ
244+ * @throws AssertionFailedException アサーションが失敗した場合
245+ * @return Tester このインスタンス
246+ */
247+ public function assertTrue(bool $condition,
248+ string $message = '条件がtrueであることを期待しました'): Tester {
249+ if ($condition !== true) {
250+ throw new AssertionFailedException($message);
251+ }
252+
253+ return $this;
254+ }
255+
256+ /**
257+ * 条件がfalseである事をアサートする
258+ *
259+ * @param bool $condition チェックする条件
260+ * @param string $message 失敗時のオプションメッセージ
261+ * @throws AssertionFailedException アサーションが失敗した場合
262+ * @return Tester このインスタンス
263+ */
264+ public function assertFalse(bool $condition,
265+ string $message = '条件がfalseであることを期待しました'): Tester {
266+ if ($condition !== false) {
267+ throw new AssertionFailedException($message);
268+ }
269+
270+ return $this;
271+ }
272+
273+ /**
274+ * 二つの値が等しい事をアサートする
275+ *
276+ * @param mixed $expected 期待値
277+ * @param mixed $actual 実際の値
278+ * @param string|null $message 失敗時のオプションメッセージ
279+ * @throws AssertionFailedException アサーションが失敗した場合
280+ * @return Tester このインスタンス
281+ */
282+ public function assertEquals(mixed $expected, mixed $actual,
283+ ?string $message = null): Tester {
284+ if ($expected != $actual) {
285+ if ($message === null) {
286+ $expected = $this->exportValue($expected);
287+ $actual = $this->exportValue($actual);
288+ $message = "{$expected}を期待しましたが、{$actual}が得られました";
289+ }
290+
291+ throw new AssertionFailedException($message);
292+ }
293+
294+ return $this;
295+ }
296+
297+ /**
298+ * 二つの値が同一である事をアサートする
299+ *
300+ * @param mixed $expected 期待値
301+ * @param mixed $actual 実際の値
302+ * @param string|null $message 失敗時のオプションメッセージ
303+ * @throws AssertionFailedException アサーションが失敗した場合
304+ * @return Tester このインスタンス
305+ */
306+ public function assertSame(mixed $expected, mixed $actual,
307+ ?string $message = null): Tester {
308+ if ($expected !== $actual) {
309+ if ($message === null) {
310+ $expected = $this->exportValue($expected);
311+ $actual = $this->exportValue($actual);
312+ $message =
313+ "{$expected}を期待しましたが、{$actual}が得られました(厳密な比較)";
314+ }
315+
316+ throw new AssertionFailedException($message);
317+ }
318+
319+ return $this;
320+ }
321+
322+ /**
323+ * 値がnullである事をアサートする
324+ *
325+ * @param mixed $actual チェックする値
326+ * @param string|null $message 失敗時のオプションメッセージ
327+ * @throws AssertionFailedException アサーションが失敗した場合
328+ * @return Tester このインスタンス
329+ */
330+ public function assertNull(mixed $actual, ?string $message = null): Tester {
331+ if ($actual !== null) {
332+ if ($message === null) {
333+ $actual = $this->exportValue($actual);
334+ $message = "nullを期待しましたが、{$actual}が得られました";
335+ }
336+
337+ throw new AssertionFailedException($message);
338+ }
339+
340+ return $this;
341+ }
342+
343+ /**
344+ * 値がnullでない事をアサートする
345+ *
346+ * @param mixed $actual チェックする値
347+ * @param string|null $message 失敗時のオプションメッセージ
348+ * @throws AssertionFailedException アサーションが失敗した場合
349+ * @return Tester このインスタンス
350+ */
351+ public function assertNotNull(mixed $actual, ?string $message = null): Tester {
352+ if ($actual === null) {
353+ if ($message === null) {
354+ $message = "値がnullでない事を期待しました";
355+ }
356+
357+ throw new AssertionFailedException($message);
358+ }
359+
360+ return $this;
361+ }
362+
363+ /**
364+ * 値が特定のキーを持つ事をアサートする
365+ *
366+ * @param mixed $key チェックするキー
367+ * @param array $array チェックする配列
368+ * @param string|null $message 失敗時のオプションメッセージ
369+ * @throws AssertionFailedException アサーションが失敗した場合
370+ * @return Tester このインスタンス
371+ */
372+ public function assertArrayHasKey(mixed $key, array $array,
373+ ?string $message = null): Tester {
374+ if (!is_array($array) && !($array instanceof \ArrayAccess)) {
375+ throw new AssertionFailedException(
376+ '第2引数は配列又はArrayAccessを実装している必要があります');
377+ }
378+
379+ if (!array_key_exists($key, $array)) {
380+ if ($message === null) {
381+ $message = "配列がキー '{$key}' を持つ事を期待しました";
382+ }
383+
384+ throw new AssertionFailedException($message);
385+ }
386+
387+ return $this;
388+ }
389+
390+ /**
391+ * 文字列がサブ文字列を含むことをアサートする
392+ *
393+ * @param string $needle 検索するサブ文字列
394+ * @param string $haystack 検索対象の文字列
395+ * @param string|null $message 失敗時のオプションメッセージ
396+ * @throws AssertionFailedException アサーションが失敗した場合
397+ * @return Tester このインスタンス
398+ */
399+ public function assertStringContains(string $needle, string $haystack,
400+ ?string $message = null): Tester {
401+ if (!is_string($needle) || !is_string($haystack)) {
402+ throw new AssertionFailedException('両方の引数は文字列である必要があります');
403+ }
404+
405+ if (strpos($haystack, $needle) === false) {
406+ if ($message === null) {
407+ $message = "文字列 '{$haystack}' が '{$needle}' を含む事を期待しました";
408+ }
409+
410+ throw new AssertionFailedException($message);
411+ }
412+
413+ return $this;
414+ }
415+
416+ /**
417+ * コールバックが例外をスローする事をアサートする
418+ *
419+ * @param callable $callback 実行するコールバック
420+ * @param string $exceptionClass 期待される例外クラス
421+ * @param string|null $message 失敗時のオプションメッセージ
422+ * @throws AssertionFailedException アサーションが失敗した場合
423+ * @return Tester このインスタンス
424+ */
425+ public function assertThrows(callable $callback, string $exceptionClass,
426+ ?string $message = null): Tester {
427+ try {
428+ call_user_func($callback);
429+
430+ if ($message === null) {
431+ $message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした";
432+ }
433+
434+ throw new AssertionFailedException($message);
435+ } catch (\Throwable $e) {
436+ if (!($e instanceof $exceptionClass)) {
437+ if ($message === null) {
438+ $message = "'{$exceptionClass}' 型の例外を期待しましたが、"
439+ .get_class($e)." が得られました";
440+ }
441+
442+ throw new AssertionFailedException($message);
443+ }
444+ }
445+
446+ return $this;
447+ }
448+
449+ /**
450+ * コレクションが指定された要素数を持つ事
451+ *
452+ * @param int $expoected 期待される要素数
453+ * @param iterable $collection 配列または Traversable
454+ * @param string|null $message 失敗時のカスタムメッセージ
455+ * @return Tester
456+ *
457+ * @throws AssertionFailedException
458+ */
459+ public function assertCount(int $expected, iterable $collection, ?string $message = null): Tester {
460+ $actual = is_countable($collection) ? count($collection) : iterator_count($collection);
461+
462+ if ($actual !== $expected) {
463+ $message ??= "要素数が {$expected} であることを期待しましたが、{$actual} でした";
464+ throw new AssertionFailedException($message);
465+ }
466+
467+ return $this;
468+ }
469+
470+ /**
471+ * 現在の describe ブロック内の残りすべての it() をスキップ
472+ *
473+ * @param string $reason スキップ理由(出力に表示)
474+ * @return void
475+ */
476+ public function skipAll(string $reason = 'このテストケースはスキップされました'): void {
477+ $this->skipCurrentDescribe = true;
478+
479+ if ($this->verboseOutput) {
480+ $this->output($this->colorize('cyan', " スキップAll: {$reason}"));
481+ }
482+ }
483+
484+ /**
485+ * テスト結果の詳細を表示する
486+ *
487+ * @return Tester
488+ */
489+ public function printSummary(): Tester {
490+ $this->output('');
491+ $this->output($this->colorize('bold', "テスト結果の概要:"));
492+ $this->output(" テスト総数: {$this->testCount}");
493+ $this->output(" ".$this->colorize('green', "合格: {$this->passCount}"));
494+ $this->output(" ".$this->colorize('red', "失敗: {$this->failCount}"));
495+ $this->output(" ".$this->colorize('yellow', "エラー: {$this->errorCount}"));
496+ $this->output('');
497+
498+ // 失敗を書き出す
499+ if (count($this->failures) > 0) {
500+ $this->output($this->colorize('bold', "失敗:"));
501+
502+ foreach ($this->failures as $i => $f) {
503+ $num = $i + 1;
504+ $this->output(" {$num}) {$f['testCase']} → {$f['test']}");
505+ $this->output(" ".$this->colorize('red', $f['message']));
506+ $this->output('');
507+ }
508+ }
509+
510+ // エラーを書き出す
511+ if (count($this->errors) > 0) {
512+ $this->output($this->colorize('bold', "エラー:"));
513+
514+ foreach ($this->errors as $i => $e) {
515+ $num = $i + 1;
516+ $this->output(" {$num}) {$e['testCase']} → {$e['test']}");
517+ $this->output(" ".$this->colorize('yellow', $e['message']));
518+
519+ if (isset($e['trace'])) {
520+ $this->output(" ".$this->colorize('yellow', "スタックトレース:"));
521+ $this->output(" ".$this->colorize('yellow', $e['trace']));
522+ }
523+
524+ $this->output('');
525+ }
526+ }
527+
528+ if ($this->failCount === 0 && $this->errorCount === 0) {
529+ $this->output($this->colorize('green', "全てのテストに合格しました!"));
530+ } else {
531+ $this->output($this->colorize('red', "テストが失敗・エラーで完了しました。"));
532+ }
533+
534+ return $this;
535+ }
536+
537+ // 機能性メソッド
538+
539+ /**
540+ * コンソールにテキストを出力する
541+ *
542+ * @param string $text 出力するテキスト
543+ * @param bool $newline 改行を追加するかどうか
544+ * @return void
545+ */
546+ private function output(string $text, bool $newline = true): void {
547+ echo $text.($newline ? PHP_EOL : '');
548+ }
549+
550+ /**
551+ * 有効な場合はテキストに色を適用する
552+ *
553+ * @param string $color 色名
554+ * @param string $text 色付けするテキスト
555+ * @return string
556+ */
557+ private function colorize(string $color, string $text): string {
558+ if (!$this->colorOutput || !isset($this->colors[$color])) {
559+ return $text;
560+ }
561+
562+ return $this->colors[$color].$text.$this->colors['reset'];
563+ }
564+
565+ /**
566+ * 値を表示用の文字列としてエクスポートする
567+ *
568+ * @param mixed $value エクスポートする値
569+ * @return string
570+ */
571+ private function exportValue(mixed $value): string {
572+ if (is_null($value)) return 'null';
573+ if (is_bool($value)) return $value ? 'true' : 'false';
574+ if (is_array($value)) return 'Array('.count($value).')';
575+ if (is_object($value)) return get_class($value).' Object';
576+
577+ if (is_string($value)) {
578+ if (strlen($value) > 40) {
579+ return "'".substr($value, 0, 37)."...'";
580+ }
581+
582+ return "'{$value}'";
583+ }
584+
585+ return (string)$value;
586+ }
587+
588+ /**
589+ * テストの失敗を記録する
590+ *
591+ * @param string $message 失敗メッセージ
592+ * @return void
593+ */
594+ private function recordFailure(string $message): void {
595+ $this->failures[] = [
596+ 'testCase' => $this->currentTestCase,
597+ 'test' => $this->currentTest,
598+ 'message' => $message,
599+ ];
600+ }
601+
602+ /**
603+ * テストのエラーを記録する
604+ *
605+ * @param string $message エラーメッセージ
606+ * @param string|null $trace スタックトレース
607+ * @return void
608+ */
609+ private function recordError(string $message, ?string $trace = null): void {
610+ $this->errors[] = [
611+ 'testCase' => $this->currentTestCase,
612+ 'test' => $this->currentTest,
613+ 'message' => $message,
614+ 'trace' => $trace,
615+ ];
616+ }
617+}
@@ -0,0 +1,79 @@
1+<?php
2+namespace Std\Test;
3+
4+require_once __DIR__.'/../../../autoload.php';
5+
6+use Std\Lib\Tester;
7+use Std\Lib\Activitypub;
8+use Site\Controller\BlogPost;
9+
10+if (ACTIVITYPUB_ENABLED) {
11+ $test = new Tester([
12+ 'colorOutput' => true,
13+ 'verboseOutput' => true
14+ ]);
15+
16+ /*
17+ postInbox(array $activity): void
18+ update(): string
19+ sendActorUpdate(array $params): void
20+ */
21+
22+ $test->describe('ActivityPub情報の受け取り', function($test): void {
23+ $test->it('このインスタンスのユーザーを受け取れるはず', function($test): void {
24+ $ap = new Activitypub();
25+ $res = $ap->getActor();
26+ $test->assertNotNull($res);
27+ $json = json_decode($res, true);
28+
29+ $expect = 'Person';
30+ $test->assertEquals($json['type'], $expect);
31+
32+ $expect = 'http://127.0.0.1:8000';
33+ $test->assertEquals($json['url'], $expect);
34+
35+ $expect = 'http://127.0.0.1:8000/ap/actor';
36+ $test->assertEquals($json['id'], $expect);
37+
38+ $expect = 'http://127.0.0.1:8000/ap/inbox';
39+ $test->assertEquals($json['inbox'], $expect);
40+
41+ $expect = 'http://127.0.0.1:8000/ap/outbox';
42+ $test->assertEquals($json['outbox'], $expect);
43+
44+ $expect = 'http://127.0.0.1:8000/ap/followers';
45+ $test->assertEquals($json['followers'], $expect);
46+
47+ $expect = 'http://127.0.0.1:8000/ap/following';
48+ $test->assertEquals($json['following'], $expect);
49+ });
50+
51+ $test->it('アクティビティを受け取れるはず', function($test): void {
52+ $blog = new BlogPost;
53+ $posts = $blog->getPosts('/blog/', null);
54+ $ap = new Activitypub($posts);
55+ $res = $ap->getActivity('a8c04518-4181-4ec6-9ef0-3f88f23b84b6'); // /blog/feature-test
56+ $test->assertNotNull($res);
57+ $json = json_decode($res, true);
58+
59+ $expect = 'Create';
60+ $test->assertEquals($json['type'], $expect);
61+
62+ $expect = '【マークダウン】関数';
63+ $test->assertEquals($json['object']['name'], $expect);
64+
65+ $expect = 'https://www.w3.org/ns/activitystreams#Public';
66+ $test->assertEquals($json['object']['to'][0], $expect);
67+
68+ $expect = 'zBbw7K7L1dEwr6htZX5JevRv3oLtHwXQU4o5jT8jn9RKF';
69+ $test->assertEquals($json['attachment'][0]['digestMultibase'], $expect);
70+
71+ $expect = 'http://127.0.0.1:8000/static/article/o_53803618dc1691.28179609-orig.jpg';
72+ $test->assertEquals($json['attachment'][0]['url'], $expect);
73+ });
74+ });
75+
76+ // TODO: 未完了
77+
78+ $test->printSummary();
79+}
@@ -0,0 +1,229 @@
1+<?php
2+namespace Std\Test;
3+
4+require_once __DIR__.'/../../../autoload.php';
5+
6+use Std\Lib\Tester;
7+use Std\Lib\Auth;
8+
9+if (AUTH_ENABLED) {
10+ $test = new Tester([
11+ 'colorOutput' => true,
12+ 'verboseOutput' => true
13+ ]);
14+
15+ if (AUTH_REGISTER_ENABLED) {
16+ $test->describe('ユーザー登録', function($test): void {
17+ $test->it('アカウントを作成出来るはず', function($test): void {
18+ $user = "admin1234";
19+ $pass = "testTEST1234!#$%@[]:;";
20+ $passVerify = "testTEST1234!#$%@[]:;";
21+ $email = "test@076.co.jp";
22+
23+ $auth = new Auth();
24+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
25+
26+ $expect = true;
27+
28+ $test->assertNotNull($res);
29+ $test->assertEquals($res->isSuccess, $expect);
30+ });
31+
32+ $test->it('空フィールドでアカウントを作成出来ないはず', function($test): void {
33+ $user = "";
34+ $pass = "testTEST1234!#$%@[]:;";
35+ $passVerify = "testTEST1234!#$%@[]:;";
36+ $email = "test@076.ne.jp";
37+
38+ $auth = new Auth();
39+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
40+
41+ $expect = 'エラー:ユーザー名をご入力下さい。'.'<br />';
42+
43+ $test->assertNotNull($res);
44+ $test->assertEquals($res->message, $expect);
45+
46+ $user = "admin5678";
47+ $pass = "";
48+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
49+
50+ $expect = 'エラー:パスワードをご入力下さい。'.'<br />';
51+
52+ $test->assertNotNull($res);
53+ $test->assertEquals($res->message, $expect);
54+
55+ $pass = "testTEST1234!#$%@[]:;";
56+ $email = "";
57+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
58+
59+ $expect = 'エラー:メールアドレスをご入力下さい。'.'<br />';
60+
61+ $test->assertNotNull($res);
62+ $test->assertEquals($res->message, $expect);
63+ });
64+
65+ $test->it('既にユーザー名やメールアドレスが存在するアカウントを作成出来ないはず', function($test): void {
66+ $user = "admin1234";
67+ $pass = "testTEST1234!#$%@[]:;";
68+ $passVerify = "testTEST1234!#$%@[]:;";
69+ $email = "test@076.ne.jp";
70+
71+ $auth = new Auth();
72+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
73+
74+ $expect = 'ユーザー「admin1234」は既に存在します。'.'<br />';
75+
76+ $test->assertNotNull($res);
77+ $test->assertEquals($res->message, $expect);
78+
79+ $user = "admin5678";
80+ $email = "test@076.co.jp";
81+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
82+
83+ $expect = 'ユーザー「test@076.co.jp」は既に存在します。'.'<br />';
84+
85+ $test->assertNotNull($res);
86+ $test->assertEquals($res->message, $expect);
87+ });
88+
89+ $test->it('一致しない、短い、又は不安なパスワードでアカウントを作成出来ないはず', function($test): void {
90+ $user = "admin5678";
91+ $pass = "testtest1234!#$%@[]:;";
92+ $passVerify = "testTEST1234!#$%@[]:;";
93+ $email = "test@076.ne.jp";
94+
95+ $auth = new Auth();
96+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
97+
98+ $expect = 'エラー:パスワードは一致していません。'.'<br />';
99+
100+ $test->assertNotNull($res);
101+ $test->assertEquals($res->message, $expect);
102+
103+ $pass = "testTEST1234";
104+ $passVerify = "testTEST1234";
105+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
106+
107+ $expect = 'エラー:パスワードは20以上をご入力下さい。'.'<br />';
108+
109+ $test->assertNotNull($res);
110+ $test->assertEquals($res->message, $expect);
111+
112+ $pass = "testTEST1234testTEST";
113+ $passVerify = "testTEST1234testTEST";
114+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
115+
116+ $expect = 'エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。'.'<br />';
117+
118+ $test->assertNotNull($res);
119+ $test->assertEquals($res->message, $expect);
120+ });
121+
122+ $test->it('不正なメールアドレスでアカウントを作成出来ないはず', function($test): void {
123+ $user = "admin5678";
124+ $pass = "testTEST1234!#$%@[]:;";
125+ $passVerify = "testTEST1234!#$%@[]:;";
126+ $email = "test@076.niggerballs";
127+
128+ $auth = new Auth();
129+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
130+
131+ $expect = 'エラー:メールアドレスは不正です。'.'<br />';
132+
133+ $test->assertNotNull($res);
134+ $test->assertEquals($res->message, $expect);
135+
136+ $email = "test@076";
137+ $res = $auth->mkUser($user, $pass, $passVerify, $email);
138+
139+ $expect = 'エラー:メールアドレスは不正です。'.'<br />';
140+
141+ $test->assertNotNull($res);
142+ $test->assertEquals($res->message, $expect);
143+ });
144+
145+ // ユーザーの削除
146+ $users = scandir(ROOT.'/data/user');
147+ $userFile = '';
148+ foreach ($users as $u) {
149+ if (str_contains($u, 'admin1234')) {
150+ $userFile = $u;
151+ break;
152+ }
153+ }
154+ unlink(ROOT.'/data/user/'.$userFile);
155+ rmdir(ROOT.'/public/static/user/admin1234');
156+ });
157+ }
158+
159+ // TODO: CLIからクッキーを設定出来ないから、「Lib\Auth::getUserData(): Return value must be of type stdClass, null returned」って発生する・・・
160+ // $test->describe('ログインとログアウト', function($test): void {
161+ // $user = "admin1234";
162+ // $pass = "testTEST1234!#$%@[]:;";
163+ // $passVerify = "testTEST1234!#$%@[]:;";
164+ // $email = "test@076.co.jp";
165+
166+ // $auth = new Auth();
167+ // $res = $auth->mkUser($user, $pass, $passVerify, $email);
168+ // unset($auth);
169+
170+ // $test->it('正しい認証情報でログイン出来るはず', function($test) use ($user, $pass): void {
171+ // $auth = new Auth();
172+ // $res = $auth->setToken($user, $pass);
173+
174+ // $test->assertTrue($res->isSuccess);
175+ // $test->assertEquals('ログイン成功', $res->message);
176+
177+ // $auth2 = new Auth();
178+ // $usr = $auth2->getLoggedInUser();
179+
180+ // $test->assertNotNull($usr);
181+ // $test->assertEquals($user, $usr->username);
182+ // });
183+
184+ // $test->it('間違ったパスワードでログイン出来ないはず', function($test) use ($user): void {
185+ // $auth = new Auth();
186+ // $res = $auth->setToken($user, 'fuseipassword');
187+
188+ // $test->assertFalse($res->isSuccess);
189+ // $test->assertEquals('ユーザー名又はパスワードが一致していません', $res->message);
190+ // });
191+
192+ // $test->it('存在しないユーザーでログイン出来ないはず', function($test) use ($pass): void {
193+ // $auth = new Auth();
194+ // $res = $auth->setToken('fumeiuser', $pass);
195+
196+ // $test->assertFalse($res->isSuccess);
197+ // $test->assertEquals('ユーザー名又はパスワードが一致していません', $res->message);
198+ // });
199+
200+ // $test->it('ログアウト出来るはず', function($test) use ($user, $pass): void {
201+ // $auth = new Auth();
202+ // $auth->setToken($user, $pass);
203+
204+ // $res = $auth->logout();
205+
206+ // $test->assertTrue($res->isSuccess);
207+ // $test->assertEquals('ログイン成功', $res->message);
208+
209+ // $auth2 = new Auth();
210+ // $usr = $auth2->getLoggedInUser();
211+
212+ // $test->assertNull($usr);
213+ // });
214+
215+ // // ユーザーの削除
216+ // $users = scandir(ROOT.'/data/user');
217+ // $userFile = '';
218+ // foreach ($users as $u) {
219+ // if (str_contains($u, 'admin1234')) {
220+ // $userFile = $u;
221+ // break;
222+ // }
223+ // }
224+ // unlink(ROOT.'/data/user/'.$userFile);
225+ // rmdir(ROOT.'/public/static/user/admin1234');
226+ // });
227+
228+ $test->printSummary();
229+}
@@ -0,0 +1,94 @@
1+<?php
2+namespace Std\Test;
3+
4+require_once __DIR__.'/../../../autoload.php';
5+
6+use Std\Lib\Tester;
7+use Std\Lib\Curl;
8+
9+if (CURL_ENABLED) {
10+ $test = new Tester([
11+ 'colorOutput' => true,
12+ 'verboseOutput' => true
13+ ]);
14+
15+ $test->describe('Curlの基本的な機能性', function($test): void {
16+ $test->it('URLで使って新しいインスタンスを作成するはず', function($test): void {
17+ $curl = new Curl('https://076.moe');
18+ $test->assertNotNull($curl);
19+ });
20+
21+ $test->it('メソッドでURLを設定出来るはす', function($test): void {
22+ $curl = new Curl();
23+ $curl->setUrl('https://076.moe');
24+ $test->assertNotNull($curl);
25+ });
26+
27+ $test->it('ヘッダー文字を作成出来るはず', function($test): void {
28+ $curl = new Curl('https://076.moe');
29+ $curl->setHeaders([
30+ 'Accept' => 'application/json',
31+ 'User-Agent' => 'LoliTest/1.0'
32+ ]);
33+
34+ $reflectionClass = new \ReflectionClass($curl);
35+ $method = $reflectionClass->getMethod('buildHeaderString');
36+ $method->setAccessible(true);
37+
38+ $headerString = $method->invoke($curl);
39+ $test->assertStringContains('Accept: application/json', $headerString);
40+ $test->assertStringContains('User-Agent: LoliTest/1.0', $headerString);
41+ });
42+
43+ $test->skip('移転を対応するはず', '作成中・・・');
44+
45+ $test->it('メソッドチェーニングを対応するはず', function($test): void {
46+ $curl = new Curl();
47+ $result = $curl->setUrl('https://076.moe')
48+ ->setMethod('GET')
49+ ->setTimeout(30);
50+
51+ $test->assertSame($curl, $result);
52+ });
53+ });
54+
55+ $test->describe('Curl HTTP リクエスト', function($test): void {
56+ $networkAvailable = true;
57+
58+ if (!$networkAvailable) {
59+ $test->skip('076.moeでGETリクエストの確認', 'ネットワークが無効です。');
60+ $test->skip('postman-echo.comでPOSTリクエストの確認', 'ネットワークが無効です。');
61+ return;
62+ }
63+
64+ $test->it('076.moeでGETリクエストの確認', function($test): void {
65+ $curl = new Curl('https://076.moe');
66+ $result = $curl->execute();
67+
68+ $test->assertTrue($result->isSuccess);
69+ $test->assertEquals(200, $curl->getResponseCode());
70+ $test->assertNotNull($curl->getResponseBody());
71+ $test->assertStringContains('<html', $curl->getResponseBody());
72+ });
73+
74+ $test->it('postman-echo.comでPOSTリクエストの確認', function($test): void {
75+ $curl = new Curl();
76+ $curl->setUrl('https://postman-echo.com/post')
77+ ->setMethod('POST')
78+ ->setPostFields([
79+ 'name' => '山田太郎',
80+ 'email' => 't.yamada@example.com'
81+ ]);
82+
83+ $result = $curl->execute();
84+ $test->assertTrue($result->isSuccess);
85+ $test->assertEquals(200, $curl->getResponseCode());
86+
87+ $responseBody = $curl->getResponseBody();
88+ $test->assertStringContains('山田太郎', $responseBody);
89+ $test->assertStringContains('t.yamada@example.com', $responseBody);
90+ });
91+ });
92+
93+ $test->printSummary();
94+}
@@ -0,0 +1,234 @@
1+<?php
2+namespace Std\Test;
3+
4+require_once __DIR__.'/../../../autoload.php';
5+
6+use Std\Lib\Tester;
7+use Std\Lib\Markdown;
8+
9+$test = new Tester([
10+ 'colorOutput' => true,
11+ 'verboseOutput' => true
12+]);
13+
14+function mkfile(string $body): void {
15+ $post = fopen(ROOT."/blog/dietrannynigger.md", "w");
16+ fwrite($post, "title: 【】\n");
17+ fwrite($post, "uuid: ".uuid()."\n");
18+ fwrite($post, "author: 諏訪子\n");
19+ fwrite($post, "date: ".date("Y-m-d H:i:s")."\n");
20+ fwrite($post, "thumbnail: \n");
21+ fwrite($post, "thumborient: center\n");
22+ fwrite($post, "category: \n");
23+ fwrite($post, "----\n");
24+ fwrite($post, $body);
25+ fclose($post);
26+}
27+
28+function rmfile(): void {
29+ unlink(ROOT."/blog/dietrannynigger.md");
30+}
31+
32+class Constants {
33+ public const START = "<p> ";
34+ public const END = " </p>";
35+}
36+
37+$test->describe('マークダウン', function($test): void {
38+
39+ $test->it('メタデータをパーシング出来るはず', function($test): void {
40+ $md = new Markdown('feature-test', '/blog/');
41+ $test->assertNotNull($md);
42+
43+ $meta = $md->getMetadata();
44+ $test->assertNotNull($meta);
45+
46+ $expect = new \stdClass;
47+ $expect->title = '【マークダウン】関数';
48+ $expect->uuid = 'a8c04518-4181-4ec6-9ef0-3f88f23b84b6';
49+ $expect->author = '諏訪子';
50+ $expect->date = '2025-11-07 20:33:42';
51+ $expect->thumbnail = 'o_53803618dc1691.28179609-orig.jpg';
52+ $expect->thumborient = 'center';
53+ $expect->category = ['test', 'おちんちん', 'でっかいおっぱい'];
54+ $expect->css = ['code', 'blockquote', 'table', 'blink'];
55+
56+ $test->assertEquals($expect, $meta);
57+ });
58+
59+ $test->it('横線はHTMLに交換出来るはず', function($test): void {
60+ mkfile('----');
61+
62+ $md = new Markdown('dietrannynigger', '/blog/');
63+ $test->assertNotNull($md);
64+
65+ $body = $md->parse();
66+ $test->assertNotNull($body);
67+
68+ $expect = "<hr />";
69+ $actual = str_replace("\n", '', trim($body));
70+
71+ $test->assertEquals($expect, $actual);
72+ });
73+
74+ $test->it('ヘッダーはHTMLに交換出来るはず', function($test): void {
75+ mkfile('# ケロケロ');
76+
77+ $md = new Markdown('dietrannynigger', '/blog/');
78+ $test->assertNotNull($md);
79+
80+ $body = $md->parse();
81+ $test->assertNotNull($body);
82+
83+ $expect = "<h1 id=\"section-1\">ケロケロ</h1>";
84+ $actual = trim($body);
85+
86+ $test->assertEquals($expect, $actual);
87+
88+ // ---------------
89+
90+ mkfile('## ケロケロ');
91+
92+ $md = new Markdown('dietrannynigger', '/blog/');
93+ $test->assertNotNull($md);
94+
95+ $body = $md->parse();
96+ $test->assertNotNull($body);
97+
98+ $expect = "<h2 id=\"section-1\">ケロケロ</h2>";
99+ $actual = trim($body);
100+
101+ $test->assertEquals($expect, $actual);
102+
103+ // ---------------
104+
105+ mkfile('### ケロケロ');
106+
107+ $md = new Markdown('dietrannynigger', '/blog/');
108+ $test->assertNotNull($md);
109+
110+ $body = $md->parse();
111+ $test->assertNotNull($body);
112+
113+ $expect = "<h3 id=\"section-1\">ケロケロ</h3>";
114+ $actual = trim($body);
115+
116+ $test->assertEquals($expect, $actual);
117+
118+ // ---------------
119+
120+ mkfile('#### ケロケロ');
121+
122+ $md = new Markdown('dietrannynigger', '/blog/');
123+ $test->assertNotNull($md);
124+
125+ $body = $md->parse();
126+ $test->assertNotNull($body);
127+
128+ $expect = "<h4 id=\"section-1\">ケロケロ</h4>";
129+ $actual = trim($body);
130+
131+ $test->assertEquals($expect, $actual);
132+
133+ // ---------------
134+
135+ mkfile('##### ケロケロ');
136+
137+ $md = new Markdown('dietrannynigger', '/blog/');
138+ $test->assertNotNull($md);
139+
140+ $body = $md->parse();
141+ $test->assertNotNull($body);
142+
143+ $expect = "<h5 id=\"section-1\">ケロケロ</h5>";
144+ $actual = trim($body);
145+
146+ $test->assertEquals($expect, $actual);
147+
148+ // ---------------
149+
150+ mkfile('###### ケロケロ');
151+
152+ $md = new Markdown('dietrannynigger', '/blog/');
153+ $test->assertNotNull($md);
154+
155+ $body = $md->parse();
156+ $test->assertNotNull($body);
157+
158+ $expect = "<h6 id=\"section-1\">ケロケロ</h6>";
159+ $actual = trim($body);
160+
161+ $test->assertEquals($expect, $actual);
162+ });
163+
164+ $test->skip('コメントはHTMLに交換出来るはず', '作成中・・・');
165+
166+ $test->it('画像、音楽、動画はHTMLに交換出来るはず', function($test): void {
167+ mkfile("![](https://ass.technicalsuwako.moe/banner.png)\n
168+![ケロケロ](https://ass.technicalsuwako.moe/banner.png)\n
169+![ケロケロ#max-width: 200px;](https://ass.technicalsuwako.moe/banner.png)\n
170+$[audio/ogg](https://ass.technicalsuwako.moe/砕月着信音.ogg)\n
171+#[video/mp4](https://ass.technicalsuwako.moe/フリーズ.mp4)");
172+
173+ $md = new Markdown('dietrannynigger', '/blog/');
174+ $test->assertNotNull($md);
175+
176+ $body = $md->parse();
177+ $test->assertNotNull($body);
178+
179+ $expect = '<p><img style="width: 100%; " src="https://ass.technicalsuwako.moe/banner.png" alt="" /> </p> '
180+ . '<p><img style="width: 100%; " src="https://ass.technicalsuwako.moe/banner.png" alt="ケロケロ" /> </p> '
181+ . '<p><img style="width: 100%; max-width: 200px;" src="https://ass.technicalsuwako.moe/banner.png" alt="ケロケロ" /> </p> '
182+ . '<p><audio controls><source src="https://ass.technicalsuwako.moe/砕月着信音.ogg" type="audio/ogg" /></audio> </p> '
183+ . '<p><video style="max-width: 100%;" controls><source src="https://ass.technicalsuwako.moe/フリーズ.mp4" type="video/mp4" /></video> </p>';
184+ $actual = str_replace("\n", '', str_replace(' ', '', trim($body)));
185+
186+ $test->assertEquals($expect, $actual);
187+ });
188+
189+ $test->it('基本的なマークダウンはHTMLに交換出来るはず', function($test): void {
190+ mkfile("**太文字**\n
191+*イタリック*\n
192+_下線_\n
193+~取消線~\n
194+!:(5)明滅文字:!\n
195+^(72)フォントサイズ^\n
196+%(ff00ff)フォントカラー%");
197+
198+ $md = new Markdown('dietrannynigger', '/blog/');
199+ $test->assertNotNull($md);
200+
201+ $body = $md->parse();
202+ $test->assertNotNull($body);
203+
204+ $expect = '<p> <strong>太文字</strong> </p> '
205+ . '<p> <em>イタリック</em> </p> '
206+ . '<p> <u>下線</u> </p> '
207+ . '<p> <s>取消線</s> </p> '
208+ . '<p> <span class="blink" style="animation-duration: 5s;">明滅文字</span> </p> '
209+ . '<p> <span style="font-size: 72px;">フォントサイズ</span> </p> '
210+ . '<p> <span style="color: #ff00ff;">フォントカラー</span> </p>';
211+ $actual = str_replace("\n", '', trim($body));
212+
213+ $test->assertEquals($expect, $actual);
214+ });
215+
216+ $test->it('複数マークダウンはHTMLに交換出来るはず', function($test): void {
217+ mkfile("[^(500)<God>(すわこ)](https://techsuwako.jp/)^");
218+
219+ $md = new Markdown('dietrannynigger', '/blog/');
220+ $test->assertNotNull($md);
221+
222+ $body = $md->parse();
223+ $test->assertNotNull($body);
224+
225+ $expect = Constants::START.'<a href="https://techsuwako.jp/"><span style="font-size: 500px;"><ruby>God<rp>(</rp><rt>すわこ</rt><rp>)</rp></ruby></a></span>'.Constants::END;
226+ $actual = str_replace("\n", '', trim($body));
227+
228+ $test->assertEquals($expect, $actual);
229+ });
230+
231+ rmfile();
232+});
233+
234+$test->printSummary();
@@ -0,0 +1,38 @@
1+<?php
2+namespace Std\Test;
3+
4+require_once __DIR__.'/../../../autoload.php';
5+
6+use Std\Lib\Tester;
7+use Std\Lib\Route;
8+
9+$test = new Tester([
10+ 'colorOutput' => true,
11+ 'verboseOutput' => true
12+]);
13+
14+$test->describe('ルート', function($test): void {
15+ $test->it('ルートに追加出来るはず', function($test): void {
16+ class Webpage {
17+ public function about(array $params): void {}
18+ }
19+
20+ $routes = [
21+ Route::add('GET', 'about', Webpage::class.'@about'),
22+ ];
23+
24+ $expect = [
25+ [
26+ 'method' => 'GET',
27+ 'path' => 'about',
28+ 'class' => 'Std\Test\Webpage@about',
29+ 'params' => [],
30+ ]
31+ ];
32+
33+ $test->assertNotNull($routes);
34+ $test->assertEquals($expect, $routes);
35+ });
36+});
37+
38+$test->printSummary();
変更 tester.php
@@ -8,15 +8,17 @@
88 mb_internal_encoding('UTF-8');
99 header('Content-Type: text/plain; charset=UTF-8');
1010
11-$testDir = ROOT.'/src/Site/Test';
12-$testFiles = glob($testDir.'/*.php');
11+$testDirs = [ROOT.'/src/Site/Test', ROOT.'/src/Std/Test'];
12+$testFiles = [];
13+foreach ($testDirs as $d) $testFiles[] = glob($d.'/*.php');
14+$testFiles = array_merge(...$testFiles);
1315
1416 if (empty($testFiles)) {
15- echo "テストファイルは{$testDir}にありません\n";
17+ echo "テストファイルではありません\n";
1618 exit(1);
1719 }
1820
19-echo "{$testDir}にある".count($testFiles)."個テストファイルを実行中:\n";
21+echo count($testFiles)."個テストファイルを実行中:\n";
2022 echo "------------------------------------------------\n";
2123
2224 $totalFiles = 0;
変更 util.php
@@ -384,8 +384,8 @@
384384 return $len == $sum;
385385 }
386386
387-function getImageInfo(string $url): \Site\Lib\Image {
388- $img = new \Site\Lib\Image($url);
387+function getImageInfo(string $url): \Std\Lib\Image {
388+ $img = new \Std\Lib\Image($url);
389389 return $img;
390390 }
391391