master
ブランチ master
LittleBeast / src / Std / Lib / Activitypub.php
45.7 KB、1201行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, 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
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib;
39
40use Std\Lib\Crypto\Rsa;
41use Std\Lib\Curl;
42use Std\Lib\Fedi\Actor;
43use Std\Lib\Fedi\Fedi;
44use Std\Lib\Fedi\Remote;
45use LogType;
46use Result;
47use Uri\Rfc3986\Uri;
48
49/**
50 * ActivityPubプロトコルの実装クラス
51 *
52 * このクラスはActivityPubプロトコルを利用して分散型
53 * ソーシャルネットワーキングを実装します。
54 */
55class 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}