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 @@ | ||
| 1 | 1 | data/*.pem |
| 2 | 2 | data/*.txt |
| 3 | 3 | data/*.json |
| 4 | +data/cron | |
| 4 | 5 | data/cache |
| 5 | 6 | data/user/*.json |
| 6 | 7 | log/*.txt |
変更
README-JP.md
| @@ -188,5 +188,22 @@ | ||
| 188 | 188 | } |
| 189 | 189 | ``` |
| 190 | 190 | |
| 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 | + | |
| 191 | 208 | ## 必要な PHP モジュール |
| 192 | -* php_gmp | |
| 209 | +* php_gmp |
変更
README.md
| @@ -188,5 +188,22 @@ | ||
| 188 | 188 | } |
| 189 | 189 | ``` |
| 190 | 190 | |
| 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 | + | |
| 191 | 208 | ## Required PHP module |
| 192 | 209 | * php_gmp |
| @@ -50,6 +50,7 @@ | ||
| 50 | 50 | 'pass' => 'hogehogehoge', |
| 51 | 51 | ]); |
| 52 | 52 | define('FEDIINFO', [ |
| 53 | + 'domain' => '', | |
| 53 | 54 | 'actor' => 'loli', |
| 54 | 55 | 'actorNick' => 'ロリ', |
| 55 | 56 | 'desc' => '', |
| @@ -68,6 +69,7 @@ | ||
| 68 | 69 | define('LOGGING_ENABLED', true); |
| 69 | 70 | define('ATOM_ENABLED', true); |
| 70 | 71 | define('ACTIVITYPUB_ENABLED', false); |
| 72 | +define('CRON_ENABLED', false); | |
| 71 | 73 | define('CURL_ENABLED', true); |
| 72 | 74 | define('AUTH_ENABLED', false); |
| 73 | 75 | 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 @@ | ||
| 16 | 16 | |
| 17 | 17 | merror { |
| 18 | 18 | color: #f358ad; |
| 19 | - border: 1px dashed var(--color-f35869, #f358ad); | |
| 19 | + border: #f358ad; | |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | 22 | .fraction { |
| @@ -39,6 +39,7 @@ | ||
| 39 | 39 | |
| 40 | 40 | use Std\Lib\Curl; |
| 41 | 41 | use LogType; |
| 42 | +use Result; | |
| 42 | 43 | use Uri\Rfc3986\Uri; |
| 43 | 44 | |
| 44 | 45 | /** |
| @@ -55,7 +56,15 @@ | ||
| 55 | 56 | private string $icon; |
| 56 | 57 | private array $posts = []; |
| 57 | 58 | private string $https; |
| 59 | + /** 秘密鍵のキャッシュ(1回の実行で何度も読まない様に) */ | |
| 60 | + private ?\OpenSSLAsymmetricKey $privKey = null; | |
| 58 | 61 | |
| 62 | + /** 連合しない(会員限定等の)カテゴリー */ | |
| 63 | + private const RESTRICTED = ['memberonly', 'subscriberonly', 'staffonly', 'nintendodevonly']; | |
| 64 | + | |
| 65 | + /** 配信に失敗した受付ボックスを再試行する回数(federate()の実行回数) */ | |
| 66 | + public const FEDERATE_MAX_TRIES = 5; | |
| 67 | + | |
| 59 | 68 | /** |
| 60 | 69 | * コンストラクタ |
| 61 | 70 | * |
| @@ -64,7 +73,9 @@ | ||
| 64 | 73 | public function __construct(array $posts = []) { |
| 65 | 74 | if (!ACTIVITYPUB_ENABLED) return; |
| 66 | 75 | $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'); | |
| 68 | 79 | $this->actor = FEDIINFO['actor']; |
| 69 | 80 | $this->actorNick = FEDIINFO['actorNick']; |
| 70 | 81 | $this->desc = FEDIINFO['desc']; |
| @@ -135,67 +146,7 @@ | ||
| 135 | 146 | |
| 136 | 147 | foreach ($this->posts as $post) { |
| 137 | 148 | 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); | |
| 199 | 150 | break; |
| 200 | 151 | } |
| 201 | 152 | |
| @@ -210,74 +161,9 @@ | ||
| 210 | 161 | public function getOutbox(): string { |
| 211 | 162 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 212 | 163 | $items = []; |
| 213 | - $counter = 0; | |
| 214 | 164 | |
| 215 | - foreach ($this->posts as $post) { | |
| 216 | - $uid = $post['uuid']; | |
| 165 | + foreach ($this->posts as $post) $items[] = $this->buildCreate($post); | |
| 217 | 166 | |
| 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 | - | |
| 281 | 167 | $outbox = [ |
| 282 | 168 | '@context' => [ |
| 283 | 169 | 'https://www.w3.org/ns/activitystreams', |
| @@ -410,20 +296,123 @@ | ||
| 410 | 296 | /** |
| 411 | 297 | * アクター更新をフォロワーに送信する |
| 412 | 298 | * |
| 413 | - * @param array $params パラメータ配列 | |
| 414 | - * @return void | |
| 299 | + * @return Result 送信した件数 | |
| 415 | 300 | */ |
| 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; | |
| 422 | 305 | |
| 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); | |
| 424 | 314 | } |
| 425 | 315 | |
| 426 | 316 | /** |
| 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 | + /** | |
| 427 | 416 | * 受付ボックスへのリクエストのHTTP署名を検証する |
| 428 | 417 | * |
| 429 | 418 | * @param string $body リクエストの本文(生データ) |
| @@ -581,75 +570,234 @@ | ||
| 581 | 570 | * |
| 582 | 571 | * @param string $inboxUrl 送信先のインボックスURL |
| 583 | 572 | * @param array $activity 送信するアクティビティデータ |
| 584 | - * @return void | |
| 573 | + * @return bool 2xxが返ったらtrue | |
| 585 | 574 | */ |
| 586 | - private function sendActivity(string $inboxUrl, array $activity): void { | |
| 575 | + protected function sendActivity(string $inboxUrl, array $activity): bool { | |
| 587 | 576 | if (!$this->isSafeRemoteUrl($inboxUrl)) { |
| 588 | - logger(\LogType::ActivityPub, "安全ではない受付ボックスへの送信を拒否: {$inboxUrl}"); | |
| 589 | - return; | |
| 577 | + logger(LogType::ActivityPub, "安全ではない受付ボックスへの送信を拒否: {$inboxUrl}"); | |
| 578 | + return false; | |
| 590 | 579 | } |
| 591 | 580 | |
| 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; | |
| 601 | 583 | |
| 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; | |
| 606 | 587 | |
| 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 | + | |
| 607 | 623 | $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)), | |
| 610 | 627 | 'Content-Type' => 'application/activity+json', |
| 611 | - 'Digest' => "SHA-256=$digest", | |
| 612 | 628 | ]; |
| 613 | 629 | |
| 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']}"; | |
| 616 | 634 | |
| 617 | 635 | 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; | |
| 624 | 638 | } |
| 625 | 639 | |
| 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).'"'; | |
| 632 | 644 | |
| 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 | + } | |
| 640 | 647 | |
| 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; | |
| 645 | 655 | |
| 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; | |
| 650 | 665 | } |
| 651 | 666 | |
| 652 | 667 | /** |
| 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 | + /** | |
| 653 | 801 | * フォロワーを受け入れる |
| 654 | 802 | * |
| 655 | 803 | * @param array $activity フォローアクティビティデータ |
| @@ -694,7 +842,9 @@ | ||
| 694 | 842 | 'object' => $activity, |
| 695 | 843 | ]; |
| 696 | 844 | |
| 697 | - $this->sendActivity($inbox, $accept); | |
| 845 | + if (!$this->sendActivity($inbox, $accept)) { | |
| 846 | + logger(LogType::ActivityPub, "「{$followerActor}」へのAcceptの送信に失敗"); | |
| 847 | + } | |
| 698 | 848 | } |
| 699 | 849 | |
| 700 | 850 | /** |
| @@ -721,8 +871,9 @@ | ||
| 721 | 871 | */ |
| 722 | 872 | private function getFollowersList(): array { |
| 723 | 873 | $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 !== '')); | |
| 726 | 877 | } |
| 727 | 878 | |
| 728 | 879 | /** |
| @@ -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 & 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 @@ | ||
| 36 | 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | 37 | */ |
| 38 | 38 | 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); | |
| 40 | 40 | |
| 41 | 41 | enum LogType { |
| 42 | 42 | case ActivityPub; |
| 43 | 43 | case Auth; |
| 44 | 44 | case Mailer; |
| 45 | 45 | case Image; |
| 46 | + case Cron; | |
| 46 | 47 | } |
| 47 | 48 | |
| 48 | 49 | class Result { |
| @@ -267,6 +268,10 @@ | ||
| 267 | 268 | break; |
| 268 | 269 | case LogType::Image: |
| 269 | 270 | $logfile .= 'img_log.txt'; |
| 271 | + $success = true; | |
| 272 | + break; | |
| 273 | + case LogType::Cron: | |
| 274 | + $logfile .= 'cron_log.txt'; | |
| 270 | 275 | $success = true; |
| 271 | 276 | break; |
| 272 | 277 | default: |