| 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 Std\Lib\Crypto\Rsa; |
| 41 | use Std\Lib\Curl; |
| 42 | use Std\Lib\Fedi\Actor; |
| 43 | use Std\Lib\Fedi\Fedi; |
| 44 | use Std\Lib\Fedi\Remote; |
| 45 | use LogType; |
| 46 | use Result; |
| 47 | use Uri\Rfc3986\Uri; |
| 48 | |
| 49 | /** |
| 50 | * ActivityPubプロトコルの実装クラス |
| 51 | * |
| 52 | * このクラスはActivityPubプロトコルを利用して分散型 |
| 53 | * ソーシャルネットワーキングを実装します。 |
| 54 | */ |
| 55 | class ActivityPub { |
| 56 | private string $domain; |
| 57 | private string $actor; |
| 58 | private string $actorNick; |
| 59 | private string $desc; |
| 60 | private string $icon; |
| 61 | /** 投稿(必要になった時に読む場合はクロージャ) */ |
| 62 | private array|\Closure $posts = []; |
| 63 | private string $https; |
| 64 | /** 秘密鍵のキャッシュ(1回の実行で何度も読まない様に) */ |
| 65 | private ?Rsa $privKey = null; |
| 66 | |
| 67 | /** 連合しない(会員限定等の)カテゴリー */ |
| 68 | private const RESTRICTED = ['memberonly', 'subscriberonly', 'staffonly', 'nintendodevonly']; |
| 69 | |
| 70 | /** 配信に失敗した受付ボックスを再試行する回数(federate()の実行回数) */ |
| 71 | public const FEDERATE_MAX_TRIES = 5; |
| 72 | |
| 73 | /** |
| 74 | * コンストラクタ |
| 75 | * |
| 76 | * @param array|\Closure $posts 投稿データの配列(「section」はブログのディレクトリ、無ければblog)、 |
| 77 | * 又は配列を返すクロージャ(受付ボックス等、投稿を使わない事が多い時) |
| 78 | */ |
| 79 | public function __construct(array|\Closure $posts = []) { |
| 80 | if (!ACTIVITYPUB_ENABLED) return; |
| 81 | $this->https = 'http'.((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 's' : '').'://'; |
| 82 | // CLI(cron)にはHTTP_HOSTが無いので、FEDIINFOの「domain」を優先する |
| 83 | $this->domain = \Std\Lib\Fedi\Fedi::configuredDomain(); |
| 84 | if ($this->domain === '') $this->domain = $this->https.($_SERVER['HTTP_HOST'] ?? '127.0.0.1:8000'); |
| 85 | $this->actor = FEDIINFO['actor']; |
| 86 | $this->actorNick = FEDIINFO['actorNick']; |
| 87 | $this->desc = FEDIINFO['desc']; |
| 88 | // アイコンはURL(ASS_DOMAINのサーバー等)でも良い |
| 89 | $this->icon = preg_match('#^https?://#i', FEDIINFO['icon']) ? FEDIINFO['icon'] : "{$this->domain}".FEDIINFO['icon']; |
| 90 | $this->posts = $posts; |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * ActivityPubアクタープロフィールを受け取る |
| 95 | * |
| 96 | * @return string アクターオブジェクト |
| 97 | * @throws \Exception 公開鍵の読み込みに失敗した場合 |
| 98 | */ |
| 99 | public function getActor(): string { |
| 100 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 101 | $pubkey = @file_get_contents(FEDIINFO['pubkey']); |
| 102 | if ($pubkey === false) { |
| 103 | throw new \Exception('公開鍵の受取に失敗。パス:'.FEDIINFO['pubkey']); |
| 104 | } |
| 105 | |
| 106 | $actor = [ |
| 107 | '@context' => [ |
| 108 | 'https://www.w3.org/ns/activitystreams', |
| 109 | 'https://w3id.org/security/v1', |
| 110 | ], |
| 111 | 'id' => "{$this->domain}/ap/actor", |
| 112 | 'name' => $this->actorNick, |
| 113 | 'summary' => $this->desc, |
| 114 | 'manuallyApprovesFollowers' => false, |
| 115 | 'icon' => [ |
| 116 | 'type' => 'Image', |
| 117 | 'mediaType' => 'image/png', |
| 118 | 'url' => $this->icon, |
| 119 | ], |
| 120 | 'image' => [ |
| 121 | 'type' => 'Image', |
| 122 | 'url' => ass_url('article/o_53803618dc1691.28179609.jpg', $this->domain), |
| 123 | 'mediaType' => 'image/jpeg', |
| 124 | ], |
| 125 | 'type' => 'Person', |
| 126 | 'url' => "{$this->domain}", |
| 127 | 'preferredUsername' => $this->actor, |
| 128 | 'inbox' => "{$this->domain}/ap/inbox", |
| 129 | 'outbox' => "{$this->domain}/ap/outbox", |
| 130 | 'followers' => "{$this->domain}/ap/followers", |
| 131 | 'following' => "{$this->domain}/ap/following", |
| 132 | 'published' => '2025-03-28T18:00:00Z', |
| 133 | 'updated' => gmdate('c'), |
| 134 | 'publicKey' => [ |
| 135 | 'id' => "{$this->domain}/ap/actor#main-key", |
| 136 | 'owner' => "{$this->domain}/ap/actor", |
| 137 | 'publicKeyPem' => $pubkey, |
| 138 | ], |
| 139 | ]; |
| 140 | |
| 141 | return json_encode($actor, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| 142 | } |
| 143 | |
| 144 | /** |
| 145 | * 特定のUUIDに対応するActivityを取得する |
| 146 | * |
| 147 | * @param string $uuid 取得するアクティビティのUUID |
| 148 | * @param string $section ブログのディレクトリ |
| 149 | * @return string JSONエンコードされたアクティビティデータ |
| 150 | */ |
| 151 | public function getActivity(string $uuid, string $section = 'blog'): string { |
| 152 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 153 | $post = $this->findPublicPost($uuid, $section); |
| 154 | if ($post === null) return ''; |
| 155 | |
| 156 | return json_encode($this->buildCreate($post), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * 特定のUUIDに対応するNote(Createの中身)を取得する |
| 161 | * 相手のサーバーがオブジェクトのIDを取得しに来た時の為 |
| 162 | * |
| 163 | * @param string $uuid 投稿のUUID |
| 164 | * @param string $section ブログのディレクトリ |
| 165 | * @return string JSONエンコードされたNote、無ければ空文字 |
| 166 | */ |
| 167 | public function getObject(string $uuid, string $section = 'blog'): string { |
| 168 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 169 | $post = $this->findPublicPost($uuid, $section); |
| 170 | if ($post === null) return ''; |
| 171 | |
| 172 | $create = $this->buildCreate($post); |
| 173 | $note = ['@context' => $create['@context']] + $create['object']; |
| 174 | return json_encode($note, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * 投稿への返信のコレクション(Noteの「replies」) |
| 179 | * Fediverseにある返信(受け取った返信と、Fediverseのロールを持つユーザーのコメント)だけ。 |
| 180 | * 他のサーバーやbrowser.pub等がスレッドを表示するのに使う |
| 181 | * |
| 182 | * @param string $uuid 投稿のUUID |
| 183 | * @param string $section ブログのディレクトリ |
| 184 | * @return string JSONエンコードされたOrderedCollection、投稿が無ければ空文字 |
| 185 | */ |
| 186 | public function getReplies(string $uuid, string $section = 'blog'): string { |
| 187 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 188 | $post = $this->findPublicPost($uuid, $section); |
| 189 | if ($post === null) return ''; |
| 190 | |
| 191 | $items = Comments::fediReplies(($post['section'] ?? 'blog').'/'.$post['slug']); |
| 192 | $collection = [ |
| 193 | '@context' => 'https://www.w3.org/ns/activitystreams', |
| 194 | 'id' => "{$this->domain}/ap/objects/".self::postKey($post).'/replies', |
| 195 | 'type' => 'OrderedCollection', |
| 196 | 'totalItems' => count($items), |
| 197 | 'orderedItems' => $items, |
| 198 | ]; |
| 199 | |
| 200 | return json_encode($collection, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * アウトボックスデータを取得する(公開投稿のみ、新しい順) |
| 205 | * |
| 206 | * @return string JSONエンコードされたアウトボックスデータ |
| 207 | */ |
| 208 | public function getOutbox(): string { |
| 209 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 210 | $items = []; |
| 211 | |
| 212 | foreach (array_reverse($this->federatablePosts()) as $post) $items[] = $this->buildCreate($post); |
| 213 | |
| 214 | $outbox = [ |
| 215 | '@context' => [ |
| 216 | 'https://www.w3.org/ns/activitystreams', |
| 217 | 'https://w3id.org/security/v1', |
| 218 | ], |
| 219 | 'id' => "{$this->domain}/ap/outbox", |
| 220 | 'type' => 'OrderedCollection', |
| 221 | 'totalItems' => count($items), |
| 222 | 'orderedItems' => $items, |
| 223 | ]; |
| 224 | |
| 225 | return json_encode($outbox, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| 226 | } |
| 227 | |
| 228 | /** |
| 229 | * WebFingerデータを取得する |
| 230 | * |
| 231 | * @return string JSONエンコードされたWebFingerデータ |
| 232 | */ |
| 233 | public function getWebfinger(): string { |
| 234 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 235 | $uri = new Uri($this->domain); |
| 236 | $webfinger = [ |
| 237 | 'subject' => "acct:{$this->actor}@{$uri->getHost()}", |
| 238 | 'links' => [ |
| 239 | [ |
| 240 | 'rel' => 'self', |
| 241 | 'type' => 'application/activity+json', |
| 242 | 'href' => "{$this->domain}/ap/actor", |
| 243 | ], |
| 244 | ], |
| 245 | ]; |
| 246 | |
| 247 | return json_encode($webfinger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| 248 | } |
| 249 | |
| 250 | /** |
| 251 | * フォロワーのリストを取得する |
| 252 | * |
| 253 | * @return string JSONエンコードされたフォロワーのリスト |
| 254 | */ |
| 255 | public function getFollowers(): string { |
| 256 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 257 | return $this->collection('followers', $this->getFollowersList()); |
| 258 | } |
| 259 | |
| 260 | /** |
| 261 | * フォローしているアカウントのリストを取得する |
| 262 | * |
| 263 | * @return string JSONエンコードされたフォローリスト |
| 264 | */ |
| 265 | public function getFollowing(): string { |
| 266 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 267 | return $this->collection('following', $this->readList(ROOT.'/data/following.txt')); |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * フォロワー・フォローのOrderedCollectionを作る |
| 272 | * |
| 273 | * @param string $name followers又はfollowing |
| 274 | * @param array $items アクターのURL |
| 275 | * @return string JSON |
| 276 | */ |
| 277 | private function collection(string $name, array $items): string { |
| 278 | $collection = [ |
| 279 | '@context' => [ |
| 280 | 'https://www.w3.org/ns/activitystreams', |
| 281 | 'https://w3id.org/security/v1', |
| 282 | ], |
| 283 | 'id' => "{$this->domain}/ap/{$name}", |
| 284 | 'type' => 'OrderedCollection', |
| 285 | 'totalItems' => count($items), |
| 286 | 'orderedItems' => $items, |
| 287 | ]; |
| 288 | |
| 289 | return json_encode($collection, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| 290 | } |
| 291 | |
| 292 | /** |
| 293 | * インボックスにアクティビティを投稿する |
| 294 | * |
| 295 | * @param array $activity 処理するアクティビティデータ |
| 296 | * @return void |
| 297 | */ |
| 298 | public function postInbox(array $activity): void { |
| 299 | if (!ACTIVITYPUB_ENABLED) return; |
| 300 | [$code, $body] = $this->handleInbox($activity); |
| 301 | |
| 302 | $status = [200 => 'OK', 202 => 'Accepted', 400 => 'Bad Request', 500 => 'Internal Server Error'][$code] ?? ''; |
| 303 | header("HTTP/1.1 {$code} {$status}"); |
| 304 | header('Content-Type: application/activity+json'); |
| 305 | echo json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); |
| 306 | exit; |
| 307 | } |
| 308 | |
| 309 | /** |
| 310 | * 受付ボックスに届いたアクティビティを処理する(署名は検証済みの事) |
| 311 | * |
| 312 | * Follow:フォロワーに追加してAcceptを返す |
| 313 | * Undo(Follow):フォロワーから外す |
| 314 | * Delete(アクター自身):フォロワーから外す |
| 315 | * それ以外:何もせず202を返す(5xxを返すと相手のサーバーが何日も再送する) |
| 316 | * |
| 317 | * @param array $activity アクティビティ |
| 318 | * @return array [HTTPステータス, レスポンスの本文] |
| 319 | */ |
| 320 | public function handleInbox(array $activity): array { |
| 321 | $actor = $activity['actor'] ?? null; |
| 322 | if (is_array($actor)) $actor = $actor['id'] ?? null; |
| 323 | |
| 324 | switch ($activity['type'] ?? '') { |
| 325 | case 'Follow': |
| 326 | return $this->acceptFollower($activity); |
| 327 | |
| 328 | case 'Undo': |
| 329 | // 中身がIDだけの場合はFollowかどうか分からないので無視する(Likeの取り消し等でフォロワーを外さない様に) |
| 330 | $object = $activity['object'] ?? null; |
| 331 | $inner = is_array($object) ? ($object['actor'] ?? null) : null; |
| 332 | if (is_array($inner)) $inner = $inner['id'] ?? null; |
| 333 | if (is_array($object) && ($object['type'] ?? '') === 'Follow' && is_string($actor) && $inner === $actor) { |
| 334 | $this->removeFollower($actor); |
| 335 | return [200, ['status' => 'OK']]; |
| 336 | } |
| 337 | return [202, ['status' => '無視しました']]; |
| 338 | |
| 339 | case 'Delete': |
| 340 | $object = $activity['object'] ?? null; |
| 341 | if (is_array($object)) $object = $object['id'] ?? null; |
| 342 | if (is_string($actor) && $object === $actor) $this->removeFollower($actor); |
| 343 | return [202, ['status' => 'OK']]; |
| 344 | |
| 345 | default: |
| 346 | return [202, ['status' => '未対応なアクティビティタイプ: '.(string)($activity['type'] ?? '')]]; |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | /** |
| 351 | * ブログの投稿とコメントへのFediverseからの返信を、記事のコメントにする(署名は検証済みの事) |
| 352 | * 受付ボックスの処理(handleInbox又はマルチユーザーのInbox::handle)の前に呼ぶ |
| 353 | * |
| 354 | * Create:返信を追加(公開・未収載だけ)、Update:本文を更新、Delete:返信を削除(アカウントの削除なら全部) |
| 355 | * |
| 356 | * @param array $activity アクティビティ |
| 357 | * @return bool コメントとして処理したらtrue |
| 358 | */ |
| 359 | public function handleComments(array $activity): bool { |
| 360 | if (!ACTIVITYPUB_ENABLED || !Comments::enabled()) return false; |
| 361 | $actor = Fedi::id($activity['actor'] ?? null); |
| 362 | if ($actor === null || Fedi::isLocal($actor) || Actor::isDomainBlocked($actor)) return false; |
| 363 | |
| 364 | try { |
| 365 | switch ($activity['type'] ?? '') { |
| 366 | case 'Create': |
| 367 | return $this->commentFromNote($activity['object'] ?? null, $actor); |
| 368 | case 'Update': |
| 369 | $object = $activity['object'] ?? null; |
| 370 | return is_array($object) && Comments::updateRemote($object, $actor); |
| 371 | case 'Delete': |
| 372 | $id = Fedi::id($activity['object'] ?? null); |
| 373 | if ($id === $actor) return Comments::forgetActor($actor) > 0; |
| 374 | return $id !== null && Comments::deleteRemote($id, $actor); |
| 375 | } |
| 376 | } catch (\Throwable $e) { |
| 377 | logger(LogType::ActivityPub, 'コメントの処理に失敗:'.$e->getMessage()); |
| 378 | } |
| 379 | return false; |
| 380 | } |
| 381 | |
| 382 | /** |
| 383 | * 投稿のNoteのID(Fediverseから返信する時の宛先) |
| 384 | * |
| 385 | * @param array $post 投稿データ |
| 386 | * @return string|null 連合しない投稿(会員限定、未来の日付等)ならnull |
| 387 | */ |
| 388 | public function objectId(array $post): ?string { |
| 389 | if (!ACTIVITYPUB_ENABLED) return null; |
| 390 | $post = $this->findPublicPost((string)($post['uuid'] ?? ''), $post['section'] ?? 'blog'); |
| 391 | return $post !== null ? "{$this->domain}/ap/objects/".self::postKey($post) : null; |
| 392 | } |
| 393 | |
| 394 | /** |
| 395 | * アクタープロフィールの更新アクティビティを作成する |
| 396 | * |
| 397 | * @return string JSONエンコードされた更新アクティビティ |
| 398 | */ |
| 399 | public function update(): string { |
| 400 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 401 | $update = [ |
| 402 | '@context' => [ |
| 403 | 'https://www.w3.org/ns/activitystreams', |
| 404 | 'https://w3id.org/security/v1', |
| 405 | ], |
| 406 | 'id' => "{$this->domain}/ap/activities/update/".uuid(), |
| 407 | 'type' => 'Update', |
| 408 | 'actor' => "{$this->domain}/ap/actor", |
| 409 | 'to' => ['https://www.w3.org/ns/activitystreams#Public'], |
| 410 | 'object' => json_decode($this->getActor(), true), |
| 411 | ]; |
| 412 | |
| 413 | return json_encode($update, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| 414 | } |
| 415 | |
| 416 | /** |
| 417 | * アクター更新をフォロワーに送信する |
| 418 | * |
| 419 | * @return Result 送信した件数 |
| 420 | */ |
| 421 | public function sendActorUpdate(): Result { |
| 422 | if (!ACTIVITYPUB_ENABLED) return Result::Error('ActivityPubは無効です。'); |
| 423 | $update = json_decode($this->update(), true); |
| 424 | $ok = $ng = 0; |
| 425 | |
| 426 | foreach ($this->followerInboxes() as $inbox) { |
| 427 | if ($this->sendActivity($inbox, $update)) $ok++; |
| 428 | else $ng++; |
| 429 | } |
| 430 | |
| 431 | $data = ['delivered' => $ok, 'failed' => $ng]; |
| 432 | if ($ok === 0 && $ng > 0) return Result::Error("アクター更新の送信に全部失敗しました({$ng}件)。", $data); |
| 433 | return Result::Success("アクター更新を送信しました(成功{$ok}件、失敗{$ng}件)。", $data); |
| 434 | } |
| 435 | |
| 436 | /** |
| 437 | * まだ連合していない公開投稿のCreateアクティビティをフォロワーに送信する(cronのタスク用) |
| 438 | * |
| 439 | * 送信済みのUUIDはdata/ap_federated.jsonに記録する。 |
| 440 | * 初回の実行では既存の投稿を全部送信済みとして記録するだけで、何も送信しない(古い投稿を一気に流さない為)。 |
| 441 | * 後から連合する様にしたブログ(config.phpのBLOGSのfederate)も同じく、既存の投稿は送信しない。 |
| 442 | * 日付が未来の投稿はその日時になってから送信する。 |
| 443 | * 失敗した受付ボックスは次回以降に再試行し、FEDERATE_MAX_TRIES回で諦める。 |
| 444 | * |
| 445 | * @param string|null $stateFile 記録ファイル(テスト用、既定はdata/ap_federated.json) |
| 446 | * @return Result data:posts(送信を終えた投稿数)、delivered(配信成功数)、failed(配信失敗数) |
| 447 | */ |
| 448 | public function federate(?string $stateFile = null): Result { |
| 449 | if (!ACTIVITYPUB_ENABLED) return Result::Error('ActivityPubは無効です。'); |
| 450 | if (PHP_SAPI === 'cli' && empty(FEDIINFO['domain'])) { |
| 451 | return Result::Error('CLIで実行する場合はconfig.phpのFEDIINFOに「domain」(例:https://example.com)を設定して下さい。'); |
| 452 | } |
| 453 | |
| 454 | $stateFile ??= ROOT.'/data/ap_federated.json'; |
| 455 | $fp = @fopen($stateFile, 'c+'); |
| 456 | if ($fp === false) return Result::Error("「{$stateFile}」を開けません。"); |
| 457 | if (!flock($fp, LOCK_EX | LOCK_NB)) { |
| 458 | fclose($fp); |
| 459 | return Result::Error('別の連合処理が実行中です。'); |
| 460 | } |
| 461 | |
| 462 | try { |
| 463 | $raw = stream_get_contents($fp); |
| 464 | $state = trim((string)$raw) === '' ? null : json_decode($raw, true); |
| 465 | if ($state !== null && !is_array($state)) return Result::Error("「{$stateFile}」が壊れています。"); |
| 466 | |
| 467 | $posts = $this->federatablePosts(); |
| 468 | $zero = ['posts' => 0, 'delivered' => 0, 'failed' => 0]; |
| 469 | // 連合するブログ(config.phpのBLOGS)。新しく追加したブログの既存の投稿は送らない |
| 470 | $sections = array_keys(array_filter(blogs(), fn($b) => $b['federate'])); |
| 471 | foreach ($posts as $p) $sections[] = $p['section'] ?? 'blog'; |
| 472 | |
| 473 | if ($state === null) { |
| 474 | $state = ['sent' => [], 'pending' => [], 'sections' => array_values(array_unique($sections))]; |
| 475 | foreach ($posts as $p) $state['sent'][self::postKey($p)] = time(); |
| 476 | $this->writeFederateState($fp, $state); |
| 477 | return Result::Success('初回の実行なので、既存の投稿'.count($posts).'件を送信済みとして記録しました。', $zero); |
| 478 | } |
| 479 | |
| 480 | $state['sent'] = (array)($state['sent'] ?? []); |
| 481 | $state['pending'] = (array)($state['pending'] ?? []); |
| 482 | // 「sections」が無い記録はblogだけを連合していた頃の物 |
| 483 | $known = (array)($state['sections'] ?? ['blog']); |
| 484 | $byUuid = []; |
| 485 | foreach ($posts as $p) { |
| 486 | $key = self::postKey($p); |
| 487 | $byUuid[$key] = $p; |
| 488 | if (!in_array($p['section'] ?? 'blog', $known, true)) $state['sent'][$key] ??= time(); |
| 489 | } |
| 490 | $state['sections'] = array_values(array_unique(array_merge($known, $sections))); |
| 491 | |
| 492 | // 新しい投稿を送信待ちに追加する(古い順) |
| 493 | $inboxes = null; |
| 494 | foreach ($posts as $p) { |
| 495 | $uuid = self::postKey($p); |
| 496 | if (isset($state['sent'][$uuid]) || isset($state['pending'][$uuid])) continue; |
| 497 | $inboxes ??= $this->followerInboxes(); |
| 498 | $state['pending'][$uuid] = ['inboxes' => $inboxes, 'tries' => 0]; |
| 499 | } |
| 500 | if (empty($state['pending'])) { |
| 501 | if ($state['sections'] !== $known) $this->writeFederateState($fp, $state); |
| 502 | return Result::Success('新しい投稿はありません。', $zero); |
| 503 | } |
| 504 | |
| 505 | $done = $ok = $ng = 0; |
| 506 | foreach ($state['pending'] as $uuid => $job) { |
| 507 | // 削除された又は非公開になった投稿は送らない |
| 508 | if (!isset($byUuid[$uuid])) { |
| 509 | unset($state['pending'][$uuid]); |
| 510 | $this->writeFederateState($fp, $state); |
| 511 | continue; |
| 512 | } |
| 513 | |
| 514 | $activity = $this->buildCreate($byUuid[$uuid]); |
| 515 | $left = []; |
| 516 | foreach ((array)($job['inboxes'] ?? []) as $inbox) { |
| 517 | if (is_string($inbox) && $this->sendActivity($inbox, $activity)) $ok++; |
| 518 | else { |
| 519 | $ng++; |
| 520 | $left[] = $inbox; |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | $tries = (int)($job['tries'] ?? 0) + 1; |
| 525 | if (empty($left) || $tries >= self::FEDERATE_MAX_TRIES) { |
| 526 | if (!empty($left)) { |
| 527 | logger(LogType::ActivityPub, "「{$uuid}」の配信を諦めました:".implode(', ', $left)); |
| 528 | } |
| 529 | unset($state['pending'][$uuid]); |
| 530 | $state['sent'][$uuid] = time(); |
| 531 | $done++; |
| 532 | } else { |
| 533 | $state['pending'][$uuid] = ['inboxes' => $left, 'tries' => $tries]; |
| 534 | } |
| 535 | |
| 536 | // 途中で止まっても二重送信しない様に、1件毎に保存する |
| 537 | $this->writeFederateState($fp, $state); |
| 538 | } |
| 539 | |
| 540 | $data = ['posts' => $done, 'delivered' => $ok, 'failed' => $ng]; |
| 541 | $msg = "投稿{$done}件を連合しました(配信成功{$ok}件、失敗{$ng}件)。"; |
| 542 | if ($ok === 0 && $ng > 0) return Result::Error($msg, $data); |
| 543 | return Result::Success($msg, $data); |
| 544 | } finally { |
| 545 | flock($fp, LOCK_UN); |
| 546 | fclose($fp); |
| 547 | } |
| 548 | } |
| 549 | |
| 550 | /** |
| 551 | * 受付ボックスへのリクエストのHTTP署名を検証する |
| 552 | * |
| 553 | * @param string $body リクエストの本文(生データ) |
| 554 | * @param array $activity デコード済みのアクティビティ |
| 555 | * @return bool 署名が正しく、署名者がactorと一致すればtrue |
| 556 | */ |
| 557 | public function verifyInboxSignature(string $body, array $activity): bool { |
| 558 | $actor = $activity['actor'] ?? null; |
| 559 | if (is_array($actor)) $actor = $actor['id'] ?? null; |
| 560 | if (!is_string($actor)) return false; |
| 561 | return $this->signer($body) === $actor; |
| 562 | } |
| 563 | |
| 564 | /** |
| 565 | * リクエストのHTTP署名を検証し、署名したアクターを返す |
| 566 | * POSTはDigestも確認する。GET(フォロワー限定の投稿の取得等)はDigest無しで良い |
| 567 | * |
| 568 | * @param string $body リクエストの本文(GETは空) |
| 569 | * @return string|null 鍵の持ち主のアクターのURL、検証出来なければnull |
| 570 | */ |
| 571 | public function signer(string $body = ''): ?string { |
| 572 | if (!ACTIVITYPUB_ENABLED) return null; |
| 573 | $isPost = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST'; |
| 574 | |
| 575 | // Signature: keyId="…",algorithm="…",headers="…",signature="…" |
| 576 | $sigHeader = $_SERVER['HTTP_SIGNATURE'] ?? ''; |
| 577 | if ($sigHeader === '') return null; |
| 578 | preg_match_all('/(\w+)="([^"]*)"/', $sigHeader, $m, PREG_SET_ORDER); |
| 579 | $sig = []; |
| 580 | foreach ($m as $kv) $sig[$kv[1]] = $kv[2]; |
| 581 | if (empty($sig['keyId']) || empty($sig['signature'])) return null; |
| 582 | if (isset($sig['algorithm']) |
| 583 | && !in_array(strtolower($sig['algorithm']), ['rsa-sha256', 'hs2019'], true)) return null; |
| 584 | |
| 585 | // 最低限これらのヘッダーが署名されている事 |
| 586 | $signed = explode(' ', strtolower(trim($sig['headers'] ?? 'date'))); |
| 587 | foreach ($isPost ? ['(request-target)', 'host', 'date', 'digest'] : ['(request-target)', 'host', 'date'] as $req) { |
| 588 | if (!in_array($req, $signed, true)) return null; |
| 589 | } |
| 590 | |
| 591 | // 再送攻撃対策:Dateは前後5分以内 |
| 592 | $date = strtotime($_SERVER['HTTP_DATE'] ?? ''); |
| 593 | if (!$date || abs(time() - $date) > 300) return null; |
| 594 | |
| 595 | // Digestが本文と一致する事 / Digest must match the body |
| 596 | $expected = base64_encode(hash('sha256', $body, true)); |
| 597 | $digestOk = false; |
| 598 | foreach (explode(',', $_SERVER['HTTP_DIGEST'] ?? '') as $d) { |
| 599 | $d = trim($d); |
| 600 | if (stripos($d, 'SHA-256=') === 0 && hash_equals($expected, substr($d, 8))) $digestOk = true; |
| 601 | } |
| 602 | if ($isPost && !$digestOk) return null; |
| 603 | |
| 604 | // 署名対象の文字列を組み立てる |
| 605 | $lines = []; |
| 606 | foreach ($signed as $h) { |
| 607 | if ($h === '(request-target)') { |
| 608 | $lines[] = '(request-target): '.strtolower($_SERVER['REQUEST_METHOD']).' '.$_SERVER['REQUEST_URI']; |
| 609 | continue; |
| 610 | } |
| 611 | $key = match ($h) { |
| 612 | 'content-type' => 'CONTENT_TYPE', |
| 613 | 'content-length' => 'CONTENT_LENGTH', |
| 614 | default => 'HTTP_'.strtoupper(str_replace('-', '_', $h)), |
| 615 | }; |
| 616 | if (!isset($_SERVER[$key])) return null; |
| 617 | $lines[] = "{$h}: {$_SERVER[$key]}"; |
| 618 | } |
| 619 | |
| 620 | // 公開鍵を取得する(鍵の持ち主が署名したアクター) |
| 621 | |
| 622 | $doc = $this->fetchRemoteJson(strtok($sig['keyId'], '#')); |
| 623 | if (!$doc) return null; |
| 624 | $keys = $doc['publicKey'] ?? $doc; |
| 625 | if (array_is_list($keys)) { |
| 626 | $keys = array_values(array_filter($keys, fn($k) => is_array($k) && ($k['id'] ?? '') === $sig['keyId']))[0] ?? []; |
| 627 | } |
| 628 | if (($keys['id'] ?? '') !== $sig['keyId']) return null; |
| 629 | $owner = $keys['owner'] ?? $doc['id'] ?? null; |
| 630 | if (!is_string($owner)) return null; |
| 631 | // 鍵の持ち主と鍵のURLは同じサーバーである事 |
| 632 | if (strtolower((string)parse_url($owner, PHP_URL_HOST)) !== strtolower((string)parse_url($sig['keyId'], PHP_URL_HOST))) return null; |
| 633 | |
| 634 | try { |
| 635 | $pub = Rsa::fromPem((string)($keys['publicKeyPem'] ?? '')); |
| 636 | } catch (\InvalidArgumentException $e) { |
| 637 | return null; |
| 638 | } |
| 639 | $raw = base64_decode($sig['signature'], true); |
| 640 | if ($raw === false) return null; |
| 641 | |
| 642 | return $pub->verify(implode("\n", $lines), $raw, 'sha256') ? $owner : null; |
| 643 | } |
| 644 | |
| 645 | // 機能性メソッド |
| 646 | |
| 647 | /** |
| 648 | * リモートのURLに接続しても安全か確認する(SSRF対策) |
| 649 | * check a remote URL is safe to connect to (SSRF protection) |
| 650 | * |
| 651 | * httpsのみ、443番ポートのみ、認証情報なし、制御文字・空白なし(CRLFインジェクション対策)、 |
| 652 | * 全ての解決先IPが公開アドレスである事。 |
| 653 | * https only, port 443 only, no credentials, no control chars or spaces (CRLF injection), |
| 654 | * and every resolved IP must be a public address. |
| 655 | * 注意:Curlは改めて名前解決するので、DNSリバインディングまでは防げない。 |
| 656 | * note: Curl resolves the name again, so this does not stop DNS rebinding. |
| 657 | * |
| 658 | * @param mixed $url 確認するURL |
| 659 | * @return bool 安全ならtrue |
| 660 | */ |
| 661 | public function isSafeRemoteUrl(mixed $url): bool { |
| 662 | if (!is_string($url) || strlen($url) > 2048) return false; |
| 663 | if (preg_match('/[\x00-\x20\x7f]/', $url)) return false; |
| 664 | |
| 665 | $p = parse_url($url); |
| 666 | if (!$p || strtolower($p['scheme'] ?? '') !== 'https' || empty($p['host'])) return false; |
| 667 | if (isset($p['user']) || isset($p['pass'])) return false; |
| 668 | if (isset($p['port']) && (int)$p['port'] !== 443) return false; |
| 669 | |
| 670 | $host = trim($p['host'], '[]'); |
| 671 | if (filter_var($host, FILTER_VALIDATE_IP)) { |
| 672 | $ips = [$host]; |
| 673 | } else { |
| 674 | $ips = @gethostbynamel($host) ?: []; |
| 675 | foreach ((@dns_get_record($host, DNS_AAAA) ?: []) as $r) { |
| 676 | if (isset($r['ipv6'])) $ips[] = $r['ipv6']; |
| 677 | } |
| 678 | } |
| 679 | if (empty($ips)) return false; |
| 680 | |
| 681 | foreach ($ips as $ip) { |
| 682 | if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return false; |
| 683 | } |
| 684 | |
| 685 | return true; |
| 686 | } |
| 687 | |
| 688 | /** |
| 689 | * リモートのActivityPubオブジェクトを取得する |
| 690 | * fetch a remote ActivityPub object |
| 691 | * |
| 692 | * @param string $url 取得するURL |
| 693 | * @return array|null デコード済みのJSON、失敗した場合はnull |
| 694 | */ |
| 695 | public function fetchRemoteJson(string $url): ?array { |
| 696 | if (!$this->isSafeRemoteUrl($url)) { |
| 697 | logger(\LogType::ActivityPub, "安全ではないURLを拒否: {$url}"); |
| 698 | return null; |
| 699 | } |
| 700 | |
| 701 | // 「Authorized fetch」のサーバーは署名の無いGETを拒否するので、秘密鍵があれば署名する |
| 702 | $headers = ['Accept: application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"']; |
| 703 | $priv = $this->privateKey(false); |
| 704 | if ($priv !== null) $headers = array_merge($headers, $this->signRequest($url, '', $priv, 'get') ?? []); |
| 705 | |
| 706 | $curl = new Curl($url); |
| 707 | // リダイレクト先は検証されないので追わない / redirect targets aren't re-checked, so don't follow them |
| 708 | $curl->setHeaders($headers) |
| 709 | ->setFollowRedirects(false) |
| 710 | ->setTimeout(10) |
| 711 | ->setCaInfo('/etc/ssl/cert.pem'); |
| 712 | |
| 713 | logger(\LogType::ActivityPub, "リモートにリクエスト: {$url}"); |
| 714 | $res = $curl->execute(); |
| 715 | if (!$res->isSuccess || $curl->getResponseCode() !== 200) { |
| 716 | logger(\LogType::ActivityPub, "取得に失敗: HTTP {$curl->getResponseCode()}, エラー: {$curl->getError()}"); |
| 717 | return null; |
| 718 | } |
| 719 | |
| 720 | $body = $curl->getResponseBody(); |
| 721 | if (strlen($body) > 1048576) return null; |
| 722 | $data = json_decode($body, true); |
| 723 | return is_array($data) ? $data : null; |
| 724 | } |
| 725 | |
| 726 | /** |
| 727 | * 指定されたインボックスURLにアクティビティを送信する |
| 728 | * |
| 729 | * @param string $inboxUrl 送信先のインボックスURL |
| 730 | * @param array $activity 送信するアクティビティデータ |
| 731 | * @return bool 2xxが返ったらtrue |
| 732 | */ |
| 733 | protected function sendActivity(string $inboxUrl, array $activity): bool { |
| 734 | if (!$this->isSafeRemoteUrl($inboxUrl)) { |
| 735 | logger(LogType::ActivityPub, "安全ではない受付ボックスへの送信を拒否: {$inboxUrl}"); |
| 736 | return false; |
| 737 | } |
| 738 | |
| 739 | $priv = $this->privateKey(); |
| 740 | if ($priv === null) return false; |
| 741 | |
| 742 | $body = json_encode($activity, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); |
| 743 | $headers = $this->signRequest($inboxUrl, $body, $priv); |
| 744 | if ($headers === null) return false; |
| 745 | |
| 746 | $curl = new Curl($inboxUrl); |
| 747 | // リダイレクト先は検証されないので追わない / redirect targets aren't re-checked, so don't follow them |
| 748 | $curl->setMethod('POST') |
| 749 | ->setPostRaw($body) |
| 750 | ->setHeaders($headers) |
| 751 | ->setFollowRedirects(false) |
| 752 | ->setTimeout(15) |
| 753 | ->setCaInfo('/etc/ssl/cert.pem'); |
| 754 | |
| 755 | $res = $curl->execute(); |
| 756 | $code = $curl->getResponseCode(); |
| 757 | $ok = $res->isSuccess && $code >= 200 && $code < 300; |
| 758 | |
| 759 | $msg = "アクティビティ「{$activity['type']}」を「{$inboxUrl}」に送信: HTTP {$code}"; |
| 760 | if (!$ok) $msg .= "、エラー:{$curl->getError()}、レスポンス:".substr($curl->getResponseBody(), 0, 500); |
| 761 | logger(LogType::ActivityPub, $msg); |
| 762 | |
| 763 | return $ok; |
| 764 | } |
| 765 | |
| 766 | /** |
| 767 | * リクエストのヘッダーを作り、HTTP署名を付ける |
| 768 | * POST:(request-target) host date digest(Mastodon等が必須とする組み合わせ) |
| 769 | * GET:(request-target) host date |
| 770 | * |
| 771 | * @param string $url 送信先のURL |
| 772 | * @param string $body 送信する本文(GETは空) |
| 773 | * @param Rsa $priv 秘密鍵 |
| 774 | * @param string $method post又はget |
| 775 | * @param string|null $keyId 鍵のID(既定はブログのアクターの鍵。マルチユーザーではユーザーの鍵) |
| 776 | * @return array|null 「名前: 値」のヘッダー、署名に失敗した場合はnull |
| 777 | */ |
| 778 | public function signRequest(string $url, string $body, Rsa $priv, string $method = 'post', ?string $keyId = null): ?array { |
| 779 | $p = parse_url($url); |
| 780 | if (!$p || empty($p['host'])) return null; |
| 781 | $method = strtolower($method); |
| 782 | $target = ($p['path'] ?? '') === '' ? '/' : $p['path']; |
| 783 | if (isset($p['query'])) $target .= '?'.$p['query']; |
| 784 | |
| 785 | $headers = [ |
| 786 | 'Host' => $p['host'].(isset($p['port']) && (int)$p['port'] !== 443 ? ':'.$p['port'] : ''), |
| 787 | 'Date' => gmdate('D, d M Y H:i:s \G\M\T'), |
| 788 | ]; |
| 789 | $signed = ['(request-target)', 'host', 'date']; |
| 790 | $lines = ["(request-target): {$method} {$target}", "host: {$headers['Host']}", "date: {$headers['Date']}"]; |
| 791 | |
| 792 | if ($method === 'post') { |
| 793 | $headers['Digest'] = 'SHA-256='.base64_encode(hash('sha256', $body, true)); |
| 794 | $headers['Content-Type'] = 'application/activity+json'; |
| 795 | $signed[] = 'digest'; |
| 796 | $lines[] = "digest: {$headers['Digest']}"; |
| 797 | } |
| 798 | |
| 799 | try { |
| 800 | $signature = $priv->sign(implode("\n", $lines), 'sha256'); |
| 801 | } catch (\Throwable $e) { |
| 802 | logger(LogType::ActivityPub, 'エラー:署名に失敗: '.$e->getMessage()); |
| 803 | return null; |
| 804 | } |
| 805 | |
| 806 | $keyId ??= "{$this->domain}/ap/actor#main-key"; |
| 807 | $headers['Signature'] = "keyId=\"{$keyId}\"," |
| 808 | .'algorithm="rsa-sha256",' |
| 809 | .'headers="'.implode(' ', $signed).'",' |
| 810 | .'signature="'.base64_encode($signature).'"'; |
| 811 | |
| 812 | return array_map(fn($k, $v) => "{$k}: {$v}", array_keys($headers), $headers); |
| 813 | } |
| 814 | |
| 815 | /** |
| 816 | * 秘密鍵を読み込む(1回だけ) |
| 817 | * |
| 818 | * @param bool $log 失敗をログに書くか |
| 819 | * @return Rsa|null 失敗した場合はnull |
| 820 | */ |
| 821 | private function privateKey(bool $log = true): ?Rsa { |
| 822 | if ($this->privKey !== null) return $this->privKey; |
| 823 | |
| 824 | $file = FEDIINFO['privkey'] ?? ''; |
| 825 | $pem = is_string($file) && is_readable($file) ? file_get_contents($file) : false; |
| 826 | try { |
| 827 | $key = $pem === false ? null : Rsa::fromPem($pem); |
| 828 | } catch (\InvalidArgumentException $e) { |
| 829 | $key = null; |
| 830 | } |
| 831 | if ($key === null || !$key->isPrivate()) { |
| 832 | if ($log) logger(LogType::ActivityPub, "エラー:秘密鍵「{$file}」の読込に失敗"); |
| 833 | return null; |
| 834 | } |
| 835 | |
| 836 | return $this->privKey = $key; |
| 837 | } |
| 838 | |
| 839 | /** |
| 840 | * 返信(Note等)をコメントにする |
| 841 | * |
| 842 | * @param mixed $object CreateのobjectかそのID |
| 843 | * @param string $actor アクティビティのアクター |
| 844 | * @return bool コメントにしたらtrue |
| 845 | */ |
| 846 | private function commentFromNote(mixed $object, string $actor): bool { |
| 847 | if (is_string($object)) $object = Remote::object($object); |
| 848 | if (!is_array($object) || !in_array($object['type'] ?? '', ['Note', 'Article', 'Page'], true)) return false; |
| 849 | $parent = Fedi::id($object['inReplyTo'] ?? null); |
| 850 | if ($parent === null) return false; |
| 851 | |
| 852 | // 返信先:ブログの投稿か、Fediverseから受け取ったコメント |
| 853 | $post = $this->postByObjectId($parent); |
| 854 | $target = $post !== null ? [($post['section'] ?? 'blog').'/'.$post['slug'], null, (string)$post['title']] : Comments::findRemote($parent); |
| 855 | if ($target === null) return false; |
| 856 | |
| 857 | if (Fedi::id($object['attributedTo'] ?? null) !== $actor) return false; |
| 858 | $id = Fedi::id($object['id'] ?? null); |
| 859 | if ($id === null || Fedi::domainOf($id) !== Fedi::domainOf($actor)) return false; |
| 860 | // DMやフォロワー限定の返信は公開しない |
| 861 | $vis = Fedi::visibility(Fedi::ids($object['to'] ?? []), Fedi::ids($object['cc'] ?? [])); |
| 862 | if (!in_array($vis, ['public', 'unlisted'], true)) return false; |
| 863 | |
| 864 | $doc = Remote::actor($actor); |
| 865 | if ($doc === null) return false; |
| 866 | $res = Comments::addRemote($target[0], $target[2], $doc, $object, $target[1]); |
| 867 | if (!$res->isSuccess) logger(LogType::ActivityPub, "「{$id}」をコメントに出来ませんでした:{$res->message}"); |
| 868 | return $res->isSuccess; |
| 869 | } |
| 870 | |
| 871 | /** |
| 872 | * NoteのID(/ap/objects/{UUID}又は/ap/objects/{ブログ}/{UUID})から公開投稿を探す |
| 873 | */ |
| 874 | private function postByObjectId(string $id): ?array { |
| 875 | $prefix = "{$this->domain}/ap/objects/"; |
| 876 | if (!str_starts_with($id, $prefix)) return null; |
| 877 | $key = explode('/', substr($id, strlen($prefix))); |
| 878 | if (count($key) === 1) return $this->findPublicPost($key[0], 'blog'); |
| 879 | return count($key) === 2 ? $this->findPublicPost($key[1], $key[0]) : null; |
| 880 | } |
| 881 | |
| 882 | /** |
| 883 | * 投稿のIDの部分:blogはUUIDだけ(今まで通り)、他のブログは「ブログ/UUID」 |
| 884 | * 翻訳した記事は同じUUIDなので、ブログ毎に違うIDにする |
| 885 | */ |
| 886 | private static function postKey(array $post): string { |
| 887 | $section = $post['section'] ?? 'blog'; |
| 888 | return $section === 'blog' ? $post['uuid'] : "{$section}/{$post['uuid']}"; |
| 889 | } |
| 890 | |
| 891 | /** |
| 892 | * 投稿からCreateアクティビティを作る(getActivity・getOutbox・federateで共通) |
| 893 | * |
| 894 | * @param array $post 投稿データ |
| 895 | * @return array アクティビティ |
| 896 | */ |
| 897 | private function buildCreate(array $post): array { |
| 898 | $uuid = self::postKey($post); |
| 899 | $section = $post['section'] ?? 'blog'; |
| 900 | $url = "{$this->domain}/{$section}/".rawurlencode($post['slug']); |
| 901 | $more = blogs()[$section]['more'] ?? '読み続き'; |
| 902 | // 投稿の日付はPHPのタイムゾーンの時刻なので、UTCに変換する |
| 903 | $published = gmdate('Y-m-d\TH:i:s\Z', strtotime($post['date']) ?: time()); |
| 904 | $esc = fn(string $s) => htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 905 | |
| 906 | $note = [ |
| 907 | 'id' => "{$this->domain}/ap/objects/{$uuid}", |
| 908 | 'type' => 'Note', |
| 909 | 'name' => $post['title'], |
| 910 | 'attributedTo' => "{$this->domain}/ap/actor", |
| 911 | 'cc' => [ |
| 912 | "{$this->domain}/ap/followers", |
| 913 | ], |
| 914 | 'to' => ['https://www.w3.org/ns/activitystreams#Public'], |
| 915 | 'content' => $esc($post['preview'])."<br /><br /><a href=\"{$esc($url)}\">{$esc($more)}</a>", |
| 916 | 'url' => $url, |
| 917 | 'published' => $published, |
| 918 | 'replies' => "{$this->domain}/ap/objects/{$uuid}/replies", |
| 919 | 'sensitive' => false, |
| 920 | ]; |
| 921 | |
| 922 | // タグと添付は(Createではなく)Noteに付ける。そうしないと相手のサーバーに無視される |
| 923 | if (!empty($post['category'])) { |
| 924 | $note['tag'] = array_map(fn($cat) => ['type' => 'Hashtag', 'name' => '#'.$cat], array_values($post['category'])); |
| 925 | } |
| 926 | |
| 927 | $thumb = $post['thumbnail'] ?? ''; |
| 928 | $imgpath = ROOT.ass_path("article/{$thumb}"); |
| 929 | if ($thumb !== '' && is_file($imgpath)) { |
| 930 | $note['attachment'] = [ |
| 931 | [ |
| 932 | 'digestMultibase' => 'z'.base58btc_encode(hash_file('sha256', $imgpath, true)), |
| 933 | 'mediaType' => self::imageType($imgpath), |
| 934 | 'type' => 'Image', |
| 935 | 'url' => ass_url('article/'.rawurlencode($thumb), $this->domain), |
| 936 | ], |
| 937 | ]; |
| 938 | } |
| 939 | |
| 940 | return [ |
| 941 | '@context' => [ |
| 942 | 'https://www.w3.org/ns/activitystreams', |
| 943 | 'https://w3id.org/security/v1', |
| 944 | [ |
| 945 | 'Emoji' => 'toot:Emoji', |
| 946 | 'EmojiReact' => 'litepub:EmojiReact', |
| 947 | 'Hashtag' => 'as:Hashtag', |
| 948 | 'litepub' => 'http://litepub.social/ns#', |
| 949 | 'sensitive' => 'as:sensitive', |
| 950 | 'toot' => 'http://joinmastodon.org/ns#', |
| 951 | ], |
| 952 | ], |
| 953 | 'id' => "{$this->domain}/ap/activities/create/{$uuid}", |
| 954 | 'type' => 'Create', |
| 955 | 'actor' => "{$this->domain}/ap/actor", |
| 956 | 'cc' => [ |
| 957 | "{$this->domain}/ap/followers", |
| 958 | ], |
| 959 | 'published' => $published, |
| 960 | 'to' => ['https://www.w3.org/ns/activitystreams#Public'], |
| 961 | 'object' => $note, |
| 962 | ]; |
| 963 | } |
| 964 | |
| 965 | /** |
| 966 | * UUIDで公開投稿を探す |
| 967 | * |
| 968 | * @param string $uuid 投稿のUUID |
| 969 | * @param string $section ブログのディレクトリ |
| 970 | * @return array|null 投稿データ、無い(又は非公開)場合はnull |
| 971 | */ |
| 972 | private function findPublicPost(string $uuid, string $section = 'blog'): ?array { |
| 973 | if ($uuid === '') return null; |
| 974 | foreach ($this->federatablePosts() as $post) { |
| 975 | if ($post['uuid'] === $uuid && ($post['section'] ?? 'blog') === $section) return $post; |
| 976 | } |
| 977 | return null; |
| 978 | } |
| 979 | |
| 980 | /** |
| 981 | * 画像のMIMEタイプ(fileinfoが無い環境では拡張子で判断する) |
| 982 | * |
| 983 | * @param string $path 画像のパス |
| 984 | * @return string |
| 985 | */ |
| 986 | private static function imageType(string $path): string { |
| 987 | if (function_exists('mime_content_type')) { |
| 988 | $type = @mime_content_type($path); |
| 989 | if (is_string($type) && $type !== '') return $type; |
| 990 | } |
| 991 | return match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) { |
| 992 | 'png' => 'image/png', |
| 993 | 'gif' => 'image/gif', |
| 994 | 'webp' => 'image/webp', |
| 995 | 'avif' => 'image/avif', |
| 996 | 'svg' => 'image/svg+xml', |
| 997 | default => 'image/jpeg', |
| 998 | }; |
| 999 | } |
| 1000 | |
| 1001 | /** |
| 1002 | * 連合できる投稿(UUIDあり、公開、日付が過去)を古い順に返す |
| 1003 | * |
| 1004 | * @return array 投稿データの配列 |
| 1005 | */ |
| 1006 | private function federatablePosts(): array { |
| 1007 | if ($this->posts instanceof \Closure) $this->posts = ($this->posts)(); |
| 1008 | $now = time(); |
| 1009 | $out = []; |
| 1010 | |
| 1011 | foreach ($this->posts as $p) { |
| 1012 | if (!is_string($p['uuid'] ?? null) || $p['uuid'] === '') continue; |
| 1013 | if (($p['preview'] ?? '') === '未許可') continue; |
| 1014 | if (array_intersect(self::RESTRICTED, (array)($p['category'] ?? []))) continue; |
| 1015 | $t = strtotime($p['date'] ?? ''); |
| 1016 | if ($t === false || $t > $now) continue; |
| 1017 | $p['_time'] = $t; |
| 1018 | $out[] = $p; |
| 1019 | } |
| 1020 | |
| 1021 | usort($out, fn($a, $b) => $a['_time'] <=> $b['_time']); |
| 1022 | return $out; |
| 1023 | } |
| 1024 | |
| 1025 | /** |
| 1026 | * federate()の記録を書き込む |
| 1027 | * |
| 1028 | * @param resource $fp ロック済みのファイル |
| 1029 | * @param array $state 記録 |
| 1030 | * @return void |
| 1031 | */ |
| 1032 | private function writeFederateState($fp, array $state): void { |
| 1033 | ftruncate($fp, 0); |
| 1034 | rewind($fp); |
| 1035 | fwrite($fp, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); |
| 1036 | fflush($fp); |
| 1037 | } |
| 1038 | |
| 1039 | /** |
| 1040 | * フォロワーの受付ボックスを取得する(sharedInboxがあればそちら、重複なし) |
| 1041 | * |
| 1042 | * @return array 受付ボックスのURL |
| 1043 | */ |
| 1044 | protected function followerInboxes(): array { |
| 1045 | $inboxes = []; |
| 1046 | |
| 1047 | foreach ($this->getFollowersList() as $actor) { |
| 1048 | $data = $this->fetchRemoteJson($actor); |
| 1049 | if (!$data) { |
| 1050 | logger(LogType::ActivityPub, "フォロワー「{$actor}」の取得に失敗"); |
| 1051 | continue; |
| 1052 | } |
| 1053 | |
| 1054 | $inbox = $data['endpoints']['sharedInbox'] ?? null; |
| 1055 | if (!$this->isSafeRemoteUrl($inbox)) $inbox = $data['inbox'] ?? null; |
| 1056 | if (!$this->isSafeRemoteUrl($inbox)) continue; |
| 1057 | $inboxes[$inbox] = true; |
| 1058 | } |
| 1059 | |
| 1060 | return array_keys($inboxes); |
| 1061 | } |
| 1062 | |
| 1063 | /** |
| 1064 | * フォロワーを受け入れる |
| 1065 | * |
| 1066 | * @param array $activity フォローアクティビティデータ |
| 1067 | * @return array [HTTPステータス, レスポンスの本文] |
| 1068 | */ |
| 1069 | private function acceptFollower(array $activity): array { |
| 1070 | $followerActor = $activity['actor'] ?? null; |
| 1071 | if (!is_string($followerActor) || !$this->isSafeRemoteUrl($followerActor)) { |
| 1072 | return [400, ['error' => 'アクターがない']]; |
| 1073 | } |
| 1074 | |
| 1075 | $object = $activity['object'] ?? null; |
| 1076 | if (is_array($object)) $object = $object['id'] ?? null; |
| 1077 | if ($object !== "{$this->domain}/ap/actor") { |
| 1078 | return [400, ['error' => '不正なフォロー対象']]; |
| 1079 | } |
| 1080 | |
| 1081 | $inbox = $this->getInboxFromActor($followerActor); |
| 1082 | if (!$inbox) { |
| 1083 | return [500, ['error' => 'フォロワーの受付ボックスの受取に失敗']]; |
| 1084 | } |
| 1085 | |
| 1086 | $this->storeFollower($followerActor); |
| 1087 | |
| 1088 | $accept = [ |
| 1089 | '@context' => [ |
| 1090 | 'https://www.w3.org/ns/activitystreams', |
| 1091 | 'https://w3id.org/security/v1', |
| 1092 | ], |
| 1093 | 'id' => "{$this->domain}/ap/activities/accept/".uuid(), |
| 1094 | 'type' => 'Accept', |
| 1095 | 'actor' => "{$this->domain}/ap/actor", |
| 1096 | 'object' => $activity, |
| 1097 | ]; |
| 1098 | |
| 1099 | if (!$this->sendActivity($inbox, $accept)) { |
| 1100 | logger(LogType::ActivityPub, "「{$followerActor}」へのAcceptの送信に失敗"); |
| 1101 | } |
| 1102 | return [200, ['status' => 'OK']]; |
| 1103 | } |
| 1104 | |
| 1105 | /** |
| 1106 | * フォロワーをストレージに保存する |
| 1107 | * |
| 1108 | * @param string $followerActor フォロワーのアクターURL |
| 1109 | * @return void |
| 1110 | */ |
| 1111 | private function storeFollower(string $followerActor): void { |
| 1112 | $this->modifyFollowers(function(array $list) use ($followerActor): array { |
| 1113 | if (!in_array($followerActor, $list, true)) $list[] = $followerActor; |
| 1114 | return $list; |
| 1115 | }); |
| 1116 | } |
| 1117 | |
| 1118 | /** |
| 1119 | * フォロワーを外す |
| 1120 | * |
| 1121 | * @param string $followerActor フォロワーのアクターURL |
| 1122 | * @return void |
| 1123 | */ |
| 1124 | private function removeFollower(string $followerActor): void { |
| 1125 | $this->modifyFollowers(fn(array $list): array => array_values(array_filter($list, fn($a) => $a !== $followerActor))); |
| 1126 | } |
| 1127 | |
| 1128 | /** |
| 1129 | * フォロワーのファイルをロックして読み書きする(同時のFollowで消えない様に) |
| 1130 | * |
| 1131 | * @param callable $fn リストを受け取り、新しいリストを返す |
| 1132 | * @return void |
| 1133 | */ |
| 1134 | private function modifyFollowers(callable $fn): void { |
| 1135 | $file = $this->followersFile(); |
| 1136 | $fp = @fopen($file, 'c+'); |
| 1137 | if ($fp === false) { |
| 1138 | logger(LogType::ActivityPub, "「{$file}」を開けません"); |
| 1139 | return; |
| 1140 | } |
| 1141 | |
| 1142 | flock($fp, LOCK_EX); |
| 1143 | $list = self::parseList((string)stream_get_contents($fp)); |
| 1144 | $new = $fn($list); |
| 1145 | if ($new !== $list) { |
| 1146 | ftruncate($fp, 0); |
| 1147 | rewind($fp); |
| 1148 | fwrite($fp, empty($new) ? '' : implode("\n", $new)."\n"); |
| 1149 | fflush($fp); |
| 1150 | } |
| 1151 | flock($fp, LOCK_UN); |
| 1152 | fclose($fp); |
| 1153 | @chmod($file, 0644); |
| 1154 | } |
| 1155 | |
| 1156 | /** |
| 1157 | * フォロワーを保存するファイル(テストで変えられる様に) |
| 1158 | * |
| 1159 | * @return string |
| 1160 | */ |
| 1161 | protected function followersFile(): string { |
| 1162 | return ROOT.'/data/followers.txt'; |
| 1163 | } |
| 1164 | |
| 1165 | /** |
| 1166 | * フォロワーのリストを配列として取得する |
| 1167 | * |
| 1168 | * @return array フォロワーのURLの配列 |
| 1169 | */ |
| 1170 | private function getFollowersList(): array { |
| 1171 | return $this->readList($this->followersFile()); |
| 1172 | } |
| 1173 | |
| 1174 | /** |
| 1175 | * 1行に1つのURLのファイルを読む |
| 1176 | * |
| 1177 | * @param string $file ファイル |
| 1178 | * @return array 空行を除いた行 |
| 1179 | */ |
| 1180 | private function readList(string $file): array { |
| 1181 | return is_file($file) ? self::parseList((string)file_get_contents($file)) : []; |
| 1182 | } |
| 1183 | |
| 1184 | private static function parseList(string $text): array { |
| 1185 | return array_values(array_filter(array_map('trim', explode("\n", $text)), fn($l) => $l !== '')); |
| 1186 | } |
| 1187 | |
| 1188 | /** |
| 1189 | * アクターのインボックスURLを取得する |
| 1190 | * |
| 1191 | * @param string $actor アクターのURL |
| 1192 | * @return string|null インボックスURL、取得に失敗した場合はnull |
| 1193 | */ |
| 1194 | protected function getInboxFromActor(string $actor): ?string { |
| 1195 | $data = $this->fetchRemoteJson($actor); |
| 1196 | if (!$data) return null; |
| 1197 | |
| 1198 | $inbox = $data['inbox'] ?? null; |
| 1199 | return $this->isSafeRemoteUrl($inbox) ? $inbox : null; |
| 1200 | } |
| 1201 | } |