| 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\Crypto\Rsa; |
| 45 | use Site\Controller\BlogPost; |
| 46 | |
| 47 | $test = new Tester([ |
| 48 | 'colorOutput' => true, |
| 49 | 'verboseOutput' => true |
| 50 | ]); |
| 51 | |
| 52 | $followers = tempnam(sys_get_temp_dir(), 'lbfollow_'); |
| 53 | $key = Rsa::generate(2048); |
| 54 | |
| 55 | // 外部との通信だけを偽物にする |
| 56 | $make = fn(array $posts = []) => new class($posts, $followers, $key) extends Activitypub { |
| 57 | public array $sent = []; |
| 58 | public array $remote = []; |
| 59 | public function __construct(array $posts, private string $file, private Rsa $key) { |
| 60 | parent::__construct($posts); |
| 61 | } |
| 62 | protected function followersFile(): string { return $this->file; } |
| 63 | protected function getInboxFromActor(string $actor): ?string { return $this->remote[$actor]['inbox'] ?? null; } |
| 64 | public function fetchRemoteJson(string $url): ?array { return $this->remote[$url] ?? null; } |
| 65 | protected function sendActivity(string $inboxUrl, array $activity): bool { |
| 66 | $this->sent[] = [$inboxUrl, $activity]; |
| 67 | return true; |
| 68 | } |
| 69 | public function sign(string $url, string $body, string $method = 'post'): ?array { |
| 70 | return $this->signRequest($url, $body, $this->key, $method); |
| 71 | } |
| 72 | }; |
| 73 | |
| 74 | $test->describe('ActivityPub', function($test) use ($make, $followers, $key): void { |
| 75 | if (!ACTIVITYPUB_ENABLED) { |
| 76 | $test->skipAll('ACTIVITYPUB_ENABLEDが無効'); |
| 77 | } |
| 78 | |
| 79 | $posts = ACTIVITYPUB_ENABLED ? (new BlogPost())->getPosts('/blog/', null) : []; |
| 80 | $finger = ACTIVITYPUB_ENABLED ? json_decode($make()->getWebfinger(), true) : []; |
| 81 | $domain = substr($finger['links'][0]['href'] ?? '', 0, -strlen('/ap/actor')); |
| 82 | $feature = 'a8c04518-4181-4ec6-9ef0-3f88f23b84b6'; // /blog/feature-test |
| 83 | $member = 'd49f60c7-6d85-4263-9d3f-c20025f2fa0d'; // /blog/memberonly-post |
| 84 | // サンプルの投稿はLittleBeastのblog/にだけある(サイトでは公開されてしまうので置かない) |
| 85 | $sample = is_file(ROOT.'/blog/feature-test.md'); |
| 86 | $noSample = 'サンプルの投稿(blog/feature-test.md)が無い'; |
| 87 | $remoteActor = 'https://1.1.1.1/users/alice'; |
| 88 | |
| 89 | $test->it('WebFingerを受け取れるはず', function($test) use ($finger, $domain): void { |
| 90 | $host = parse_url($domain, PHP_URL_HOST); |
| 91 | $test->assertEquals("acct:".FEDIINFO['actor']."@{$host}", $finger['subject']); |
| 92 | $test->assertEquals('application/activity+json', $finger['links'][0]['type']); |
| 93 | }); |
| 94 | |
| 95 | $test->it('このインスタンスのユーザーを受け取れるはず', function($test) use ($make, $domain): void { |
| 96 | try { |
| 97 | $json = json_decode($make()->getActor(), true); |
| 98 | } catch (\Exception $e) { |
| 99 | $test->assertStringContains('公開鍵', $e->getMessage()); |
| 100 | return; // 公開鍵が読めない環境 |
| 101 | } |
| 102 | $test->assertEquals('Person', $json['type']); |
| 103 | $test->assertEquals("{$domain}/ap/actor", $json['id']); |
| 104 | $test->assertEquals("{$domain}/ap/inbox", $json['inbox']); |
| 105 | $test->assertEquals("{$domain}/ap/outbox", $json['outbox']); |
| 106 | $test->assertEquals("{$domain}/ap/followers", $json['followers']); |
| 107 | $test->assertEquals("{$domain}/ap/actor#main-key", $json['publicKey']['id']); |
| 108 | }); |
| 109 | |
| 110 | if ($sample) $test->it('公開投稿のアクティビティを受け取れるはず', function($test) use ($make, $posts, $feature, $domain): void { |
| 111 | $json = json_decode($make($posts)->getActivity($feature), true); |
| 112 | $test->assertEquals('Create', $json['type']); |
| 113 | $test->assertEquals("{$domain}/ap/activities/create/{$feature}", $json['id']); |
| 114 | $test->assertEquals('【マークダウン】関数', $json['object']['name']); |
| 115 | $test->assertEquals('https://www.w3.org/ns/activitystreams#Public', $json['object']['to'][0]); |
| 116 | // 投稿の日付(PHPのタイムゾーン)をUTCにする |
| 117 | $test->assertEquals(gmdate('Y-m-d\TH:i:s\Z', strtotime('2025-11-07 20:33:42')), $json['published']); |
| 118 | // タグと添付はNoteに付ける |
| 119 | $test->assertEquals(['type' => 'Hashtag', 'name' => '#test'], $json['object']['tag'][0]); |
| 120 | $test->assertFalse(isset($json['tag'])); |
| 121 | $test->assertStringContains('/blog/feature-test', $json['object']['url']); |
| 122 | |
| 123 | if (is_file(ROOT.ass_path('article/o_53803618dc1691.28179609-orig.jpg'))) { |
| 124 | $test->assertEquals('zBbw7K7L1dEwr6htZX5JevRv3oLtHwXQU4o5jT8jn9RKF', $json['object']['attachment'][0]['digestMultibase']); |
| 125 | $test->assertEquals(ass_url('article/o_53803618dc1691.28179609-orig.jpg', $domain), $json['object']['attachment'][0]['url']); |
| 126 | $test->assertEquals('image/jpeg', $json['object']['attachment'][0]['mediaType']); |
| 127 | } |
| 128 | }); |
| 129 | else $test->skip('公開投稿のアクティビティを受け取れるはず', $noSample); |
| 130 | |
| 131 | $test->it('会員限定・存在しない投稿は返さないはず', function($test) use ($make, $posts, $member): void { |
| 132 | $ap = $make($posts); |
| 133 | $test->assertEquals('', $ap->getActivity($member)); |
| 134 | $test->assertEquals('', $ap->getActivity('00000000-0000-0000-0000-000000000000')); |
| 135 | $test->assertEquals('', $ap->getActivity('')); |
| 136 | $test->assertEquals('', $ap->getObject($member)); |
| 137 | }); |
| 138 | |
| 139 | if ($sample) $test->it('オブジェクト(Note)を受け取れるはず', function($test) use ($make, $posts, $feature, $domain): void { |
| 140 | $json = json_decode($make($posts)->getObject($feature), true); |
| 141 | $test->assertEquals('Note', $json['type']); |
| 142 | $test->assertEquals("{$domain}/ap/objects/{$feature}", $json['id']); |
| 143 | $test->assertTrue(isset($json['@context'])); |
| 144 | }); |
| 145 | else $test->skip('オブジェクト(Note)を受け取れるはず', $noSample); |
| 146 | |
| 147 | $test->it('アウトボックスは公開投稿だけを新しい順に返すはず', function($test) use ($make, $posts, $member): void { |
| 148 | $json = json_decode($make($posts)->getOutbox(), true); |
| 149 | $test->assertEquals('OrderedCollection', $json['type']); |
| 150 | $test->assertEquals(count($json['orderedItems']), $json['totalItems']); |
| 151 | $ids = array_map(fn($i) => basename($i['object']['id']), $json['orderedItems']); |
| 152 | $test->assertFalse(in_array($member, $ids, true)); |
| 153 | $test->assertFalse(in_array('1c0ac334-a017-4813-9137-939b483c08eb', $ids, true)); // staffonly |
| 154 | $dates = array_map(fn($i) => $i['published'], $json['orderedItems']); |
| 155 | $sorted = $dates; |
| 156 | rsort($sorted); |
| 157 | $test->assertEquals($sorted, $dates); |
| 158 | }); |
| 159 | |
| 160 | $test->it('フォローを受け入れてAcceptを送るはず', function($test) use ($make, $followers, $domain, $remoteActor): void { |
| 161 | file_put_contents($followers, ''); |
| 162 | $ap = $make(); |
| 163 | $ap->remote[$remoteActor] = ['inbox' => "{$remoteActor}/inbox"]; |
| 164 | $follow = ['type' => 'Follow', 'id' => "{$remoteActor}#follow", 'actor' => $remoteActor, 'object' => "{$domain}/ap/actor"]; |
| 165 | |
| 166 | [$code] = $ap->handleInbox($follow); |
| 167 | $test->assertEquals(200, $code); |
| 168 | $test->assertEquals("{$remoteActor}\n", file_get_contents($followers)); |
| 169 | $test->assertEquals("{$remoteActor}/inbox", $ap->sent[0][0]); |
| 170 | $test->assertEquals('Accept', $ap->sent[0][1]['type']); |
| 171 | $test->assertEquals($follow, $ap->sent[0][1]['object']); |
| 172 | |
| 173 | // 2回フォローしても1行だけ |
| 174 | $ap->handleInbox($follow); |
| 175 | $test->assertEquals("{$remoteActor}\n", file_get_contents($followers)); |
| 176 | |
| 177 | $list = json_decode($ap->getFollowers(), true); |
| 178 | $test->assertEquals('OrderedCollection', $list['type']); |
| 179 | $test->assertEquals([$remoteActor], $list['orderedItems']); |
| 180 | }); |
| 181 | |
| 182 | $test->it('不正なフォローを拒否するはず', function($test) use ($make, $domain, $remoteActor): void { |
| 183 | $ap = $make(); |
| 184 | $test->assertEquals(400, $ap->handleInbox(['type' => 'Follow', 'actor' => $remoteActor, 'object' => 'https://1.1.1.1/someone'])[0]); |
| 185 | $test->assertEquals(400, $ap->handleInbox(['type' => 'Follow', 'actor' => 'http://127.0.0.1/x', 'object' => "{$domain}/ap/actor"])[0]); |
| 186 | // 受付ボックスが分からない |
| 187 | $test->assertEquals(500, $ap->handleInbox(['type' => 'Follow', 'actor' => $remoteActor, 'object' => "{$domain}/ap/actor"])[0]); |
| 188 | $test->assertEquals([], $ap->sent); |
| 189 | }); |
| 190 | |
| 191 | $test->it('フォローの取り消しでフォロワーから外すはず', function($test) use ($make, $followers, $domain, $remoteActor): void { |
| 192 | file_put_contents($followers, "{$remoteActor}\nhttps://1.1.1.1/users/bob\n"); |
| 193 | $ap = $make(); |
| 194 | |
| 195 | // IDだけのUndo(Likeの取り消しかも知れない)と他人のフォローの取り消しは無視する |
| 196 | $test->assertEquals(202, $ap->handleInbox(['type' => 'Undo', 'actor' => $remoteActor, 'object' => "{$remoteActor}#like"])[0]); |
| 197 | $other = ['type' => 'Follow', 'actor' => 'https://1.1.1.1/users/bob', 'object' => "{$domain}/ap/actor"]; |
| 198 | $test->assertEquals(202, $ap->handleInbox(['type' => 'Undo', 'actor' => $remoteActor, 'object' => $other])[0]); |
| 199 | $test->assertEquals("{$remoteActor}\nhttps://1.1.1.1/users/bob\n", file_get_contents($followers)); |
| 200 | |
| 201 | $mine = ['type' => 'Follow', 'actor' => $remoteActor, 'object' => "{$domain}/ap/actor"]; |
| 202 | $test->assertEquals(200, $ap->handleInbox(['type' => 'Undo', 'actor' => $remoteActor, 'object' => $mine])[0]); |
| 203 | $test->assertEquals("https://1.1.1.1/users/bob\n", file_get_contents($followers)); |
| 204 | }); |
| 205 | |
| 206 | $test->it('アカウントの削除でフォロワーから外すはず', function($test) use ($make, $followers): void { |
| 207 | file_put_contents($followers, "https://1.1.1.1/users/bob\n"); |
| 208 | $ap = $make(); |
| 209 | $test->assertEquals(202, $ap->handleInbox(['type' => 'Delete', 'actor' => 'https://1.1.1.1/users/bob', 'object' => 'https://1.1.1.1/users/bob/statuses/1'])[0]); |
| 210 | $test->assertEquals("https://1.1.1.1/users/bob\n", file_get_contents($followers)); |
| 211 | $test->assertEquals(202, $ap->handleInbox(['type' => 'Delete', 'actor' => 'https://1.1.1.1/users/bob', 'object' => 'https://1.1.1.1/users/bob'])[0]); |
| 212 | $test->assertEquals('', file_get_contents($followers)); |
| 213 | }); |
| 214 | |
| 215 | $test->it('未対応のアクティビティは202を返すはず', function($test) use ($make): void { |
| 216 | // 5xxを返すと相手のサーバーが何日も再送する |
| 217 | [$code] = $make()->handleInbox(['type' => 'Like', 'actor' => 'https://1.1.1.1/users/bob', 'object' => 'x']); |
| 218 | $test->assertEquals(202, $code); |
| 219 | }); |
| 220 | |
| 221 | $test->it('POSTとGETの署名を作れるはず', function($test) use ($make, $key): void { |
| 222 | $ap = $make(); |
| 223 | $parse = function(array $list): array { |
| 224 | $h = []; |
| 225 | foreach ($list as $line) { |
| 226 | [$k, $v] = explode(': ', $line, 2); |
| 227 | $h[$k] = $v; |
| 228 | } |
| 229 | return $h; |
| 230 | }; |
| 231 | |
| 232 | $h = $parse($ap->sign('https://a.example/inbox', '{}')); |
| 233 | $test->assertStringContains('headers="(request-target) host date digest"', $h['Signature']); |
| 234 | $test->assertEquals('SHA-256='.base64_encode(hash('sha256', '{}', true)), $h['Digest']); |
| 235 | |
| 236 | $h = $parse($ap->sign('https://a.example/users/x?y=1', '', 'get')); |
| 237 | $test->assertStringContains('headers="(request-target) host date"', $h['Signature']); |
| 238 | $test->assertFalse(isset($h['Digest'])); |
| 239 | preg_match('/signature="([^"]+)"/', $h['Signature'], $m); |
| 240 | $signed = "(request-target): get /users/x?y=1\nhost: a.example\ndate: {$h['Date']}"; |
| 241 | $test->assertTrue(Rsa::fromPem($key->publicPem())->verify($signed, base64_decode($m[1]))); |
| 242 | }); |
| 243 | |
| 244 | $test->it('受付ボックスの署名を検証するはず', function($test) use ($make, $key, $domain, $remoteActor): void { |
| 245 | $ap = $make(); |
| 246 | $keyId = "{$domain}/ap/actor#main-key"; |
| 247 | $actor = "{$domain}/ap/actor"; |
| 248 | // 自分の鍵で署名したリクエストを「相手」として検証する |
| 249 | $ap->remote[$actor] = ['id' => $actor, 'publicKey' => ['id' => $keyId, 'owner' => $actor, |
| 250 | 'publicKeyPem' => $key->publicPem()]]; |
| 251 | |
| 252 | $body = json_encode(['type' => 'Follow', 'actor' => $actor, 'object' => 'x']); |
| 253 | $headers = []; |
| 254 | foreach ($ap->sign('https://'.parse_url($domain, PHP_URL_HOST).'/ap/inbox', $body) as $line) { |
| 255 | [$k, $v] = explode(': ', $line, 2); |
| 256 | $headers[$k] = $v; |
| 257 | } |
| 258 | $server = $_SERVER; |
| 259 | $setup = function(array $override = []) use ($headers, $server): void { |
| 260 | $_SERVER = $server; |
| 261 | $_SERVER['REQUEST_METHOD'] = 'POST'; |
| 262 | $_SERVER['REQUEST_URI'] = '/ap/inbox'; |
| 263 | $_SERVER['HTTP_HOST'] = $headers['Host']; |
| 264 | $_SERVER['HTTP_DATE'] = $headers['Date']; |
| 265 | $_SERVER['HTTP_DIGEST'] = $headers['Digest']; |
| 266 | $_SERVER['HTTP_SIGNATURE'] = $headers['Signature']; |
| 267 | foreach ($override as $k => $v) $_SERVER[$k] = $v; |
| 268 | }; |
| 269 | |
| 270 | try { |
| 271 | $setup(); |
| 272 | $test->assertTrue($ap->verifyInboxSignature($body, json_decode($body, true)), '正しい署名'); |
| 273 | |
| 274 | $setup(); |
| 275 | $test->assertFalse($ap->verifyInboxSignature($body.' ', json_decode($body, true)), '本文の改竄'); |
| 276 | |
| 277 | $setup(['REQUEST_URI' => '/ap/other']); |
| 278 | $test->assertFalse($ap->verifyInboxSignature($body, json_decode($body, true)), 'パスの改竄'); |
| 279 | |
| 280 | $setup(['HTTP_DATE' => gmdate('D, d M Y H:i:s \G\M\T', time() - 3600)]); |
| 281 | $test->assertFalse($ap->verifyInboxSignature($body, json_decode($body, true)), '古い日付'); |
| 282 | |
| 283 | $setup(); |
| 284 | $test->assertFalse($ap->verifyInboxSignature($body, ['type' => 'Follow', 'actor' => $remoteActor]), '署名者とactorが違う'); |
| 285 | |
| 286 | $setup(['HTTP_SIGNATURE' => str_replace('(request-target) ', '', $headers['Signature'])]); |
| 287 | $test->assertFalse($ap->verifyInboxSignature($body, json_decode($body, true)), '(request-target)が無い'); |
| 288 | } finally { |
| 289 | $_SERVER = $server; |
| 290 | } |
| 291 | }); |
| 292 | }); |
| 293 | |
| 294 | @unlink($followers); |
| 295 | |
| 296 | $test->printSummary(); |