Merge pull request 'Cronライブラリ' (#46) from cron into master

Reviewed-on: http://192.168.10.104:3000/suwako/LittleBeast/pulls/46

suwako <suwako@076.moe>、2026-11-10 19:24
コミット 64fb903dfcd9bd7a428daac013b875ea170196ae ・親 e19826b ・親 800f280 ・ファイルを見る

11個のファイル、+1727 -192

変更 .gitignore
@@ -1,6 +1,7 @@
11 data/*.pem
22 data/*.txt
33 data/*.json
4+data/cron
45 data/cache
56 data/user/*.json
67 log/*.txt
変更 README-JP.md
@@ -188,5 +188,22 @@
188188 }
189189 ```
190190
191+### Cron
192+定期的なタスク(例えば、新しいブログ投稿を ActivityPub のフォロワーに送る `apfederate`)はシステムの cron で実行します。\
193+`config/config.php` で `CRON_ENABLED` を `true` にして(ActivityPub を使う場合は `FEDIINFO['domain']` も設定して)、
194+php-fpm を実行するユーザーの crontab に以下の行を追加して下さい(`crontab -u www -e`):
195+```
196+* * * * * /usr/local/bin/php-8.5 /var/www/htdocs/cron.php
197+```
198+
199+ジョブはコマンドラインで管理します:
200+```sh
201+php cron.php add "*/10 * * * *" apfederate
202+php cron.php list
203+php cron.php help
204+```
205+
206+タスクは `cron.php` で登録します。
207+
191208 ## 必要な PHP モジュール
192-* php_gmp
209+* php_gmp
変更 README.md
@@ -188,5 +188,22 @@
188188 }
189189 ```
190190
191+### Cron
192+Scheduled tasks (for example `apfederate`, which sends new blog posts to ActivityPub followers) are run by the system cron.\
193+Set `CRON_ENABLED` to `true` in `config/config.php` (and `FEDIINFO['domain']` when using ActivityPub),
194+then add this line to the crontab of the user php-fpm runs as (`crontab -u www -e`):
195+```
196+* * * * * /usr/local/bin/php-8.5 /var/www/htdocs/cron.php
197+```
198+
199+Jobs are managed from the command line:
200+```sh
201+php cron.php add "*/10 * * * *" apfederate
202+php cron.php list
203+php cron.php help
204+```
205+
206+Tasks are registered in `cron.php`.
207+
191208 ## Required PHP module
192209 * php_gmp
@@ -50,6 +50,7 @@
5050 'pass' => 'hogehogehoge',
5151 ]);
5252 define('FEDIINFO', [
53+ 'domain' => '',
5354 'actor' => 'loli',
5455 'actorNick' => 'ロリ',
5556 'desc' => '',
@@ -68,6 +69,7 @@
6869 define('LOGGING_ENABLED', true);
6970 define('ATOM_ENABLED', true);
7071 define('ACTIVITYPUB_ENABLED', false);
72+define('CRON_ENABLED', false);
7173 define('CURL_ENABLED', true);
7274 define('AUTH_ENABLED', false);
7375 define('OPENPROVIDER_ENABLED', false);
追加 cron.php
@@ -0,0 +1,208 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+
39+// システムのcrontabから毎分呼ぶ
40+// * * * * * php /var/www/htdocs/cron.php
41+// Webから呼ばれない様に(public/の外にあるけど念の為)
42+if (PHP_SAPI !== 'cli') {
43+ http_response_code(404);
44+ exit;
45+}
46+
47+require_once __DIR__.DIRECTORY_SEPARATOR.'/autoload.php';
48+define('ROOT', realpath(__DIR__));
49+require_once ROOT.'/config/config.php';
50+require_once ROOT.'/util.php';
51+mb_internal_encoding('UTF-8');
52+
53+use Site\Controller\BlogPost;
54+use Std\Lib\Activitypub;
55+use Std\Lib\Cron;
56+use Std\Lib\CronTab;
57+
58+$cron = new Cron();
59+
60+////////////////////////////////////////////////////////////////
61+// タスクの登録
62+// ジョブの「タスク名」と処理を結び付ける。
63+// 処理はジョブ(CronTab)を受け取り、Result・bool・nullを返す(falseかResult::Errorは失敗)。
64+////////////////////////////////////////////////////////////////
65+
66+if (ACTIVITYPUB_ENABLED) {
67+ // 新しいブログ投稿をフォロワーに送る
68+ $cron->register('apfederate', fn() => (new Activitypub((new BlogPost())->getPosts('/blog/', null)))->federate());
69+ // プロフィール(アクター)の更新をフォロワーに送る
70+ $cron->register('apupdate', fn() => (new Activitypub())->sendActorUpdate());
71+}
72+
73+////////////////////////////////////////////////////////////////
74+
75+$usage = <<<TXT
76+使い方: php cron.php [コマンド]
77+
78+ (なし) 予定の来たジョブを実行する(crontabから毎分呼ぶ)
79+ list ジョブの一覧
80+ tasks 登録されたタスク名の一覧
81+ add "<予定>" <タスク> ジョブを追加する 例: add "*/10 * * * *" apfederate
82+ edit <ID> "<予定>" <タスク> ジョブを変更する 例: edit 1 "@hourly" apfederate
83+ enable|disable|toggle <ID> ジョブを有効・無効にする
84+ delete <ID> ジョブを削除する
85+ run <ID> ジョブを今すぐ実行する
86+ next <ID> [件数] 次の実行予定(既定5件)
87+
88+予定は「分 時 日 月 曜日」又は @yearly @monthly @weekly @daily @hourly。
89+「*」がシェルに展開されない様に、予定は引用符で囲んで下さい。
90+
91+TXT;
92+
93+$args = array_slice($argv, 1);
94+$cmd = array_shift($args) ?? '';
95+
96+/** 結果を表示して終了コードを返す */
97+$finish = function(Result $res): int {
98+ if ($res->isSuccess) {
99+ if ((string)$res->message !== '') echo $res->message, "\n";
100+ return 0;
101+ }
102+ fwrite(STDERR, $res->message."\n");
103+ return 1;
104+};
105+
106+/** 引数のID */
107+$id = function() use ($args, $usage): int {
108+ if (!isset($args[0]) || !ctype_digit($args[0])) {
109+ fwrite(STDERR, "IDを指定して下さい。\n\n{$usage}");
110+ exit(2);
111+ }
112+ return (int)$args[0];
113+};
114+
115+/** 引数のジョブ(未登録のタスクは警告だけ) */
116+$job = function(array $parts, bool $isActive = true) use ($cron, $usage): CronTab {
117+ try {
118+ $ct = CronTab::fromString(implode(' ', $parts), $isActive);
119+ } catch (\InvalidArgumentException $e) {
120+ fwrite(STDERR, $e->getMessage()."\n\n{$usage}");
121+ exit(2);
122+ }
123+ if (!in_array($ct->command, $cron->tasks(), true)) {
124+ fwrite(STDERR, "注意:タスク「{$ct->command}」は登録されていません(cron.phpの「タスクの登録」を参照)。\n");
125+ }
126+ return $ct;
127+};
128+
129+$fmt = fn(?int $t) => $t === null ? '-' : date('Y-m-d H:i', $t);
130+// 全角文字があっても列が揃う様に、表示幅で埋める
131+$pad = fn(string $s, int $w) => $s.str_repeat(' ', max(1, $w - mb_strwidth($s)));
132+
133+switch ($cmd) {
134+case '':
135+ // crontabはメールで出力を送るので、問題がなければ何も出力しない
136+ $res = $cron->run();
137+ if (!$res->isSuccess) exit($finish($res));
138+ foreach ($res->data['failed'] as $jid => $err) fwrite(STDERR, "#{$jid}:{$err}\n");
139+ exit(empty($res->data['failed']) ? 0 : 1);
140+
141+case 'list':
142+ $res = $cron->list();
143+ if (!$res->isSuccess) exit($finish($res));
144+ if (empty($res->data)) {
145+ echo "ジョブはありません。\n";
146+ exit(0);
147+ }
148+ $now = new \DateTimeImmutable();
149+ $widths = [5, 5, 20, 16, 17, 17];
150+ $row = function(array $cols) use ($pad, $widths): void {
151+ $line = '';
152+ foreach ($widths as $i => $w) $line .= $pad((string)$cols[$i], $w);
153+ echo rtrim($line.$cols[count($widths)]), "\n";
154+ };
155+ $row(['ID', '有効', '予定', 'タスク', '次回', '前回', 'エラー']);
156+ foreach ($res->data as $d) {
157+ $ct = CronTab::fromArray($d);
158+ $err = $ct->validate();
159+ $next = $err === null && $ct->isActive ? $ct->nextRun($now)?->getTimestamp() : null;
160+ $row([$ct->getId(), $ct->isActive ? 'o' : '-', $ct->expression(), $ct->command,
161+ $fmt($next), $fmt($ct->lastRun), $err ?? $ct->lastError ?? '']);
162+ }
163+ exit(0);
164+
165+case 'tasks':
166+ $tasks = $cron->tasks();
167+ echo empty($tasks) ? "登録されたタスクはありません。\n" : implode("\n", $tasks)."\n";
168+ exit(0);
169+
170+case 'add':
171+ exit($finish($cron->add($job($args))));
172+
173+case 'edit':
174+ // 有効・無効はそのまま
175+ $old = $cron->show($id());
176+ if (!$old->isSuccess) exit($finish($old));
177+ exit($finish($cron->edit($id(), $job(array_slice($args, 1), (bool)($old->data['isActive'] ?? true)))));
178+
179+case 'enable':
180+case 'disable':
181+case 'toggle':
182+case 'delete':
183+ exit($finish($cron->$cmd($id())));
184+
185+case 'run':
186+ exit($finish($cron->runNow($id())));
187+
188+case 'next':
189+ $res = $cron->show($id());
190+ if (!$res->isSuccess) exit($finish($res));
191+ $ct = CronTab::fromArray($res->data);
192+ if (($err = $ct->validate()) !== null) exit($finish(Result::Error($err)));
193+ $n = isset($args[1]) && ctype_digit($args[1]) ? max(1, min(100, (int)$args[1])) : 5;
194+ $t = new \DateTimeImmutable();
195+ for ($i = 0; $i < $n && ($t = $ct->nextRun($t)) !== null; $i++) echo $t->format('Y-m-d H:i (D)'), "\n";
196+ if ($i === 0) echo "5年以内に実行予定はありません。\n";
197+ exit(0);
198+
199+case 'help':
200+case '-h':
201+case '--help':
202+ echo $usage;
203+ exit(0);
204+
205+default:
206+ fwrite(STDERR, "不明なコマンド:{$cmd}\n\n{$usage}");
207+ exit(2);
208+}
@@ -16,7 +16,7 @@
1616
1717 merror {
1818 color: #f358ad;
19- border: 1px dashed var(--color-f35869, #f358ad);
19+ border: #f358ad;
2020 }
2121
2222 .fraction {
@@ -39,6 +39,7 @@
3939
4040 use Std\Lib\Curl;
4141 use LogType;
42+use Result;
4243 use Uri\Rfc3986\Uri;
4344
4445 /**
@@ -55,7 +56,15 @@
5556 private string $icon;
5657 private array $posts = [];
5758 private string $https;
59+ /** 秘密鍵のキャッシュ(1回の実行で何度も読まない様に) */
60+ private ?\OpenSSLAsymmetricKey $privKey = null;
5861
62+ /** 連合しない(会員限定等の)カテゴリー */
63+ private const RESTRICTED = ['memberonly', 'subscriberonly', 'staffonly', 'nintendodevonly'];
64+
65+ /** 配信に失敗した受付ボックスを再試行する回数(federate()の実行回数) */
66+ public const FEDERATE_MAX_TRIES = 5;
67+
5968 /**
6069 * コンストラクタ
6170 *
@@ -64,7 +73,9 @@
6473 public function __construct(array $posts = []) {
6574 if (!ACTIVITYPUB_ENABLED) return;
6675 $this->https = 'http'.((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 's' : '').'://';
67- $this->domain = $this->https.($_SERVER['HTTP_HOST'] ?? '127.0.0.1:8000');
76+ // CLI(cron)にはHTTP_HOSTが無いので、FEDIINFOの「domain」を優先する
77+ $this->domain = rtrim((string)(FEDIINFO['domain'] ?? ''), '/');
78+ if ($this->domain === '') $this->domain = $this->https.($_SERVER['HTTP_HOST'] ?? '127.0.0.1:8000');
6879 $this->actor = FEDIINFO['actor'];
6980 $this->actorNick = FEDIINFO['actorNick'];
7081 $this->desc = FEDIINFO['desc'];
@@ -135,67 +146,7 @@
135146
136147 foreach ($this->posts as $post) {
137148 if ($post['uuid'] != $uuid) continue;
138-
139- $items = [
140- '@context' => [
141- 'https://www.w3.org/ns/activitystreams',
142- 'https://w3id.org/security/v1',
143- [
144- 'Emoji' => 'toot:Emoji',
145- 'EmojiReact' => 'litepub:EmojiReact',
146- 'Hashtag' => 'as:Hashtag',
147- 'litepub' => 'http://litepub.social/ns#',
148- 'sensitive' => 'as:sensitive',
149- 'toot' => 'http://joinmastodon.org/ns#',
150- ],
151- ],
152- 'id' => "{$this->domain}/ap/activities/create/{$post['uuid']}",
153- 'type' => 'Create',
154- 'actor' => "{$this->domain}/ap/actor",
155- 'cc' => [
156- "{$this->domain}/ap/followers",
157- ],
158- 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
159- 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
160- 'object' => [
161- 'id' => "{$this->domain}/ap/objects/{$post['uuid']}",
162- 'type' => 'Note',
163- 'name' => $post['title'],
164- 'attributedTo' => "{$this->domain}/ap/actor",
165- 'cc' => [
166- "{$this->domain}/ap/followers",
167- ],
168- 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
169- 'content' => $post['preview']."<br /><br /><a href=\"{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
170- 'url' => "{$this->domain}/blog/{$post['slug']}",
171- 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
172- 'replies' => "{$this->domain}/ap/objects/{$uuid}/replies",
173- 'sensitive' => false,
174- ],
175- ];
176-
177- if (isset($post['category']) && !empty($post['category'])) {
178- $item['tag'] = [];
179- foreach ($post['category'] as $cat) {
180- $items['tag'][] = $cat;
181- }
182- }
183-
184- if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
185- $imgurl = "{$this->domain}/static/article/{$post['thumbnail']}";
186- $imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
187- $imgraw = file_get_contents($imgpath);
188-
189- $items['attachment'] = [
190- [
191- 'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
192- 'mediaType' => mime_content_type($imgpath),
193- 'type' => "Image",
194- 'url' => $imgurl,
195- ],
196- ];
197- }
198-
149+ $items = $this->buildCreate($post);
199150 break;
200151 }
201152
@@ -210,74 +161,9 @@
210161 public function getOutbox(): string {
211162 if (!ACTIVITYPUB_ENABLED) return '';
212163 $items = [];
213- $counter = 0;
214164
215- foreach ($this->posts as $post) {
216- $uid = $post['uuid'];
165+ foreach ($this->posts as $post) $items[] = $this->buildCreate($post);
217166
218- $items[] = [
219- '@context' => [
220- 'https://www.w3.org/ns/activitystreams',
221- 'https://w3id.org/security/v1',
222- [
223- 'Emoji' => 'toot:Emoji',
224- 'EmojiReact' => 'litepub:EmojiReact',
225- 'Hashtag' => 'as:Hashtag',
226- 'litepub' => 'http://litepub.social/ns#',
227- 'sensitive' => 'as:sensitive',
228- 'toot' => 'http://joinmastodon.org/ns#',
229- ],
230- ],
231- 'id' => "{$this->domain}/ap/activities/create/{$uid}",
232- 'type' => 'Create',
233- 'actor' => "{$this->domain}/ap/actor",
234- 'cc' => [
235- "{$this->domain}/ap/followers",
236- ],
237- 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
238- 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
239- 'object' => [
240- 'id' => "{$this->domain}/ap/objects/{$uid}",
241- 'type' => 'Note',
242- 'name' => $post['title'],
243- 'attributedTo' => "{$this->domain}/ap/actor",
244- 'cc' => [
245- "{$this->domain}/ap/followers",
246- ],
247- 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
248- 'content' => $post['preview']."<br /><br /><a href=\"{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
249- 'url' => "{$this->domain}/blog/{$post['slug']}",
250- 'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
251- 'replies' => "{$this->domain}/ap/objects/{$uid}/replies",
252- 'sensitive' => false,
253- ],
254- ];
255-
256- if (isset($post['category']) && !empty($post['category'])) {
257- $items[$counter]['tag'] = [];
258- foreach ($post['category'] as $cat) {
259- $items[$counter]['tag'][] = $cat;
260- }
261- }
262-
263- if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
264- $imgurl = "{$this->domain}/static/article/{$post['thumbnail']}";
265- $imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
266- $imgraw = file_get_contents($imgpath);
267-
268- $items[$counter]['attachment'] = [
269- [
270- 'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
271- 'mediaType' => mime_content_type($imgpath),
272- 'type' => "Image",
273- 'url' => $imgurl,
274- ],
275- ];
276- }
277-
278- $counter++;
279- }
280-
281167 $outbox = [
282168 '@context' => [
283169 'https://www.w3.org/ns/activitystreams',
@@ -410,20 +296,123 @@
410296 /**
411297 * アクター更新をフォロワーに送信する
412298 *
413- * @param array $params パラメータ配列
414- * @return void
299+ * @return Result 送信した件数
415300 */
416- public function sendActorUpdate(array $params): void {
417- if (!ACTIVITYPUB_ENABLED) return;
418- $f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
419- $ap = new Activitypub();
420- $inboxes = implode("\n", $f);
421- $update = json_decode($ap->update(), true);
301+ public function sendActorUpdate(): Result {
302+ if (!ACTIVITYPUB_ENABLED) return Result::Error('ActivityPubは無効です。');
303+ $update = json_decode($this->update(), true);
304+ $ok = $ng = 0;
422305
423- foreach ($f as $inbox) $this->sendActivity($inbox, $update);
306+ foreach ($this->followerInboxes() as $inbox) {
307+ if ($this->sendActivity($inbox, $update)) $ok++;
308+ else $ng++;
309+ }
310+
311+ $data = ['delivered' => $ok, 'failed' => $ng];
312+ if ($ok === 0 && $ng > 0) return Result::Error("アクター更新の送信に全部失敗しました({$ng}件)。", $data);
313+ return Result::Success("アクター更新を送信しました(成功{$ok}件、失敗{$ng}件)。", $data);
424314 }
425315
426316 /**
317+ * まだ連合していない公開投稿のCreateアクティビティをフォロワーに送信する(cronのタスク用)
318+ *
319+ * 送信済みのUUIDはdata/ap_federated.jsonに記録する。
320+ * 初回の実行では既存の投稿を全部送信済みとして記録するだけで、何も送信しない(古い投稿を一気に流さない為)。
321+ * 日付が未来の投稿はその日時になってから送信する。
322+ * 失敗した受付ボックスは次回以降に再試行し、FEDERATE_MAX_TRIES回で諦める。
323+ *
324+ * @param string|null $stateFile 記録ファイル(テスト用、既定はdata/ap_federated.json)
325+ * @return Result data:posts(送信を終えた投稿数)、delivered(配信成功数)、failed(配信失敗数)
326+ */
327+ public function federate(?string $stateFile = null): Result {
328+ if (!ACTIVITYPUB_ENABLED) return Result::Error('ActivityPubは無効です。');
329+ if (PHP_SAPI === 'cli' && empty(FEDIINFO['domain'])) {
330+ return Result::Error('CLIで実行する場合はconfig.phpのFEDIINFOに「domain」(例:https://example.com)を設定して下さい。');
331+ }
332+
333+ $stateFile ??= ROOT.'/data/ap_federated.json';
334+ $fp = @fopen($stateFile, 'c+');
335+ if ($fp === false) return Result::Error("「{$stateFile}」を開けません。");
336+ if (!flock($fp, LOCK_EX | LOCK_NB)) {
337+ fclose($fp);
338+ return Result::Error('別の連合処理が実行中です。');
339+ }
340+
341+ try {
342+ $raw = stream_get_contents($fp);
343+ $state = trim((string)$raw) === '' ? null : json_decode($raw, true);
344+ if ($state !== null && !is_array($state)) return Result::Error("「{$stateFile}」が壊れています。");
345+
346+ $posts = $this->federatablePosts();
347+ $zero = ['posts' => 0, 'delivered' => 0, 'failed' => 0];
348+
349+ if ($state === null) {
350+ $state = ['sent' => [], 'pending' => []];
351+ foreach ($posts as $p) $state['sent'][$p['uuid']] = time();
352+ $this->writeFederateState($fp, $state);
353+ return Result::Success('初回の実行なので、既存の投稿'.count($posts).'件を送信済みとして記録しました。', $zero);
354+ }
355+
356+ $state['sent'] = (array)($state['sent'] ?? []);
357+ $state['pending'] = (array)($state['pending'] ?? []);
358+ $byUuid = array_column($posts, null, 'uuid');
359+
360+ // 新しい投稿を送信待ちに追加する(古い順)
361+ $inboxes = null;
362+ foreach ($posts as $p) {
363+ $uuid = $p['uuid'];
364+ if (isset($state['sent'][$uuid]) || isset($state['pending'][$uuid])) continue;
365+ $inboxes ??= $this->followerInboxes();
366+ $state['pending'][$uuid] = ['inboxes' => $inboxes, 'tries' => 0];
367+ }
368+ if (empty($state['pending'])) return Result::Success('新しい投稿はありません。', $zero);
369+
370+ $done = $ok = $ng = 0;
371+ foreach ($state['pending'] as $uuid => $job) {
372+ // 削除された又は非公開になった投稿は送らない
373+ if (!isset($byUuid[$uuid])) {
374+ unset($state['pending'][$uuid]);
375+ $this->writeFederateState($fp, $state);
376+ continue;
377+ }
378+
379+ $activity = $this->buildCreate($byUuid[$uuid]);
380+ $left = [];
381+ foreach ((array)($job['inboxes'] ?? []) as $inbox) {
382+ if (is_string($inbox) && $this->sendActivity($inbox, $activity)) $ok++;
383+ else {
384+ $ng++;
385+ $left[] = $inbox;
386+ }
387+ }
388+
389+ $tries = (int)($job['tries'] ?? 0) + 1;
390+ if (empty($left) || $tries >= self::FEDERATE_MAX_TRIES) {
391+ if (!empty($left)) {
392+ logger(LogType::ActivityPub, "「{$uuid}」の配信を諦めました:".implode(', ', $left));
393+ }
394+ unset($state['pending'][$uuid]);
395+ $state['sent'][$uuid] = time();
396+ $done++;
397+ } else {
398+ $state['pending'][$uuid] = ['inboxes' => $left, 'tries' => $tries];
399+ }
400+
401+ // 途中で止まっても二重送信しない様に、1件毎に保存する
402+ $this->writeFederateState($fp, $state);
403+ }
404+
405+ $data = ['posts' => $done, 'delivered' => $ok, 'failed' => $ng];
406+ $msg = "投稿{$done}件を連合しました(配信成功{$ok}件、失敗{$ng}件)。";
407+ if ($ok === 0 && $ng > 0) return Result::Error($msg, $data);
408+ return Result::Success($msg, $data);
409+ } finally {
410+ flock($fp, LOCK_UN);
411+ fclose($fp);
412+ }
413+ }
414+
415+ /**
427416 * 受付ボックスへのリクエストのHTTP署名を検証する
428417 *
429418 * @param string $body リクエストの本文(生データ)
@@ -581,75 +570,234 @@
581570 *
582571 * @param string $inboxUrl 送信先のインボックスURL
583572 * @param array $activity 送信するアクティビティデータ
584- * @return void
573+ * @return bool 2xxが返ったらtrue
585574 */
586- private function sendActivity(string $inboxUrl, array $activity): void {
575+ protected function sendActivity(string $inboxUrl, array $activity): bool {
587576 if (!$this->isSafeRemoteUrl($inboxUrl)) {
588- logger(\LogType::ActivityPub, "安全ではない受付ボックスへの送信を拒否: {$inboxUrl}");
589- return;
577+ logger(LogType::ActivityPub, "安全ではない受付ボックスへの送信を拒否: {$inboxUrl}");
578+ return false;
590579 }
591580
592- $privFile = FEDIINFO['privkey'];
593- $priv = file_get_contents($privFile);
594- if ($priv === false) {
595- logger(LogType::ActivityPub, "エラー:秘密鍵「{$privFile}」の読込に失敗");
596- header('HTTP/1.1 500 Internal Server Error');
597- header('Content-Type: application/activity+json');
598- echo json_encode(['error' => '秘密鍵の読込に失敗']);
599- exit;
600- }
581+ $priv = $this->privateKey();
582+ if ($priv === null) return false;
601583
602- $body = json_encode($activity, JSON_UNESCAPED_SLASHES);
603- $digest = base64_encode(hash('sha256', $body, true));
604- $date = gmdate('D, d M Y H:i:s \G\M\T');
605- $uri = new Uri($inboxUrl);
584+ $body = json_encode($activity, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
585+ $headers = $this->signRequest($inboxUrl, $body, $priv);
586+ if ($headers === null) return false;
606587
588+ $curl = new Curl($inboxUrl);
589+ // リダイレクト先は検証されないので追わない / redirect targets aren't re-checked, so don't follow them
590+ $curl->setMethod('POST')
591+ ->setPostRaw($body)
592+ ->setHeaders($headers)
593+ ->setFollowRedirects(false)
594+ ->setTimeout(15)
595+ ->setCaInfo('/etc/ssl/cert.pem');
596+
597+ $res = $curl->execute();
598+ $code = $curl->getResponseCode();
599+ $ok = $res->isSuccess && $code >= 200 && $code < 300;
600+
601+ $msg = "アクティビティ「{$activity['type']}」を「{$inboxUrl}」に送信: HTTP {$code}";
602+ if (!$ok) $msg .= "、エラー:{$curl->getError()}、レスポンス:".substr($curl->getResponseBody(), 0, 500);
603+ logger(LogType::ActivityPub, $msg);
604+
605+ return $ok;
606+ }
607+
608+ /**
609+ * POSTリクエストのヘッダーを作り、HTTP署名を付ける
610+ * 署名対象:(request-target) host date digest(Mastodon等が必須とする組み合わせ)
611+ *
612+ * @param string $url 送信先のURL
613+ * @param string $body 送信する本文
614+ * @param \OpenSSLAsymmetricKey $priv 秘密鍵
615+ * @return array|null 「名前: 値」のヘッダー、署名に失敗した場合はnull
616+ */
617+ protected function signRequest(string $url, string $body, \OpenSSLAsymmetricKey $priv): ?array {
618+ $p = parse_url($url);
619+ if (!$p || empty($p['host'])) return null;
620+ $target = ($p['path'] ?? '') === '' ? '/' : $p['path'];
621+ if (isset($p['query'])) $target .= '?'.$p['query'];
622+
607623 $headers = [
608- 'Host' => $uri->getHost().($uri->getPort() ? ':'.$uri->getPort() : ''),
609- 'Date' => $date,
624+ 'Host' => $p['host'].(isset($p['port']) && (int)$p['port'] !== 443 ? ':'.$p['port'] : ''),
625+ 'Date' => gmdate('D, d M Y H:i:s \G\M\T'),
626+ 'Digest' => 'SHA-256='.base64_encode(hash('sha256', $body, true)),
610627 'Content-Type' => 'application/activity+json',
611- 'Digest' => "SHA-256=$digest",
612628 ];
613629
614- $stringToSign = "host: {$headers['Host']}\n"."date: {$headers['Date']}\n"."digest: {$headers['Digest']}";
615- logger(LogType::ActivityPub, "署名対象: {$stringToSign}");
630+ $stringToSign = "(request-target): post {$target}\n"
631+ ."host: {$headers['Host']}\n"
632+ ."date: {$headers['Date']}\n"
633+ ."digest: {$headers['Digest']}";
616634
617635 if (!openssl_sign($stringToSign, $signature, $priv, OPENSSL_ALGO_SHA256)) {
618- $error = openssl_error_string();
619- logger(LogType::ActivityPub, "エラー:署名に失敗: {$error}");
620- header('HTTP/1.1 500 Internal Server Error');
621- header('Content-Type: application/activity+json');
622- echo json_encode(['error' => '署名に失敗']);
623- exit;
636+ logger(LogType::ActivityPub, 'エラー:署名に失敗: '.openssl_error_string());
637+ return null;
624638 }
625639
626- $sigValue = base64_encode($signature);
627- $headers['Signature'] = "keyId=\"{$this->domain}/ap/actor#main-key\",";
628- $headers['Signature'] .= 'algorithm="rsa-sha256",';
629- $headers['Signature'] .= 'headers="host date digest",';
630- $headers['Signature'] .= 'signature="'.$sigValue.'"';
631- logger(LogType::ActivityPub, "署名: {$headers['Signature']}\n送信データ: {$body}");
640+ $headers['Signature'] = "keyId=\"{$this->domain}/ap/actor#main-key\","
641+ .'algorithm="rsa-sha256",'
642+ .'headers="(request-target) host date digest",'
643+ .'signature="'.base64_encode($signature).'"';
632644
633- $curl = new Curl($inboxUrl);
634- $curl->setMethod('POST')
635- ->setPostRaw($body)
636- ->setHeaders(array_map(fn($k, $v) => "$k: $v", array_keys($headers), $headers))
637- ->setCaInfo('/etc/ssl/cert.pem')
638- ->setVerbose(true)
639- ->setStderr(fopen(ROOT.'/log/ap_log.txt', 'a'));
645+ return array_map(fn($k, $v) => "{$k}: {$v}", array_keys($headers), $headers);
646+ }
640647
641- $success = $curl->execute();
642- $res = $curl->getResponseBody();
643- $code = $curl->getResponseCode();
644- $err = $curl->getError();
648+ /**
649+ * 秘密鍵を読み込む(1回だけ)
650+ *
651+ * @return \OpenSSLAsymmetricKey|null 失敗した場合はnull
652+ */
653+ private function privateKey(): ?\OpenSSLAsymmetricKey {
654+ if ($this->privKey !== null) return $this->privKey;
645655
646- var_dump(print_r($res));
647- logger(LogType::ActivityPub, "アクティビティは「{$inboxUrl}」に送信しました: HTTP {$code}");
648- logger(LogType::ActivityPub, "エラー: {$err}");
649- logger(LogType::ActivityPub, "レスポンス: {$res}");
656+ $file = FEDIINFO['privkey'];
657+ $pem = is_readable($file) ? file_get_contents($file) : false;
658+ $key = $pem === false ? false : openssl_pkey_get_private($pem);
659+ if ($key === false) {
660+ logger(LogType::ActivityPub, "エラー:秘密鍵「{$file}」の読込に失敗");
661+ return null;
662+ }
663+
664+ return $this->privKey = $key;
650665 }
651666
652667 /**
668+ * 投稿からCreateアクティビティを作る(getActivity・getOutbox・federateで共通)
669+ *
670+ * @param array $post 投稿データ
671+ * @return array アクティビティ
672+ */
673+ private function buildCreate(array $post): array {
674+ $uuid = $post['uuid'];
675+ $url = "{$this->domain}/blog/".rawurlencode($post['slug']);
676+ // 投稿の日付はPHPのタイムゾーンの時刻なので、UTCに変換する
677+ $published = gmdate('Y-m-d\TH:i:s\Z', strtotime($post['date']) ?: time());
678+ $esc = fn(string $s) => htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
679+
680+ $note = [
681+ 'id' => "{$this->domain}/ap/objects/{$uuid}",
682+ 'type' => 'Note',
683+ 'name' => $post['title'],
684+ 'attributedTo' => "{$this->domain}/ap/actor",
685+ 'cc' => [
686+ "{$this->domain}/ap/followers",
687+ ],
688+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
689+ 'content' => $esc($post['preview'])."<br /><br /><a href=\"{$esc($url)}\">読み続き</a>",
690+ 'url' => $url,
691+ 'published' => $published,
692+ 'replies' => "{$this->domain}/ap/objects/{$uuid}/replies",
693+ 'sensitive' => false,
694+ ];
695+
696+ // タグと添付は(Createではなく)Noteに付ける。そうしないと相手のサーバーに無視される
697+ if (!empty($post['category'])) {
698+ $note['tag'] = array_map(fn($cat) => ['type' => 'Hashtag', 'name' => '#'.$cat], array_values($post['category']));
699+ }
700+
701+ $thumb = $post['thumbnail'] ?? '';
702+ $imgpath = ROOT."/public/static/article/{$thumb}";
703+ if ($thumb !== '' && is_file($imgpath)) {
704+ $note['attachment'] = [
705+ [
706+ 'digestMultibase' => 'z'.base58btc_encode(hash_file('sha256', $imgpath, true)),
707+ 'mediaType' => mime_content_type($imgpath),
708+ 'type' => 'Image',
709+ 'url' => "{$this->domain}/static/article/".rawurlencode($thumb),
710+ ],
711+ ];
712+ }
713+
714+ return [
715+ '@context' => [
716+ 'https://www.w3.org/ns/activitystreams',
717+ 'https://w3id.org/security/v1',
718+ [
719+ 'Emoji' => 'toot:Emoji',
720+ 'EmojiReact' => 'litepub:EmojiReact',
721+ 'Hashtag' => 'as:Hashtag',
722+ 'litepub' => 'http://litepub.social/ns#',
723+ 'sensitive' => 'as:sensitive',
724+ 'toot' => 'http://joinmastodon.org/ns#',
725+ ],
726+ ],
727+ 'id' => "{$this->domain}/ap/activities/create/{$uuid}",
728+ 'type' => 'Create',
729+ 'actor' => "{$this->domain}/ap/actor",
730+ 'cc' => [
731+ "{$this->domain}/ap/followers",
732+ ],
733+ 'published' => $published,
734+ 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
735+ 'object' => $note,
736+ ];
737+ }
738+
739+ /**
740+ * 連合できる投稿(UUIDあり、公開、日付が過去)を古い順に返す
741+ *
742+ * @return array 投稿データの配列
743+ */
744+ private function federatablePosts(): array {
745+ $now = time();
746+ $out = [];
747+
748+ foreach ($this->posts as $p) {
749+ if (!is_string($p['uuid'] ?? null) || $p['uuid'] === '') continue;
750+ if (($p['preview'] ?? '') === '未許可') continue;
751+ if (array_intersect(self::RESTRICTED, (array)($p['category'] ?? []))) continue;
752+ $t = strtotime($p['date'] ?? '');
753+ if ($t === false || $t > $now) continue;
754+ $p['_time'] = $t;
755+ $out[] = $p;
756+ }
757+
758+ usort($out, fn($a, $b) => $a['_time'] <=> $b['_time']);
759+ return $out;
760+ }
761+
762+ /**
763+ * federate()の記録を書き込む
764+ *
765+ * @param resource $fp ロック済みのファイル
766+ * @param array $state 記録
767+ * @return void
768+ */
769+ private function writeFederateState($fp, array $state): void {
770+ ftruncate($fp, 0);
771+ rewind($fp);
772+ fwrite($fp, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
773+ fflush($fp);
774+ }
775+
776+ /**
777+ * フォロワーの受付ボックスを取得する(sharedInboxがあればそちら、重複なし)
778+ *
779+ * @return array 受付ボックスのURL
780+ */
781+ protected function followerInboxes(): array {
782+ $inboxes = [];
783+
784+ foreach ($this->getFollowersList() as $actor) {
785+ $data = $this->fetchRemoteJson($actor);
786+ if (!$data) {
787+ logger(LogType::ActivityPub, "フォロワー「{$actor}」の取得に失敗");
788+ continue;
789+ }
790+
791+ $inbox = $data['endpoints']['sharedInbox'] ?? null;
792+ if (!$this->isSafeRemoteUrl($inbox)) $inbox = $data['inbox'] ?? null;
793+ if (!$this->isSafeRemoteUrl($inbox)) continue;
794+ $inboxes[$inbox] = true;
795+ }
796+
797+ return array_keys($inboxes);
798+ }
799+
800+ /**
653801 * フォロワーを受け入れる
654802 *
655803 * @param array $activity フォローアクティビティデータ
@@ -694,7 +842,9 @@
694842 'object' => $activity,
695843 ];
696844
697- $this->sendActivity($inbox, $accept);
845+ if (!$this->sendActivity($inbox, $accept)) {
846+ logger(LogType::ActivityPub, "「{$followerActor}」へのAcceptの送信に失敗");
847+ }
698848 }
699849
700850 /**
@@ -721,8 +871,9 @@
721871 */
722872 private function getFollowersList(): array {
723873 $file = ROOT.'/data/followers.txt';
724- $f = array_filter(explode("\n", file_get_contents($file)));
725- return file_exists($file) ? array_filter(explode("\n", file_get_contents($file))) : [];
874+ if (!is_file($file)) return [];
875+ $lines = file($file, FILE_IGNORE_NEW_LINES) ?: [];
876+ return array_values(array_filter(array_map('trim', $lines), fn($l) => $l !== ''));
726877 }
727878
728879 /**
@@ -0,0 +1,401 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Lib;
39+
40+use Result;
41+use LogType;
42+use Std\Lib\CronTab;
43+
44+/**
45+ * 定期的なタスクの実行(cron)
46+ *
47+ * ジョブはdata/cron/cron.jsonに保存し、タスクはコードで名前に登録する:
48+ * $cron = new Cron();
49+ * $cron->register('apfederate', fn() => …);
50+ * $cron->add(CronTab::fromString('0 * * * * apfederate'));
51+ * $cron->run(); // システムのcrontabから毎分呼ぶ(cron.php)
52+ *
53+ * run()は前回の実行から今までに予定されていたジョブを1回ずつ実行する。
54+ * 同じ分に2回呼ばれても2重に実行せず、前の実行がまだ終わっていなければ何もしない。
55+ * サーバーが止まっていた等で遅れた場合は、最大CATCH_UPの分だけ遡る。
56+ */
57+class Cron {
58+ /** 遅れた時に遡る最大時間(秒) */
59+ public const CATCH_UP = 3600;
60+
61+ private string $dataDir;
62+ private string $dataFile;
63+ private string $stateFile;
64+ private string $lockFile;
65+
66+ /** @var array タスク名 => callable */
67+ private array $tasks = [];
68+
69+ /**
70+ * @param string|null $dataDir 保存先(テスト用。既定はdata/cron/)
71+ */
72+ public function __construct(?string $dataDir = null) {
73+ $this->dataDir = rtrim($dataDir ?? ROOT.'/data/cron', '/').'/';
74+ $this->dataFile = $this->dataDir.'cron.json';
75+ $this->stateFile = $this->dataDir.'state.json';
76+ $this->lockFile = $this->dataDir.'run.lock';
77+ }
78+
79+ /**
80+ * タスクを登録する
81+ * タスクはジョブ(CronTab)を引数に受け取り、Result・bool・nullを返す。falseかResult::Errorは失敗とする。
82+ *
83+ * @param string $name タスク名(ジョブのcommand)
84+ * @param callable $task 実行する処理
85+ * @return void
86+ */
87+ public function register(string $name, callable $task): void {
88+ $this->tasks[$name] = $task;
89+ }
90+
91+ /**
92+ * 登録されたタスク名の一覧
93+ *
94+ * @return array
95+ */
96+ public function tasks(): array {
97+ return array_keys($this->tasks);
98+ }
99+
100+ public function getLastId(): int {
101+ $res = $this->list();
102+ if (!$res->isSuccess || empty($res->data)) return 0;
103+ return (int)max(array_column($res->data, 'id'));
104+ }
105+
106+ /**
107+ * 全てのジョブ
108+ *
109+ * @return Result dataはジョブの配列
110+ */
111+ public function list(): Result {
112+ if (!CRON_ENABLED) return Result::Error('エラー:Cronシステムは無効です。');
113+ if (!$this->ensureStorage()) return Result::Error('Cronディレクトリの作成に失敗');
114+
115+ $fp = fopen($this->dataFile, 'r');
116+ if (!$fp) return Result::Error('Cronファイルが存在しません。');
117+ flock($fp, LOCK_SH);
118+ $content = stream_get_contents($fp);
119+ flock($fp, LOCK_UN);
120+ fclose($fp);
121+
122+ $data = $this->decode($content);
123+ if ($data === null) return Result::Error('Cronの復号化に失敗');
124+ return Result::Success('', $data);
125+ }
126+
127+ /**
128+ * ジョブ1件
129+ *
130+ * @param int $id ジョブのID
131+ * @return Result dataはジョブ
132+ */
133+ public function show(int $id): Result {
134+ $res = $this->list();
135+ if (!$res->isSuccess) return $res;
136+
137+ foreach ($res->data as $d) {
138+ if (($d['id'] ?? 0) === $id) return Result::Success('', $d);
139+ }
140+ return Result::Error("ID {$id} のCronエントリが見つかりません。");
141+ }
142+
143+ public function enable(int $id): Result {
144+ return $this->setActive($id, fn(bool $current): bool => true);
145+ }
146+
147+ public function disable(int $id): Result {
148+ return $this->setActive($id, fn(bool $current): bool => false);
149+ }
150+
151+ public function toggle(int $id): Result {
152+ return $this->setActive($id, fn(bool $current): bool => !$current);
153+ }
154+
155+ /**
156+ * ジョブを追加する
157+ *
158+ * @param CronTab $ct ジョブ(IDは自動で付ける)
159+ * @return Result dataは追加したジョブ
160+ */
161+ public function add(CronTab $ct): Result {
162+ if (($err = $ct->validate()) !== null) return Result::Error($err);
163+
164+ return $this->modify(function(array &$data) use ($ct): Result {
165+ $ids = array_column($data, 'id');
166+ $ct->setId(empty($ids) ? 1 : (int)max($ids) + 1);
167+ $data[] = $ct->toArray();
168+ return Result::Success("Cronエントリ #{$ct->getId()} を追加しました。", $ct->toArray());
169+ });
170+ }
171+
172+ /**
173+ * ジョブを変更する(実行履歴は残す)
174+ *
175+ * @param int $id ジョブのID
176+ * @param CronTab $ct 新しい内容
177+ * @return Result dataは変更後のジョブ
178+ */
179+ public function edit(int $id, CronTab $ct): Result {
180+ if (($err = $ct->validate()) !== null) return Result::Error($err);
181+
182+ return $this->modify(function(array &$data) use ($id, $ct): Result {
183+ foreach ($data as $i => $d) {
184+ if (($d['id'] ?? 0) !== $id) continue;
185+ $ct->setId($id);
186+ $ct->lastRun = $d['lastRun'] ?? null;
187+ $ct->lastError = $d['lastError'] ?? null;
188+ $data[$i] = $ct->toArray();
189+ return Result::Success("ID {$id} のCronエントリを更新しました。", $data[$i]);
190+ }
191+ return Result::Error("ID {$id} のCronエントリが見つかりません。");
192+ });
193+ }
194+
195+ /**
196+ * ジョブを削除する
197+ *
198+ * @param int $id ジョブのID
199+ * @return Result dataは削除したジョブ
200+ */
201+ public function delete(int $id): Result {
202+ return $this->modify(function(array &$data) use ($id): Result {
203+ foreach ($data as $i => $d) {
204+ if (($d['id'] ?? 0) !== $id) continue;
205+ array_splice($data, $i, 1);
206+ return Result::Success("ID {$id} のCronエントリを削除しました。", $d);
207+ }
208+ return Result::Error("ID {$id} のCronエントリが見つかりません。");
209+ });
210+ }
211+
212+ /**
213+ * 予定の来たジョブを実行する(システムのcrontabから毎分呼ぶ)
214+ *
215+ * @param \DateTimeInterface|null $now 現在時刻(テスト用)
216+ * @return Result dataは ['ran' => [ID => 結果のメッセージ], 'failed' => [ID => エラー]]
217+ */
218+ public function run(?\DateTimeInterface $now = null): Result {
219+ if (!CRON_ENABLED) return Result::Error('エラー:Cronシステムは無効です。');
220+ if (!$this->ensureStorage()) return Result::Error('Cronディレクトリの作成に失敗');
221+
222+ // 前の実行がまだ終わっていなければ何もしない
223+ // (エラーにすると、長いタスクの間に毎分cronからメールが届くので成功扱い)
224+ $lock = @fopen($this->lockFile, 'c');
225+ if (!$lock) return Result::Error('ロックファイルを開けません。');
226+ if (!flock($lock, LOCK_EX | LOCK_NB)) {
227+ fclose($lock);
228+ return Result::Success('前の実行がまだ終わっていません。', ['ran' => [], 'failed' => [], 'busy' => true]);
229+ }
230+
231+ try {
232+ $now = \DateTimeImmutable::createFromInterface($now ?? new \DateTimeImmutable());
233+ $now = $now->setTime((int)$now->format('G'), (int)$now->format('i'));
234+
235+ // 前回の実行の次の分から今までが対象(最大CATCH_UPまで遡る)
236+ $state = $this->readState();
237+ $last = isset($state['lastTick']) ? (new \DateTimeImmutable())->setTimestamp((int)$state['lastTick'])
238+ ->setTimezone($now->getTimezone()) : null;
239+ $earliest = $now->modify('-'.self::CATCH_UP.' seconds');
240+ $from = $last === null ? $now->modify('-1 minute') : max($last, $earliest);
241+ if ($from >= $now) return Result::Success('この分は実行済みです。', ['ran' => [], 'failed' => []]);
242+
243+ $list = $this->list();
244+ if (!$list->isSuccess) return $list;
245+
246+ $ran = [];
247+ $failed = [];
248+ $updates = [];
249+ foreach ($list->data as $d) {
250+ $ct = CronTab::fromArray($d);
251+ if (!$ct->isActive || $ct->validate() !== null) continue;
252+
253+ // $fromより後、$now以前に予定があったか
254+ $due = $ct->nextRun($from);
255+ if ($due === null || $due > $now) continue;
256+
257+ [$ok, $msg] = $this->execute($ct);
258+ $updates[$ct->getId()] = ['lastRun' => $now->getTimestamp(), 'lastError' => $ok ? null : $msg];
259+ if ($ok) $ran[$ct->getId()] = $msg;
260+ else $failed[$ct->getId()] = $msg;
261+ }
262+
263+ // 実行結果を保存(実行中に変更されたジョブを上書きしない様に、IDで合わせる)
264+ if (!empty($updates)) {
265+ $this->modify(function(array &$data) use ($updates): Result {
266+ foreach ($data as $i => $d) {
267+ if (isset($updates[$d['id'] ?? 0])) $data[$i] = array_merge($d, $updates[$d['id']]);
268+ }
269+ return Result::Success();
270+ });
271+ }
272+ $this->writeState(['lastTick' => $now->getTimestamp()]);
273+
274+ $total = count($ran) + count($failed);
275+ return Result::Success("{$total}件を実行しました(失敗:".count($failed).'件)', ['ran' => $ran, 'failed' => $failed]);
276+ } finally {
277+ flock($lock, LOCK_UN);
278+ fclose($lock);
279+ }
280+ }
281+
282+ /**
283+ * ジョブを予定に関係なく今すぐ実行する(手動実行・テスト用)
284+ *
285+ * @param int $id ジョブのID
286+ * @return Result
287+ */
288+ public function runNow(int $id): Result {
289+ $res = $this->show($id);
290+ if (!$res->isSuccess) return $res;
291+
292+ $ct = CronTab::fromArray($res->data);
293+ [$ok, $msg] = $this->execute($ct);
294+ $this->modify(function(array &$data) use ($id, $ok, $msg): Result {
295+ foreach ($data as $i => $d) {
296+ if (($d['id'] ?? 0) === $id) $data[$i] = array_merge($d, ['lastRun' => time(), 'lastError' => $ok ? null : $msg]);
297+ }
298+ return Result::Success();
299+ });
300+ return $ok ? Result::Success($msg) : Result::Error($msg);
301+ }
302+
303+ ///////////////
304+
305+ /**
306+ * タスクを実行する(例外も失敗として扱う)
307+ *
308+ * @return array [成功か, メッセージ]
309+ */
310+ private function execute(CronTab $ct): array {
311+ $task = $this->tasks[$ct->command] ?? null;
312+ $label = "Cron #{$ct->getId()}({$ct->command})";
313+ if ($task === null) {
314+ $msg = "タスク「{$ct->command}」が登録されていません。";
315+ logger(LogType::Cron, "{$label}:{$msg}");
316+ return [false, $msg];
317+ }
318+
319+ $start = microtime(true);
320+ try {
321+ $res = $task($ct);
322+ $ok = !($res === false || ($res instanceof Result && !$res->isSuccess));
323+ $msg = $res instanceof Result ? (string)$res->message : ($ok ? '完了' : '失敗');
324+ } catch (\Throwable $e) {
325+ $ok = false;
326+ $msg = get_class($e).':'.$e->getMessage();
327+ }
328+
329+ logger(LogType::Cron, sprintf('%s %s:%s(%.2f秒)%s', date('Y-m-d H:i:s'), $label, $ok ? '成功' : '失敗',
330+ microtime(true) - $start, $msg !== '' ? " {$msg}" : ''));
331+ return [$ok, $msg === '' ? ($ok ? '完了' : '失敗') : $msg];
332+ }
333+
334+ private function setActive(int $id, callable $value): Result {
335+ return $this->modify(function(array &$data) use ($id, $value): Result {
336+ foreach ($data as $i => $d) {
337+ if (($d['id'] ?? 0) !== $id) continue;
338+ $data[$i]['isActive'] = $value((bool)($d['isActive'] ?? true));
339+ return Result::Success("ID {$id} を".($data[$i]['isActive'] ? '有効' : '無効').'にしました。', $data[$i]);
340+ }
341+ return Result::Error("ID {$id} のCronエントリが見つかりません。");
342+ });
343+ }
344+
345+ /**
346+ * ファイルをロックして読み、変更して書き戻す(同時に実行されても壊れない様に)
347+ * $fnがResult::Errorを返した場合は保存しない
348+ *
349+ * @param callable $fn function(array &$data): Result
350+ * @return Result $fnの結果
351+ */
352+ private function modify(callable $fn): Result {
353+ if (!CRON_ENABLED) return Result::Error('エラー:Cronシステムは無効です。');
354+ if (!$this->ensureStorage()) return Result::Error('Cronディレクトリの作成に失敗');
355+
356+ $fp = fopen($this->dataFile, 'c+');
357+ if (!$fp) return Result::Error('Cronファイルを開けません。');
358+ flock($fp, LOCK_EX);
359+
360+ try {
361+ $data = $this->decode(stream_get_contents($fp));
362+ if ($data === null) return Result::Error('Cronの復号化に失敗');
363+
364+ $res = $fn($data);
365+ if (!$res->isSuccess) return $res;
366+
367+ $json = json_encode(array_values($data), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
368+ ftruncate($fp, 0);
369+ rewind($fp);
370+ if (fwrite($fp, $json) === false) return Result::Error('Cronデータの保存に失敗');
371+ fflush($fp);
372+ return $res;
373+ } finally {
374+ flock($fp, LOCK_UN);
375+ fclose($fp);
376+ }
377+ }
378+
379+ private function decode(string|false $content): ?array {
380+ if ($content === false) return null;
381+ if (trim($content) === '') return [];
382+ $data = json_decode($content, true);
383+ return is_array($data) ? array_values($data) : null;
384+ }
385+
386+ private function ensureStorage(): bool {
387+ if (!is_dir($this->dataDir) && !@mkdir($this->dataDir, 0755, true) && !is_dir($this->dataDir)) return false;
388+ if (!file_exists($this->dataFile)) return file_put_contents($this->dataFile, '[]') !== false;
389+ return true;
390+ }
391+
392+ private function readState(): array {
393+ if (!is_file($this->stateFile)) return [];
394+ $data = json_decode((string)file_get_contents($this->stateFile), true);
395+ return is_array($data) ? $data : [];
396+ }
397+
398+ private function writeState(array $state): void {
399+ file_put_contents($this->stateFile, json_encode($state), LOCK_EX);
400+ }
401+}
@@ -0,0 +1,284 @@
1+<?php
2+namespace Std\Lib;
3+
4+/**
5+ * Cronのジョブ1件(スケジュールとタスク名)
6+ *
7+ * スケジュールは普通のcronと同じ5項目:分 時 日 月 曜日
8+ * * 全部
9+ * 5 その値だけ
10+ * 1-5 範囲
11+ * 1,3,5 リスト
12+ * */15 間隔(0, 15, 30, 45。「*」は半角の*)
13+ * 10-50/20 範囲の中の間隔(10, 30, 50)
14+ * jan-dec, sun-sat 月と曜日の名前(曜日の7も日曜日)
15+ * 又は @yearly @annually @monthly @weekly @daily @midnight @hourly
16+ *
17+ * 日と曜日の両方を指定した場合は、どちらかに合えば実行する(普通のcronと同じ)。
18+ * 時刻はPHPの既定のタイムゾーン(date_default_timezone_get())で判断する。
19+ */
20+class CronTab {
21+ private int $id = 0;
22+ public bool $isActive;
23+ public string $minute;
24+ public string $hour;
25+ public string $dayOfMonth;
26+ public string $month;
27+ public string $dayOfWeek;
28+ public string $command;
29+ /** 最後に実行した時刻(UNIX時間)、未実行ならnull */
30+ public ?int $lastRun = null;
31+ /** 最後の実行のエラー、成功ならnull */
32+ public ?string $lastError = null;
33+
34+ private const MACROS = [
35+ '@yearly' => '0 0 1 1 *',
36+ '@annually' => '0 0 1 1 *',
37+ '@monthly' => '0 0 1 * *',
38+ '@weekly' => '0 0 * * 0',
39+ '@daily' => '0 0 * * *',
40+ '@midnight' => '0 0 * * *',
41+ '@hourly' => '0 * * * *',
42+ ];
43+
44+ private const MONTHS = ['jan' => 1, 'feb' => 2, 'mar' => 3, 'apr' => 4, 'may' => 5, 'jun' => 6,
45+ 'jul' => 7, 'aug' => 8, 'sep' => 9, 'oct' => 10, 'nov' => 11, 'dec' => 12];
46+ private const DAYS = ['sun' => 0, 'mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6];
47+
48+ /** @var array|null 解析済みの各項目(値 => true)のキャッシュ */
49+ private ?array $parsed = null;
50+
51+ public function __construct(
52+ bool $isActive = true,
53+ string $minute = '*',
54+ string $hour = '*',
55+ string $dayOfMonth = '*',
56+ string $month = '*',
57+ string $dayOfWeek = '*',
58+ string $command = ''
59+ ) {
60+ $this->isActive = $isActive;
61+ $this->minute = trim($minute);
62+ $this->hour = trim($hour);
63+ $this->dayOfMonth = trim($dayOfMonth);
64+ $this->month = trim($month);
65+ $this->dayOfWeek = trim($dayOfWeek);
66+ $this->command = trim($command);
67+ }
68+
69+ /**
70+ * 「0 2 * * * apfederate」又は「@daily apfederate」から作る
71+ *
72+ * @param string $line スケジュールとタスク名
73+ * @param bool $isActive 有効にするか
74+ * @return self
75+ * @throws \InvalidArgumentException 形式が不正な場合
76+ */
77+ public static function fromString(string $line, bool $isActive = true): self {
78+ $parts = preg_split('/\s+/', trim($line));
79+ if (isset($parts[0]) && str_starts_with($parts[0], '@')) {
80+ $macro = strtolower($parts[0]);
81+ if (!isset(self::MACROS[$macro])) throw new \InvalidArgumentException("未対応のマクロ: {$parts[0]}");
82+ $parts = array_merge(explode(' ', self::MACROS[$macro]), array_slice($parts, 1));
83+ }
84+ if (count($parts) !== 6) {
85+ throw new \InvalidArgumentException('形式は「分 時 日 月 曜日 タスク名」又は「@daily タスク名」です。');
86+ }
87+ return new self($isActive, ...$parts);
88+ }
89+
90+ public static function fromArray(array $data): self {
91+ $ct = new self(
92+ (bool)($data['isActive'] ?? true),
93+ (string)($data['minute'] ?? '*'),
94+ (string)($data['hour'] ?? '*'),
95+ (string)($data['dayOfMonth'] ?? '*'),
96+ (string)($data['month'] ?? '*'),
97+ (string)($data['dayOfWeek'] ?? '*'),
98+ (string)($data['command'] ?? ''),
99+ );
100+ $ct->setId((int)($data['id'] ?? 0));
101+ $ct->lastRun = isset($data['lastRun']) ? (int)$data['lastRun'] : null;
102+ $ct->lastError = isset($data['lastError']) ? (string)$data['lastError'] : null;
103+ return $ct;
104+ }
105+
106+ public function getId(): int {
107+ return $this->id;
108+ }
109+
110+ public function setId(int $id): void {
111+ $this->id = $id;
112+ }
113+
114+ public function toArray(): array {
115+ return [
116+ 'id' => $this->id,
117+ 'isActive' => $this->isActive,
118+ 'minute' => $this->minute,
119+ 'hour' => $this->hour,
120+ 'dayOfMonth' => $this->dayOfMonth,
121+ 'month' => $this->month,
122+ 'dayOfWeek' => $this->dayOfWeek,
123+ 'command' => $this->command,
124+ 'lastRun' => $this->lastRun,
125+ 'lastError' => $this->lastError,
126+ ];
127+ }
128+
129+ /**
130+ * スケジュールの部分(「0 2 * * *」)
131+ */
132+ public function expression(): string {
133+ return "{$this->minute} {$this->hour} {$this->dayOfMonth} {$this->month} {$this->dayOfWeek}";
134+ }
135+
136+ /**
137+ * スケジュールとタスク名を確認する
138+ *
139+ * @return string|null エラーメッセージ、正しければnull
140+ */
141+ public function validate(): ?string {
142+ if (!preg_match('/^[A-Za-z0-9_.:-]{1,64}$/', $this->command)) {
143+ return 'タスク名は英数字と「_ . : -」の64文字以内で入力して下さい。';
144+ }
145+ try {
146+ $this->fields();
147+ } catch (\InvalidArgumentException $e) {
148+ return $e->getMessage();
149+ }
150+ return null;
151+ }
152+
153+ /**
154+ * 指定した時刻(分単位)に実行するか
155+ *
156+ * @param \DateTimeInterface $t 時刻
157+ * @return bool
158+ */
159+ public function isDue(\DateTimeInterface $t): bool {
160+ $f = $this->fields();
161+ return isset($f['minute'][(int)$t->format('i')])
162+ && isset($f['hour'][(int)$t->format('G')])
163+ && isset($f['month'][(int)$t->format('n')])
164+ && $this->dayMatches($t, $f);
165+ }
166+
167+ /**
168+ * 指定した時刻より後の、次の実行時刻
169+ *
170+ * @param \DateTimeInterface $after この時刻より後(同じ分は含まない)
171+ * @return \DateTimeImmutable|null 5年以内に無ければnull(2月30日等)
172+ */
173+ public function nextRun(\DateTimeInterface $after): ?\DateTimeImmutable {
174+ $f = $this->fields();
175+ $t = \DateTimeImmutable::createFromInterface($after);
176+ $t = $t->setTime((int)$t->format('G'), (int)$t->format('i'))->modify('+1 minute');
177+ $limit = $t->modify('+5 years');
178+
179+ // 合わない項目は、その単位ごと飛ばす(1分ずつ確認しない)
180+ while ($t <= $limit) {
181+ if (!isset($f['month'][(int)$t->format('n')])) {
182+ $t = $t->setDate((int)$t->format('Y'), (int)$t->format('n'), 1)->setTime(0, 0)->modify('+1 month');
183+ continue;
184+ }
185+ if (!$this->dayMatches($t, $f)) {
186+ $t = $t->setTime(0, 0)->modify('+1 day');
187+ continue;
188+ }
189+ if (!isset($f['hour'][(int)$t->format('G')])) {
190+ $t = $t->setTime((int)$t->format('G'), 0)->modify('+1 hour');
191+ continue;
192+ }
193+ if (!isset($f['minute'][(int)$t->format('i')])) {
194+ $t = $t->modify('+1 minute');
195+ continue;
196+ }
197+ return $t;
198+ }
199+
200+ return null;
201+ }
202+
203+ ///////////////
204+
205+ /**
206+ * 日と曜日の判定
207+ * どちらかが「*」で始まる場合は両方に合う事、両方とも指定された場合はどちらかに合う事(普通のcronと同じ)
208+ */
209+ private function dayMatches(\DateTimeInterface $t, array $f): bool {
210+ $dom = isset($f['dayOfMonth'][(int)$t->format('j')]);
211+ $dow = isset($f['dayOfWeek'][(int)$t->format('w')]);
212+ if (str_starts_with($this->dayOfMonth, '*') || str_starts_with($this->dayOfWeek, '*')) return $dom && $dow;
213+ return $dom || $dow;
214+ }
215+
216+ /**
217+ * 5項目を解析する
218+ *
219+ * @return array 項目名 => [値 => true]
220+ * @throws \InvalidArgumentException
221+ */
222+ private function fields(): array {
223+ if ($this->parsed !== null) return $this->parsed;
224+
225+ $dow = self::parseField($this->dayOfWeek, 0, 7, self::DAYS, '曜日');
226+ if (isset($dow[7])) {
227+ $dow[0] = true; // 7も日曜日
228+ unset($dow[7]);
229+ }
230+
231+ return $this->parsed = [
232+ 'minute' => self::parseField($this->minute, 0, 59, [], '分'),
233+ 'hour' => self::parseField($this->hour, 0, 23, [], '時'),
234+ 'dayOfMonth' => self::parseField($this->dayOfMonth, 1, 31, [], '日'),
235+ 'month' => self::parseField($this->month, 1, 12, self::MONTHS, '月'),
236+ 'dayOfWeek' => $dow,
237+ ];
238+ }
239+
240+ /**
241+ * 1項目を解析する(*, 5, 1-5, 1,3,5, 間隔, 10-50/20, 名前)
242+ *
243+ * @return array 値 => true
244+ * @throws \InvalidArgumentException
245+ */
246+ private static function parseField(string $field, int $min, int $max, array $names, string $label): array {
247+ $out = [];
248+ if ($field === '') throw new \InvalidArgumentException("{$label}が空です。");
249+
250+ foreach (explode(',', strtolower($field)) as $part) {
251+ $step = 1;
252+ if (str_contains($part, '/')) {
253+ [$part, $s] = explode('/', $part, 2);
254+ if (!ctype_digit($s) || (int)$s < 1) throw new \InvalidArgumentException("{$label}の間隔「{$s}」が不正です。");
255+ $step = (int)$s;
256+ }
257+
258+ if ($part === '*') {
259+ [$from, $to] = [$min, $max];
260+ } else if (str_contains($part, '-')) {
261+ [$a, $b] = explode('-', $part, 2);
262+ $from = self::value($a, $min, $max, $names, $label);
263+ $to = self::value($b, $min, $max, $names, $label);
264+ if ($from > $to) throw new \InvalidArgumentException("{$label}の範囲「{$part}」が逆です。");
265+ } else {
266+ $from = self::value($part, $min, $max, $names, $label);
267+ // 「5/15」は5から最後まで15毎
268+ $to = $step > 1 ? $max : $from;
269+ }
270+
271+ for ($v = $from; $v <= $to; $v += $step) $out[$v] = true;
272+ }
273+
274+ return $out;
275+ }
276+
277+ private static function value(string $v, int $min, int $max, array $names, string $label): int {
278+ if (isset($names[$v])) return $names[$v];
279+ if (!ctype_digit($v)) throw new \InvalidArgumentException("{$label}の値「{$v}」が不正です。");
280+ $n = (int)$v;
281+ if ($n < $min || $n > $max) throw new \InvalidArgumentException("{$label}の値「{$v}」は{$min}〜{$max}の範囲外です。");
282+ return $n;
283+ }
284+}
@@ -0,0 +1,449 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Test;
39+
40+require_once __DIR__.'/../../../autoload.php';
41+
42+use Std\Lib\Tester;
43+use Std\Lib\Activitypub;
44+use Std\Lib\Cron;
45+use Std\Lib\CronTab;
46+
47+$test = new Tester([
48+ 'colorOutput' => true,
49+ 'verboseOutput' => true
50+]);
51+
52+// テスト毎の一時ディレクトリ(最後に削除する)
53+$tmpDirs = [];
54+$tmp = function() use (&$tmpDirs): string {
55+ $dir = sys_get_temp_dir().'/lbcron_'.bin2hex(random_bytes(6));
56+ mkdir($dir, 0700, true);
57+ return $tmpDirs[] = $dir;
58+};
59+$at = fn(string $s) => new \DateTimeImmutable($s);
60+$next = fn(string $line, string $after) => CronTab::fromString($line)->nextRun(new \DateTimeImmutable($after))?->format('Y-m-d H:i');
61+
62+$test->describe('CronTabの解析', function($test): void {
63+ $test->it('5項目とタスク名を解析出来るはず', function($test): void {
64+ $ct = CronTab::fromString('*/5 2 * * 1-5 apfederate');
65+ $test->assertEquals('*/5 2 * * 1-5', $ct->expression());
66+ $test->assertEquals('apfederate', $ct->command);
67+ $test->assertTrue($ct->isActive);
68+ $test->assertNull($ct->validate());
69+ });
70+
71+ $test->it('マクロを展開出来るはず', function($test): void {
72+ $test->assertEquals('0 0 * * *', CronTab::fromString('@daily x')->expression());
73+ $test->assertEquals('0 * * * *', CronTab::fromString('@HOURLY x')->expression());
74+ $test->assertEquals('0 0 1 1 *', CronTab::fromString('@annually x')->expression());
75+ });
76+
77+ $test->it('形式が不正なら例外を投げるはず', function($test): void {
78+ $test->assertThrows(fn() => CronTab::fromString('* * * * apfederate'), \InvalidArgumentException::class);
79+ $test->assertThrows(fn() => CronTab::fromString('@often apfederate'), \InvalidArgumentException::class);
80+ });
81+
82+ $test->it('不正な値を検出するはず', function($test): void {
83+ $test->assertStringContains('範囲外', CronTab::fromString('61 * * * * x')->validate() ?? '');
84+ $test->assertStringContains('間隔', CronTab::fromString('*/0 * * * * x')->validate() ?? '');
85+ $test->assertStringContains('逆', CronTab::fromString('* 5-1 * * * x')->validate() ?? '');
86+ $test->assertStringContains('不正', CronTab::fromString('* * * foo * x')->validate() ?? '');
87+ $test->assertStringContains('タスク名', CronTab::fromString('* * * * * rm;-rf')->validate() ?? '');
88+ });
89+
90+ $test->it('配列に変換して戻せるはず', function($test): void {
91+ $ct = CronTab::fromString('0 3 * * sun backup', false);
92+ $ct->setId(7);
93+ $ct->lastRun = 1700000000;
94+ $copy = CronTab::fromArray($ct->toArray());
95+ $test->assertEquals($ct->toArray(), $copy->toArray());
96+ $test->assertEquals(7, $copy->getId());
97+ $test->assertFalse($copy->isActive);
98+ });
99+});
100+
101+$test->describe('CronTabの予定', function($test) use ($at, $next): void {
102+ $test->it('isDueは分単位で判断するはず', function($test) use ($at): void {
103+ $ct = CronTab::fromString('*/15 10 * * * x');
104+ $test->assertTrue($ct->isDue($at('2026-01-05 10:30')));
105+ $test->assertFalse($ct->isDue($at('2026-01-05 10:31')));
106+ $test->assertFalse($ct->isDue($at('2026-01-05 11:30')));
107+ });
108+
109+ $test->it('次の実行時刻を計算出来るはず', function($test) use ($next): void {
110+ $test->assertEquals('2026-01-05 10:45', $next('*/15 * * * * x', '2026-01-05 10:31'));
111+ // 同じ分は含まない
112+ $test->assertEquals('2026-01-05 10:45', $next('*/15 * * * * x', '2026-01-05 10:30'));
113+ // 「5/20」は5, 25, 45
114+ $test->assertEquals('2026-01-05 10:45', $next('5/20 * * * * x', '2026-01-05 10:26'));
115+ // 金曜日の後は月曜日
116+ $test->assertEquals('2026-01-12 09:00', $next('0 9 * * mon-fri x', '2026-01-09 10:00'));
117+ // 曜日の7も日曜日
118+ $test->assertEquals('2026-01-11 00:00', $next('0 0 * * 7 x', '2026-01-09 10:00'));
119+ // 月の名前
120+ $test->assertEquals('2026-07-01 00:00', $next('0 0 1 jan,jul * x', '2026-02-01 00:00'));
121+ });
122+
123+ $test->it('日と曜日の両方を指定したらどちらかに合えば実行するはず', function($test) use ($next): void {
124+ // 2026-02-06は金曜日、13日も金曜日
125+ $test->assertEquals('2026-02-06 00:00', $next('0 0 13 * fri x', '2026-02-01 00:00'));
126+ // 片方が「*」なら両方に合う事
127+ $test->assertEquals('2026-02-13 00:00', $next('0 0 13 * * x', '2026-02-01 00:00'));
128+ });
129+
130+ $test->it('閏年と存在しない日を扱えるはず', function($test) use ($next): void {
131+ $test->assertEquals('2028-02-29 00:00', $next('0 0 29 2 * x', '2026-03-01 00:00'));
132+ $test->assertNull($next('0 0 30 2 * x', '2026-03-01 00:00'));
133+ });
134+});
135+
136+$test->describe('Cronのジョブ管理', function($test) use ($tmp): void {
137+ if (!CRON_ENABLED) {
138+ $test->skipAll('CRON_ENABLEDが無効');
139+ }
140+
141+ $cron = CRON_ENABLED ? new Cron($tmp()) : null;
142+
143+ $test->it('ジョブを追加出来るはず', function($test) use ($cron): void {
144+ $res = $cron->add(CronTab::fromString('0 2 15 6 * apfederate'));
145+ $test->assertTrue($res->isSuccess);
146+ $test->assertEquals([
147+ 'id' => 1,
148+ 'isActive' => true,
149+ 'minute' => '0',
150+ 'hour' => '2',
151+ 'dayOfMonth' => '15',
152+ 'month' => '6',
153+ 'dayOfWeek' => '*',
154+ 'command' => 'apfederate',
155+ 'lastRun' => null,
156+ 'lastError' => null,
157+ ], $res->data);
158+ $test->assertEquals(2, $cron->add(CronTab::fromString('@daily other'))->data['id']);
159+ });
160+
161+ $test->it('不正なジョブは追加しないはず', function($test) use ($cron): void {
162+ $res = $cron->add(CronTab::fromString('99 * * * * x'));
163+ $test->assertFalse($res->isSuccess);
164+ $test->assertCount(2, $cron->list()->data);
165+ });
166+
167+ $test->it('ジョブを表示・変更出来るはず', function($test) use ($cron): void {
168+ $test->assertEquals('apfederate', $cron->show(1)->data['command']);
169+ $test->assertFalse($cron->show(99)->isSuccess);
170+
171+ $res = $cron->edit(1, CronTab::fromString('0 7 15 6 * apfederate'));
172+ $test->assertTrue($res->isSuccess);
173+ $test->assertEquals('7', $res->data['hour']);
174+ $test->assertEquals('7', $cron->show(1)->data['hour']);
175+ });
176+
177+ $test->it('ジョブを有効・無効に出来るはず', function($test) use ($cron): void {
178+ $test->assertFalse($cron->toggle(1)->data['isActive']);
179+ $test->assertTrue($cron->enable(1)->data['isActive']);
180+ $test->assertFalse($cron->disable(1)->data['isActive']);
181+ });
182+
183+ $test->it('ジョブを削除出来るはず', function($test) use ($cron): void {
184+ $res = $cron->delete(1);
185+ $test->assertTrue($res->isSuccess);
186+ $test->assertEquals(1, $res->data['id']);
187+ $test->assertEquals([2], array_column($cron->list()->data, 'id'));
188+ $test->assertFalse($cron->delete(1)->isSuccess);
189+ $test->assertEquals(3, $cron->add(CronTab::fromString('@hourly x'))->data['id']);
190+ });
191+});
192+
193+$test->describe('Cronの実行', function($test) use ($tmp, $at): void {
194+ if (!CRON_ENABLED) {
195+ $test->skipAll('CRON_ENABLEDが無効');
196+ }
197+
198+ $dir = CRON_ENABLED ? $tmp() : '';
199+ $count = ['every5' => 0, 'hourly' => 0];
200+ $cron = CRON_ENABLED ? new Cron($dir) : null;
201+ if ($cron) {
202+ $cron->register('every5', function() use (&$count) { $count['every5']++; });
203+ $cron->register('hourly', function() use (&$count) { $count['hourly']++; return \Result::Success('毎時'); });
204+ $cron->register('boom', function() { throw new \RuntimeException('壊れた'); });
205+ $cron->register('nope', fn() => false);
206+ $cron->add(CronTab::fromString('*/5 * * * * every5'));
207+ $cron->add(CronTab::fromString('0 * * * * hourly'));
208+ }
209+
210+ $test->it('予定の来たジョブだけを実行するはず', function($test) use ($cron, $at, &$count): void {
211+ $test->assertEquals([], $cron->run($at('2026-01-05 09:58'))->data['ran']);
212+ $test->assertEquals([], $cron->run($at('2026-01-05 09:59'))->data['ran']);
213+
214+ $res = $cron->run($at('2026-01-05 10:00'));
215+ $test->assertEquals([1, 2], array_keys($res->data['ran']));
216+ $test->assertEquals('毎時', $res->data['ran'][2]);
217+ $test->assertEquals(['every5' => 1, 'hourly' => 1], $count);
218+
219+ $test->assertEquals([], $cron->run($at('2026-01-05 10:01'))->data['ran']);
220+ $test->assertEquals([1], array_keys($cron->run($at('2026-01-05 10:05'))->data['ran']));
221+ $test->assertEquals($at('2026-01-05 10:05')->getTimestamp(), $cron->show(1)->data['lastRun']);
222+ });
223+
224+ $test->it('同じ分に2回実行しないはず', function($test) use ($cron, $at, &$count): void {
225+ $res = $cron->run($at('2026-01-05 10:05'));
226+ $test->assertEquals('この分は実行済みです。', $res->message);
227+ $test->assertEquals(2, $count['every5']);
228+ });
229+
230+ $test->it('止まっていた間の分は1回だけ実行するはず', function($test) use ($cron, $at, &$count): void {
231+ // 10:10〜10:35の間に6回予定があったが、1回だけ
232+ $res = $cron->run($at('2026-01-05 10:35'));
233+ $test->assertEquals([1], array_keys($res->data['ran']));
234+ $test->assertEquals(3, $count['every5']);
235+ });
236+
237+ $test->it('長く止まっていても各ジョブは1回だけ実行するはず', function($test) use ($cron, $at, &$count): void {
238+ // 10:35から12:30まで(11:00と12:00の予定があった)。遡るのはCATCH_UP(1時間)まで
239+ $res = $cron->run($at('2026-01-05 12:30'));
240+ $test->assertEquals([1, 2], array_keys($res->data['ran']));
241+ $test->assertEquals(['every5' => 4, 'hourly' => 2], $count);
242+ });
243+
244+ $test->it('無効なジョブは実行しないはず', function($test) use ($cron, $at, &$count): void {
245+ $cron->disable(1);
246+ $test->assertEquals([], $cron->run($at('2026-01-05 12:35'))->data['ran']);
247+ $cron->enable(1);
248+ });
249+
250+ $test->it('失敗を記録するはず', function($test) use ($cron, $at): void {
251+ $a = $cron->add(CronTab::fromString('* * * * * boom'))->data['id'];
252+ $b = $cron->add(CronTab::fromString('* * * * * nope'))->data['id'];
253+ $c = $cron->add(CronTab::fromString('* * * * * missing'))->data['id'];
254+
255+ $res = $cron->run($at('2026-01-05 12:36'));
256+ $test->assertTrue($res->isSuccess);
257+ $test->assertEquals([$a, $b, $c], array_keys($res->data['failed']));
258+ $test->assertStringContains('壊れた', $cron->show($a)->data['lastError']);
259+ $test->assertEquals('失敗', $cron->show($b)->data['lastError']);
260+ $test->assertStringContains('登録されていません', $cron->show($c)->data['lastError']);
261+
262+ foreach ([$a, $b, $c] as $id) $cron->delete($id);
263+ });
264+
265+ $test->it('前の実行が終わっていなければ何もしないはず', function($test) use ($cron, $at, $dir): void {
266+ $fp = fopen("{$dir}/run.lock", 'c');
267+ flock($fp, LOCK_EX);
268+ $res = $cron->run($at('2026-01-05 12:40'));
269+ flock($fp, LOCK_UN);
270+ fclose($fp);
271+
272+ $test->assertTrue($res->isSuccess);
273+ $test->assertTrue($res->data['busy'] ?? false);
274+ $test->assertEquals([1], array_keys($cron->run($at('2026-01-05 12:40'))->data['ran']));
275+ });
276+
277+ $test->it('予定に関係なく今すぐ実行出来るはず', function($test) use ($cron, &$count): void {
278+ $res = $cron->runNow(2);
279+ $test->assertTrue($res->isSuccess);
280+ $test->assertEquals(3, $count['hourly']);
281+ $test->assertFalse($cron->runNow(99)->isSuccess);
282+ });
283+});
284+
285+$test->describe('ActivityPubの連合(apfederate)', function($test) use ($tmp): void {
286+ if (!ACTIVITYPUB_ENABLED) {
287+ $test->skipAll('ACTIVITYPUB_ENABLEDが無効');
288+ } else if (PHP_SAPI === 'cli' && empty(FEDIINFO['domain'])) {
289+ $test->skipAll('FEDIINFOの「domain」が未設定');
290+ }
291+
292+ // 受付ボックスの取得と送信だけを偽物にする
293+ $make = fn(array $posts, array $inboxes) => new class($posts, $inboxes) extends Activitypub {
294+ public array $sent = [];
295+ public array $fail = [];
296+ public function __construct(array $posts, private array $inboxes) {
297+ parent::__construct($posts);
298+ }
299+ protected function followerInboxes(): array {
300+ return $this->inboxes;
301+ }
302+ protected function sendActivity(string $inboxUrl, array $activity): bool {
303+ if (in_array($inboxUrl, $this->fail, true)) return false;
304+ $this->sent[] = $inboxUrl.' '.basename($activity['object']['id']);
305+ return true;
306+ }
307+ public function sign(string $url, string $body, \OpenSSLAsymmetricKey $key): ?array {
308+ return $this->signRequest($url, $body, $key);
309+ }
310+ };
311+ $post = fn(string $uuid, string $date, array $extra = []) => $extra + [
312+ 'title' => "投稿{$uuid}", 'date' => $date, 'thumbnail' => '', 'category' => [],
313+ 'uuid' => $uuid, 'preview' => 'a & b', 'slug' => "post-{$uuid}",
314+ ];
315+ $inboxes = ['https://a.example/inbox', 'https://b.example/inbox'];
316+ $state = ACTIVITYPUB_ENABLED ? $tmp().'/ap_federated.json' : '';
317+ $read = fn() => json_decode(file_get_contents($state), true);
318+ $posts = [$post('old1', '2025-01-01 10:00'), $post('old2', '2025-02-01 10:00')];
319+
320+ $test->it('初回は既存の投稿を記録するだけのはず', function($test) use ($make, $posts, $inboxes, $state, $read): void {
321+ $ap = $make($posts, $inboxes);
322+ $res = $ap->federate($state);
323+ $test->assertTrue($res->isSuccess);
324+ $test->assertStringContains('初回', $res->message);
325+ $test->assertEquals([], $ap->sent);
326+ $test->assertEquals(['old1', 'old2'], array_keys($read()['sent']));
327+ });
328+
329+ $test->it('新しい公開投稿だけを古い順に送信するはず', function($test) use ($make, $post, &$posts, $inboxes, $state, $read): void {
330+ $posts[] = $post('new2', '2026-01-02 10:00');
331+ $posts[] = $post('new1', '2026-01-01 10:00');
332+ $posts[] = $post('member', '2026-01-03 10:00', ['category' => ['tech', 'memberonly']]);
333+ $posts[] = $post('locked', '2026-01-03 10:00', ['preview' => '未許可']);
334+ $posts[] = $post('', '2026-01-03 10:00');
335+ $posts[] = $post('future', date('Y-m-d H:i:s', time() + 86400));
336+
337+ $ap = $make($posts, $inboxes);
338+ $res = $ap->federate($state);
339+ $test->assertTrue($res->isSuccess);
340+ $test->assertEquals(['posts' => 2, 'delivered' => 4, 'failed' => 0], $res->data);
341+ $test->assertEquals([
342+ 'https://a.example/inbox new1', 'https://b.example/inbox new1',
343+ 'https://a.example/inbox new2', 'https://b.example/inbox new2',
344+ ], $ap->sent);
345+
346+ $s = $read();
347+ $test->assertEquals(['old1', 'old2', 'new1', 'new2'], array_keys($s['sent']));
348+ $test->assertEquals([], $s['pending']);
349+ });
350+
351+ $test->it('送信済みの投稿は2回送らないはず', function($test) use ($make, &$posts, $inboxes, $state): void {
352+ $ap = $make($posts, $inboxes);
353+ $res = $ap->federate($state);
354+ $test->assertEquals('新しい投稿はありません。', $res->message);
355+ $test->assertEquals([], $ap->sent);
356+ });
357+
358+ $test->it('失敗した受付ボックスだけを再試行するはず', function($test) use ($make, $post, &$posts, $inboxes, $state, $read): void {
359+ $posts[] = $post('retry', '2026-01-04 10:00');
360+
361+ $ap = $make($posts, $inboxes);
362+ $ap->fail = ['https://b.example/inbox'];
363+ $res = $ap->federate($state);
364+ $test->assertTrue($res->isSuccess);
365+ $test->assertEquals(['posts' => 0, 'delivered' => 1, 'failed' => 1], $res->data);
366+ $test->assertEquals(['inboxes' => ['https://b.example/inbox'], 'tries' => 1], $read()['pending']['retry']);
367+
368+ $ap = $make($posts, $inboxes);
369+ $res = $ap->federate($state);
370+ $test->assertEquals(['https://b.example/inbox retry'], $ap->sent);
371+ $test->assertEquals(['posts' => 1, 'delivered' => 1, 'failed' => 0], $res->data);
372+ $test->assertArrayHasKey('retry', $read()['sent']);
373+ });
374+
375+ $test->it('FEDERATE_MAX_TRIES回失敗したら諦めるはず', function($test) use ($make, $post, &$posts, $inboxes, $state, $read): void {
376+ $posts[] = $post('down', '2026-01-05 10:00');
377+
378+ for ($i = 1; $i <= Activitypub::FEDERATE_MAX_TRIES; $i++) {
379+ $ap = $make($posts, $inboxes);
380+ $ap->fail = $inboxes;
381+ $res = $ap->federate($state);
382+ $test->assertFalse($res->isSuccess, "{$i}回目は全部失敗なのでエラーのはず");
383+ }
384+ $test->assertEquals([], $read()['pending']);
385+ $test->assertArrayHasKey('down', $read()['sent']);
386+ });
387+
388+ $test->it('送信待ちの間に非公開になった投稿は送らないはず', function($test) use ($make, $post, &$posts, $inboxes, $state, $read): void {
389+ $posts[] = $post('hidden', '2026-01-06 10:00');
390+ $ap = $make($posts, $inboxes);
391+ $ap->fail = $inboxes;
392+ $ap->federate($state);
393+ $test->assertArrayHasKey('hidden', $read()['pending']);
394+
395+ array_pop($posts);
396+ $posts[] = $post('hidden', '2026-01-06 10:00', ['category' => ['staffonly']]);
397+ $ap = $make($posts, $inboxes);
398+ $ap->federate($state);
399+ $test->assertEquals([], $ap->sent);
400+ $test->assertEquals([], $read()['pending']);
401+ });
402+
403+ $test->it('Createアクティビティを正しく作るはず', function($test) use ($make, $post): void {
404+ $p = $post('x1', '2026-01-01 09:00', ['category' => ['php']]);
405+ $ap = $make([$p], []);
406+ $a = json_decode($ap->getActivity('x1'), true);
407+
408+ $test->assertEquals('Create', $a['type']);
409+ $test->assertEquals(gmdate('Y-m-d\TH:i:s\Z', strtotime('2026-01-01 09:00')), $a['published']);
410+ $test->assertStringContains('a &amp; b', $a['object']['content']);
411+ $test->assertEquals([['type' => 'Hashtag', 'name' => '#php']], $a['object']['tag']);
412+ $test->assertStringContains('/blog/post-x1', $a['object']['url']);
413+ });
414+
415+ $test->it('(request-target)を含めて署名するはず', function($test) use ($make): void {
416+ $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
417+ if ($key === false) {
418+ $test->assertTrue(true);
419+ return; // OpenSSLの設定が無い環境
420+ }
421+
422+ $ap = $make([], []);
423+ $body = '{"type":"Create"}';
424+ $list = $ap->sign('https://a.example:443/users/x/inbox?y=1', $body, $key);
425+ $test->assertNotNull($list);
426+
427+ $h = [];
428+ foreach ($list as $line) {
429+ [$k, $v] = explode(': ', $line, 2);
430+ $h[$k] = $v;
431+ }
432+ $test->assertEquals('a.example', $h['Host']);
433+ $test->assertEquals('SHA-256='.base64_encode(hash('sha256', $body, true)), $h['Digest']);
434+ $test->assertStringContains('headers="(request-target) host date digest"', $h['Signature']);
435+
436+ preg_match('/signature="([^"]+)"/', $h['Signature'], $m);
437+ $signed = "(request-target): post /users/x/inbox?y=1\nhost: {$h['Host']}\ndate: {$h['Date']}\ndigest: {$h['Digest']}";
438+ $pub = openssl_pkey_get_details($key)['key'];
439+ $test->assertEquals(1, openssl_verify($signed, base64_decode($m[1]), $pub, OPENSSL_ALGO_SHA256));
440+ });
441+});
442+
443+// 一時ディレクトリを削除する
444+foreach ($tmpDirs as $dir) {
445+ foreach (glob("{$dir}/*") ?: [] as $f) @unlink($f);
446+ @rmdir($dir);
447+}
448+
449+$test->printSummary();
変更 util.php
@@ -36,13 +36,14 @@
3636 OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
3737 */
3838 if (!CURL_ENABLED) define('ACTIVITYPUB_ENABLED', false);
39-foreach (['MAILER_ENABLED', 'OPENPROVIDER_ENABLED'] as $c) if (!defined($c)) define($c, false);
39+foreach (['MAILER_ENABLED', 'OPENPROVIDER_ENABLED', 'CRON_ENABLED'] as $c) if (!defined($c)) define($c, false);
4040
4141 enum LogType {
4242 case ActivityPub;
4343 case Auth;
4444 case Mailer;
4545 case Image;
46+ case Cron;
4647 }
4748
4849 class Result {
@@ -267,6 +268,10 @@
267268 break;
268269 case LogType::Image:
269270 $logfile .= 'img_log.txt';
271+ $success = true;
272+ break;
273+ case LogType::Cron:
274+ $logfile .= 'cron_log.txt';
270275 $success = true;
271276 break;
272277 default: