https = 'http'.((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 's' : '').'://'; // CLI(cron)にはHTTP_HOSTが無いので、FEDIINFOの「domain」を優先する $this->domain = \Std\Lib\Fedi\Fedi::configuredDomain(); if ($this->domain === '') $this->domain = $this->https.($_SERVER['HTTP_HOST'] ?? '127.0.0.1:8000'); $this->actor = FEDIINFO['actor']; $this->actorNick = FEDIINFO['actorNick']; $this->desc = FEDIINFO['desc']; // アイコンはURL(ASS_DOMAINのサーバー等)でも良い $this->icon = preg_match('#^https?://#i', FEDIINFO['icon']) ? FEDIINFO['icon'] : "{$this->domain}".FEDIINFO['icon']; $this->posts = $posts; } /** * ActivityPubアクタープロフィールを受け取る * * @return string アクターオブジェクト * @throws \Exception 公開鍵の読み込みに失敗した場合 */ public function getActor(): string { if (!ACTIVITYPUB_ENABLED) return ''; $pubkey = @file_get_contents(FEDIINFO['pubkey']); if ($pubkey === false) { throw new \Exception('公開鍵の受取に失敗。パス:'.FEDIINFO['pubkey']); } $actor = [ '@context' => [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', ], 'id' => "{$this->domain}/ap/actor", 'name' => $this->actorNick, 'summary' => $this->desc, 'manuallyApprovesFollowers' => false, 'icon' => [ 'type' => 'Image', 'mediaType' => 'image/png', 'url' => $this->icon, ], 'image' => [ 'type' => 'Image', 'url' => ass_url('article/o_53803618dc1691.28179609.jpg', $this->domain), 'mediaType' => 'image/jpeg', ], 'type' => 'Person', 'url' => "{$this->domain}", 'preferredUsername' => $this->actor, 'inbox' => "{$this->domain}/ap/inbox", 'outbox' => "{$this->domain}/ap/outbox", 'followers' => "{$this->domain}/ap/followers", 'following' => "{$this->domain}/ap/following", 'published' => '2025-03-28T18:00:00Z', 'updated' => gmdate('c'), 'publicKey' => [ 'id' => "{$this->domain}/ap/actor#main-key", 'owner' => "{$this->domain}/ap/actor", 'publicKeyPem' => $pubkey, ], ]; return json_encode($actor, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } /** * 特定のUUIDに対応するActivityを取得する * * @param string $uuid 取得するアクティビティのUUID * @param string $section ブログのディレクトリ * @return string JSONエンコードされたアクティビティデータ */ public function getActivity(string $uuid, string $section = 'blog'): string { if (!ACTIVITYPUB_ENABLED) return ''; $post = $this->findPublicPost($uuid, $section); if ($post === null) return ''; return json_encode($this->buildCreate($post), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } /** * 特定のUUIDに対応するNote(Createの中身)を取得する * 相手のサーバーがオブジェクトのIDを取得しに来た時の為 * * @param string $uuid 投稿のUUID * @param string $section ブログのディレクトリ * @return string JSONエンコードされたNote、無ければ空文字 */ public function getObject(string $uuid, string $section = 'blog'): string { if (!ACTIVITYPUB_ENABLED) return ''; $post = $this->findPublicPost($uuid, $section); if ($post === null) return ''; $create = $this->buildCreate($post); $note = ['@context' => $create['@context']] + $create['object']; return json_encode($note, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } /** * 投稿への返信のコレクション(Noteの「replies」) * Fediverseにある返信(受け取った返信と、Fediverseのロールを持つユーザーのコメント)だけ。 * 他のサーバーやbrowser.pub等がスレッドを表示するのに使う * * @param string $uuid 投稿のUUID * @param string $section ブログのディレクトリ * @return string JSONエンコードされたOrderedCollection、投稿が無ければ空文字 */ public function getReplies(string $uuid, string $section = 'blog'): string { if (!ACTIVITYPUB_ENABLED) return ''; $post = $this->findPublicPost($uuid, $section); if ($post === null) return ''; $items = Comments::fediReplies(($post['section'] ?? 'blog').'/'.$post['slug']); $collection = [ '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => "{$this->domain}/ap/objects/".self::postKey($post).'/replies', 'type' => 'OrderedCollection', 'totalItems' => count($items), 'orderedItems' => $items, ]; return json_encode($collection, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } /** * アウトボックスデータを取得する(公開投稿のみ、新しい順) * * @return string JSONエンコードされたアウトボックスデータ */ public function getOutbox(): string { if (!ACTIVITYPUB_ENABLED) return ''; $items = []; foreach (array_reverse($this->federatablePosts()) as $post) $items[] = $this->buildCreate($post); $outbox = [ '@context' => [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', ], 'id' => "{$this->domain}/ap/outbox", 'type' => 'OrderedCollection', 'totalItems' => count($items), 'orderedItems' => $items, ]; return json_encode($outbox, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } /** * WebFingerデータを取得する * * @return string JSONエンコードされたWebFingerデータ */ public function getWebfinger(): string { if (!ACTIVITYPUB_ENABLED) return ''; $uri = new Uri($this->domain); $webfinger = [ 'subject' => "acct:{$this->actor}@{$uri->getHost()}", 'links' => [ [ 'rel' => 'self', 'type' => 'application/activity+json', 'href' => "{$this->domain}/ap/actor", ], ], ]; return json_encode($webfinger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } /** * フォロワーのリストを取得する * * @return string JSONエンコードされたフォロワーのリスト */ public function getFollowers(): string { if (!ACTIVITYPUB_ENABLED) return ''; return $this->collection('followers', $this->getFollowersList()); } /** * フォローしているアカウントのリストを取得する * * @return string JSONエンコードされたフォローリスト */ public function getFollowing(): string { if (!ACTIVITYPUB_ENABLED) return ''; return $this->collection('following', $this->readList(ROOT.'/data/following.txt')); } /** * フォロワー・フォローのOrderedCollectionを作る * * @param string $name followers又はfollowing * @param array $items アクターのURL * @return string JSON */ private function collection(string $name, array $items): string { $collection = [ '@context' => [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', ], 'id' => "{$this->domain}/ap/{$name}", 'type' => 'OrderedCollection', 'totalItems' => count($items), 'orderedItems' => $items, ]; return json_encode($collection, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } /** * インボックスにアクティビティを投稿する * * @param array $activity 処理するアクティビティデータ * @return void */ public function postInbox(array $activity): void { if (!ACTIVITYPUB_ENABLED) return; [$code, $body] = $this->handleInbox($activity); $status = [200 => 'OK', 202 => 'Accepted', 400 => 'Bad Request', 500 => 'Internal Server Error'][$code] ?? ''; header("HTTP/1.1 {$code} {$status}"); header('Content-Type: application/activity+json'); echo json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); exit; } /** * 受付ボックスに届いたアクティビティを処理する(署名は検証済みの事) * * Follow:フォロワーに追加してAcceptを返す * Undo(Follow):フォロワーから外す * Delete(アクター自身):フォロワーから外す * それ以外:何もせず202を返す(5xxを返すと相手のサーバーが何日も再送する) * * @param array $activity アクティビティ * @return array [HTTPステータス, レスポンスの本文] */ public function handleInbox(array $activity): array { $actor = $activity['actor'] ?? null; if (is_array($actor)) $actor = $actor['id'] ?? null; switch ($activity['type'] ?? '') { case 'Follow': return $this->acceptFollower($activity); case 'Undo': // 中身がIDだけの場合はFollowかどうか分からないので無視する(Likeの取り消し等でフォロワーを外さない様に) $object = $activity['object'] ?? null; $inner = is_array($object) ? ($object['actor'] ?? null) : null; if (is_array($inner)) $inner = $inner['id'] ?? null; if (is_array($object) && ($object['type'] ?? '') === 'Follow' && is_string($actor) && $inner === $actor) { $this->removeFollower($actor); return [200, ['status' => 'OK']]; } return [202, ['status' => '無視しました']]; case 'Delete': $object = $activity['object'] ?? null; if (is_array($object)) $object = $object['id'] ?? null; if (is_string($actor) && $object === $actor) $this->removeFollower($actor); return [202, ['status' => 'OK']]; default: return [202, ['status' => '未対応なアクティビティタイプ: '.(string)($activity['type'] ?? '')]]; } } /** * ブログの投稿とコメントへのFediverseからの返信を、記事のコメントにする(署名は検証済みの事) * 受付ボックスの処理(handleInbox又はマルチユーザーのInbox::handle)の前に呼ぶ * * Create:返信を追加(公開・未収載だけ)、Update:本文を更新、Delete:返信を削除(アカウントの削除なら全部) * * @param array $activity アクティビティ * @return bool コメントとして処理したらtrue */ public function handleComments(array $activity): bool { if (!ACTIVITYPUB_ENABLED || !Comments::enabled()) return false; $actor = Fedi::id($activity['actor'] ?? null); if ($actor === null || Fedi::isLocal($actor) || Actor::isDomainBlocked($actor)) return false; try { switch ($activity['type'] ?? '') { case 'Create': return $this->commentFromNote($activity['object'] ?? null, $actor); case 'Update': $object = $activity['object'] ?? null; return is_array($object) && Comments::updateRemote($object, $actor); case 'Delete': $id = Fedi::id($activity['object'] ?? null); if ($id === $actor) return Comments::forgetActor($actor) > 0; return $id !== null && Comments::deleteRemote($id, $actor); } } catch (\Throwable $e) { logger(LogType::ActivityPub, 'コメントの処理に失敗:'.$e->getMessage()); } return false; } /** * 投稿のNoteのID(Fediverseから返信する時の宛先) * * @param array $post 投稿データ * @return string|null 連合しない投稿(会員限定、未来の日付等)ならnull */ public function objectId(array $post): ?string { if (!ACTIVITYPUB_ENABLED) return null; $post = $this->findPublicPost((string)($post['uuid'] ?? ''), $post['section'] ?? 'blog'); return $post !== null ? "{$this->domain}/ap/objects/".self::postKey($post) : null; } /** * アクタープロフィールの更新アクティビティを作成する * * @return string JSONエンコードされた更新アクティビティ */ public function update(): string { if (!ACTIVITYPUB_ENABLED) return ''; $update = [ '@context' => [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', ], 'id' => "{$this->domain}/ap/activities/update/".uuid(), 'type' => 'Update', 'actor' => "{$this->domain}/ap/actor", 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'object' => json_decode($this->getActor(), true), ]; return json_encode($update, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } /** * アクター更新をフォロワーに送信する * * @return Result 送信した件数 */ public function sendActorUpdate(): Result { if (!ACTIVITYPUB_ENABLED) return Result::Error('ActivityPubは無効です。'); $update = json_decode($this->update(), true); $ok = $ng = 0; foreach ($this->followerInboxes() as $inbox) { if ($this->sendActivity($inbox, $update)) $ok++; else $ng++; } $data = ['delivered' => $ok, 'failed' => $ng]; if ($ok === 0 && $ng > 0) return Result::Error("アクター更新の送信に全部失敗しました({$ng}件)。", $data); return Result::Success("アクター更新を送信しました(成功{$ok}件、失敗{$ng}件)。", $data); } /** * まだ連合していない公開投稿のCreateアクティビティをフォロワーに送信する(cronのタスク用) * * 送信済みのUUIDはdata/ap_federated.jsonに記録する。 * 初回の実行では既存の投稿を全部送信済みとして記録するだけで、何も送信しない(古い投稿を一気に流さない為)。 * 後から連合する様にしたブログ(config.phpのBLOGSのfederate)も同じく、既存の投稿は送信しない。 * 日付が未来の投稿はその日時になってから送信する。 * 失敗した受付ボックスは次回以降に再試行し、FEDERATE_MAX_TRIES回で諦める。 * * @param string|null $stateFile 記録ファイル(テスト用、既定はdata/ap_federated.json) * @return Result data:posts(送信を終えた投稿数)、delivered(配信成功数)、failed(配信失敗数) */ public function federate(?string $stateFile = null): Result { if (!ACTIVITYPUB_ENABLED) return Result::Error('ActivityPubは無効です。'); if (PHP_SAPI === 'cli' && empty(FEDIINFO['domain'])) { return Result::Error('CLIで実行する場合はconfig.phpのFEDIINFOに「domain」(例:https://example.com)を設定して下さい。'); } $stateFile ??= ROOT.'/data/ap_federated.json'; $fp = @fopen($stateFile, 'c+'); if ($fp === false) return Result::Error("「{$stateFile}」を開けません。"); if (!flock($fp, LOCK_EX | LOCK_NB)) { fclose($fp); return Result::Error('別の連合処理が実行中です。'); } try { $raw = stream_get_contents($fp); $state = trim((string)$raw) === '' ? null : json_decode($raw, true); if ($state !== null && !is_array($state)) return Result::Error("「{$stateFile}」が壊れています。"); $posts = $this->federatablePosts(); $zero = ['posts' => 0, 'delivered' => 0, 'failed' => 0]; // 連合するブログ(config.phpのBLOGS)。新しく追加したブログの既存の投稿は送らない $sections = array_keys(array_filter(blogs(), fn($b) => $b['federate'])); foreach ($posts as $p) $sections[] = $p['section'] ?? 'blog'; if ($state === null) { $state = ['sent' => [], 'pending' => [], 'sections' => array_values(array_unique($sections))]; foreach ($posts as $p) $state['sent'][self::postKey($p)] = time(); $this->writeFederateState($fp, $state); return Result::Success('初回の実行なので、既存の投稿'.count($posts).'件を送信済みとして記録しました。', $zero); } $state['sent'] = (array)($state['sent'] ?? []); $state['pending'] = (array)($state['pending'] ?? []); // 「sections」が無い記録はblogだけを連合していた頃の物 $known = (array)($state['sections'] ?? ['blog']); $byUuid = []; foreach ($posts as $p) { $key = self::postKey($p); $byUuid[$key] = $p; if (!in_array($p['section'] ?? 'blog', $known, true)) $state['sent'][$key] ??= time(); } $state['sections'] = array_values(array_unique(array_merge($known, $sections))); // 新しい投稿を送信待ちに追加する(古い順) $inboxes = null; foreach ($posts as $p) { $uuid = self::postKey($p); if (isset($state['sent'][$uuid]) || isset($state['pending'][$uuid])) continue; $inboxes ??= $this->followerInboxes(); $state['pending'][$uuid] = ['inboxes' => $inboxes, 'tries' => 0]; } if (empty($state['pending'])) { if ($state['sections'] !== $known) $this->writeFederateState($fp, $state); return Result::Success('新しい投稿はありません。', $zero); } $done = $ok = $ng = 0; foreach ($state['pending'] as $uuid => $job) { // 削除された又は非公開になった投稿は送らない if (!isset($byUuid[$uuid])) { unset($state['pending'][$uuid]); $this->writeFederateState($fp, $state); continue; } $activity = $this->buildCreate($byUuid[$uuid]); $left = []; foreach ((array)($job['inboxes'] ?? []) as $inbox) { if (is_string($inbox) && $this->sendActivity($inbox, $activity)) $ok++; else { $ng++; $left[] = $inbox; } } $tries = (int)($job['tries'] ?? 0) + 1; if (empty($left) || $tries >= self::FEDERATE_MAX_TRIES) { if (!empty($left)) { logger(LogType::ActivityPub, "「{$uuid}」の配信を諦めました:".implode(', ', $left)); } unset($state['pending'][$uuid]); $state['sent'][$uuid] = time(); $done++; } else { $state['pending'][$uuid] = ['inboxes' => $left, 'tries' => $tries]; } // 途中で止まっても二重送信しない様に、1件毎に保存する $this->writeFederateState($fp, $state); } $data = ['posts' => $done, 'delivered' => $ok, 'failed' => $ng]; $msg = "投稿{$done}件を連合しました(配信成功{$ok}件、失敗{$ng}件)。"; if ($ok === 0 && $ng > 0) return Result::Error($msg, $data); return Result::Success($msg, $data); } finally { flock($fp, LOCK_UN); fclose($fp); } } /** * 受付ボックスへのリクエストのHTTP署名を検証する * * @param string $body リクエストの本文(生データ) * @param array $activity デコード済みのアクティビティ * @return bool 署名が正しく、署名者がactorと一致すればtrue */ public function verifyInboxSignature(string $body, array $activity): bool { $actor = $activity['actor'] ?? null; if (is_array($actor)) $actor = $actor['id'] ?? null; if (!is_string($actor)) return false; return $this->signer($body) === $actor; } /** * リクエストのHTTP署名を検証し、署名したアクターを返す * POSTはDigestも確認する。GET(フォロワー限定の投稿の取得等)はDigest無しで良い * * @param string $body リクエストの本文(GETは空) * @return string|null 鍵の持ち主のアクターのURL、検証出来なければnull */ public function signer(string $body = ''): ?string { if (!ACTIVITYPUB_ENABLED) return null; $isPost = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST'; // Signature: keyId="…",algorithm="…",headers="…",signature="…" $sigHeader = $_SERVER['HTTP_SIGNATURE'] ?? ''; if ($sigHeader === '') return null; preg_match_all('/(\w+)="([^"]*)"/', $sigHeader, $m, PREG_SET_ORDER); $sig = []; foreach ($m as $kv) $sig[$kv[1]] = $kv[2]; if (empty($sig['keyId']) || empty($sig['signature'])) return null; if (isset($sig['algorithm']) && !in_array(strtolower($sig['algorithm']), ['rsa-sha256', 'hs2019'], true)) return null; // 最低限これらのヘッダーが署名されている事 $signed = explode(' ', strtolower(trim($sig['headers'] ?? 'date'))); foreach ($isPost ? ['(request-target)', 'host', 'date', 'digest'] : ['(request-target)', 'host', 'date'] as $req) { if (!in_array($req, $signed, true)) return null; } // 再送攻撃対策:Dateは前後5分以内 $date = strtotime($_SERVER['HTTP_DATE'] ?? ''); if (!$date || abs(time() - $date) > 300) return null; // Digestが本文と一致する事 / Digest must match the body $expected = base64_encode(hash('sha256', $body, true)); $digestOk = false; foreach (explode(',', $_SERVER['HTTP_DIGEST'] ?? '') as $d) { $d = trim($d); if (stripos($d, 'SHA-256=') === 0 && hash_equals($expected, substr($d, 8))) $digestOk = true; } if ($isPost && !$digestOk) return null; // 署名対象の文字列を組み立てる $lines = []; foreach ($signed as $h) { if ($h === '(request-target)') { $lines[] = '(request-target): '.strtolower($_SERVER['REQUEST_METHOD']).' '.$_SERVER['REQUEST_URI']; continue; } $key = match ($h) { 'content-type' => 'CONTENT_TYPE', 'content-length' => 'CONTENT_LENGTH', default => 'HTTP_'.strtoupper(str_replace('-', '_', $h)), }; if (!isset($_SERVER[$key])) return null; $lines[] = "{$h}: {$_SERVER[$key]}"; } // 公開鍵を取得する(鍵の持ち主が署名したアクター) $doc = $this->fetchRemoteJson(strtok($sig['keyId'], '#')); if (!$doc) return null; $keys = $doc['publicKey'] ?? $doc; if (array_is_list($keys)) { $keys = array_values(array_filter($keys, fn($k) => is_array($k) && ($k['id'] ?? '') === $sig['keyId']))[0] ?? []; } if (($keys['id'] ?? '') !== $sig['keyId']) return null; $owner = $keys['owner'] ?? $doc['id'] ?? null; if (!is_string($owner)) return null; // 鍵の持ち主と鍵のURLは同じサーバーである事 if (strtolower((string)parse_url($owner, PHP_URL_HOST)) !== strtolower((string)parse_url($sig['keyId'], PHP_URL_HOST))) return null; try { $pub = Rsa::fromPem((string)($keys['publicKeyPem'] ?? '')); } catch (\InvalidArgumentException $e) { return null; } $raw = base64_decode($sig['signature'], true); if ($raw === false) return null; return $pub->verify(implode("\n", $lines), $raw, 'sha256') ? $owner : null; } // 機能性メソッド /** * リモートのURLに接続しても安全か確認する(SSRF対策) * check a remote URL is safe to connect to (SSRF protection) * * httpsのみ、443番ポートのみ、認証情報なし、制御文字・空白なし(CRLFインジェクション対策)、 * 全ての解決先IPが公開アドレスである事。 * https only, port 443 only, no credentials, no control chars or spaces (CRLF injection), * and every resolved IP must be a public address. * 注意:Curlは改めて名前解決するので、DNSリバインディングまでは防げない。 * note: Curl resolves the name again, so this does not stop DNS rebinding. * * @param mixed $url 確認するURL * @return bool 安全ならtrue */ public function isSafeRemoteUrl(mixed $url): bool { if (!is_string($url) || strlen($url) > 2048) return false; if (preg_match('/[\x00-\x20\x7f]/', $url)) return false; $p = parse_url($url); if (!$p || strtolower($p['scheme'] ?? '') !== 'https' || empty($p['host'])) return false; if (isset($p['user']) || isset($p['pass'])) return false; if (isset($p['port']) && (int)$p['port'] !== 443) return false; $host = trim($p['host'], '[]'); if (filter_var($host, FILTER_VALIDATE_IP)) { $ips = [$host]; } else { $ips = @gethostbynamel($host) ?: []; foreach ((@dns_get_record($host, DNS_AAAA) ?: []) as $r) { if (isset($r['ipv6'])) $ips[] = $r['ipv6']; } } if (empty($ips)) return false; foreach ($ips as $ip) { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return false; } return true; } /** * リモートのActivityPubオブジェクトを取得する * fetch a remote ActivityPub object * * @param string $url 取得するURL * @return array|null デコード済みのJSON、失敗した場合はnull */ public function fetchRemoteJson(string $url): ?array { if (!$this->isSafeRemoteUrl($url)) { logger(\LogType::ActivityPub, "安全ではないURLを拒否: {$url}"); return null; } // 「Authorized fetch」のサーバーは署名の無いGETを拒否するので、秘密鍵があれば署名する $headers = ['Accept: application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"']; $priv = $this->privateKey(false); if ($priv !== null) $headers = array_merge($headers, $this->signRequest($url, '', $priv, 'get') ?? []); $curl = new Curl($url); // リダイレクト先は検証されないので追わない / redirect targets aren't re-checked, so don't follow them $curl->setHeaders($headers) ->setFollowRedirects(false) ->setTimeout(10) ->setCaInfo('/etc/ssl/cert.pem'); logger(\LogType::ActivityPub, "リモートにリクエスト: {$url}"); $res = $curl->execute(); if (!$res->isSuccess || $curl->getResponseCode() !== 200) { logger(\LogType::ActivityPub, "取得に失敗: HTTP {$curl->getResponseCode()}, エラー: {$curl->getError()}"); return null; } $body = $curl->getResponseBody(); if (strlen($body) > 1048576) return null; $data = json_decode($body, true); return is_array($data) ? $data : null; } /** * 指定されたインボックスURLにアクティビティを送信する * * @param string $inboxUrl 送信先のインボックスURL * @param array $activity 送信するアクティビティデータ * @return bool 2xxが返ったらtrue */ protected function sendActivity(string $inboxUrl, array $activity): bool { if (!$this->isSafeRemoteUrl($inboxUrl)) { logger(LogType::ActivityPub, "安全ではない受付ボックスへの送信を拒否: {$inboxUrl}"); return false; } $priv = $this->privateKey(); if ($priv === null) return false; $body = json_encode($activity, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $headers = $this->signRequest($inboxUrl, $body, $priv); if ($headers === null) return false; $curl = new Curl($inboxUrl); // リダイレクト先は検証されないので追わない / redirect targets aren't re-checked, so don't follow them $curl->setMethod('POST') ->setPostRaw($body) ->setHeaders($headers) ->setFollowRedirects(false) ->setTimeout(15) ->setCaInfo('/etc/ssl/cert.pem'); $res = $curl->execute(); $code = $curl->getResponseCode(); $ok = $res->isSuccess && $code >= 200 && $code < 300; $msg = "アクティビティ「{$activity['type']}」を「{$inboxUrl}」に送信: HTTP {$code}"; if (!$ok) $msg .= "、エラー:{$curl->getError()}、レスポンス:".substr($curl->getResponseBody(), 0, 500); logger(LogType::ActivityPub, $msg); return $ok; } /** * リクエストのヘッダーを作り、HTTP署名を付ける * POST:(request-target) host date digest(Mastodon等が必須とする組み合わせ) * GET:(request-target) host date * * @param string $url 送信先のURL * @param string $body 送信する本文(GETは空) * @param Rsa $priv 秘密鍵 * @param string $method post又はget * @param string|null $keyId 鍵のID(既定はブログのアクターの鍵。マルチユーザーではユーザーの鍵) * @return array|null 「名前: 値」のヘッダー、署名に失敗した場合はnull */ public function signRequest(string $url, string $body, Rsa $priv, string $method = 'post', ?string $keyId = null): ?array { $p = parse_url($url); if (!$p || empty($p['host'])) return null; $method = strtolower($method); $target = ($p['path'] ?? '') === '' ? '/' : $p['path']; if (isset($p['query'])) $target .= '?'.$p['query']; $headers = [ 'Host' => $p['host'].(isset($p['port']) && (int)$p['port'] !== 443 ? ':'.$p['port'] : ''), 'Date' => gmdate('D, d M Y H:i:s \G\M\T'), ]; $signed = ['(request-target)', 'host', 'date']; $lines = ["(request-target): {$method} {$target}", "host: {$headers['Host']}", "date: {$headers['Date']}"]; if ($method === 'post') { $headers['Digest'] = 'SHA-256='.base64_encode(hash('sha256', $body, true)); $headers['Content-Type'] = 'application/activity+json'; $signed[] = 'digest'; $lines[] = "digest: {$headers['Digest']}"; } try { $signature = $priv->sign(implode("\n", $lines), 'sha256'); } catch (\Throwable $e) { logger(LogType::ActivityPub, 'エラー:署名に失敗: '.$e->getMessage()); return null; } $keyId ??= "{$this->domain}/ap/actor#main-key"; $headers['Signature'] = "keyId=\"{$keyId}\"," .'algorithm="rsa-sha256",' .'headers="'.implode(' ', $signed).'",' .'signature="'.base64_encode($signature).'"'; return array_map(fn($k, $v) => "{$k}: {$v}", array_keys($headers), $headers); } /** * 秘密鍵を読み込む(1回だけ) * * @param bool $log 失敗をログに書くか * @return Rsa|null 失敗した場合はnull */ private function privateKey(bool $log = true): ?Rsa { if ($this->privKey !== null) return $this->privKey; $file = FEDIINFO['privkey'] ?? ''; $pem = is_string($file) && is_readable($file) ? file_get_contents($file) : false; try { $key = $pem === false ? null : Rsa::fromPem($pem); } catch (\InvalidArgumentException $e) { $key = null; } if ($key === null || !$key->isPrivate()) { if ($log) logger(LogType::ActivityPub, "エラー:秘密鍵「{$file}」の読込に失敗"); return null; } return $this->privKey = $key; } /** * 返信(Note等)をコメントにする * * @param mixed $object CreateのobjectかそのID * @param string $actor アクティビティのアクター * @return bool コメントにしたらtrue */ private function commentFromNote(mixed $object, string $actor): bool { if (is_string($object)) $object = Remote::object($object); if (!is_array($object) || !in_array($object['type'] ?? '', ['Note', 'Article', 'Page'], true)) return false; $parent = Fedi::id($object['inReplyTo'] ?? null); if ($parent === null) return false; // 返信先:ブログの投稿か、Fediverseから受け取ったコメント $post = $this->postByObjectId($parent); $target = $post !== null ? [($post['section'] ?? 'blog').'/'.$post['slug'], null, (string)$post['title']] : Comments::findRemote($parent); if ($target === null) return false; if (Fedi::id($object['attributedTo'] ?? null) !== $actor) return false; $id = Fedi::id($object['id'] ?? null); if ($id === null || Fedi::domainOf($id) !== Fedi::domainOf($actor)) return false; // DMやフォロワー限定の返信は公開しない $vis = Fedi::visibility(Fedi::ids($object['to'] ?? []), Fedi::ids($object['cc'] ?? [])); if (!in_array($vis, ['public', 'unlisted'], true)) return false; $doc = Remote::actor($actor); if ($doc === null) return false; $res = Comments::addRemote($target[0], $target[2], $doc, $object, $target[1]); if (!$res->isSuccess) logger(LogType::ActivityPub, "「{$id}」をコメントに出来ませんでした:{$res->message}"); return $res->isSuccess; } /** * NoteのID(/ap/objects/{UUID}又は/ap/objects/{ブログ}/{UUID})から公開投稿を探す */ private function postByObjectId(string $id): ?array { $prefix = "{$this->domain}/ap/objects/"; if (!str_starts_with($id, $prefix)) return null; $key = explode('/', substr($id, strlen($prefix))); if (count($key) === 1) return $this->findPublicPost($key[0], 'blog'); return count($key) === 2 ? $this->findPublicPost($key[1], $key[0]) : null; } /** * 投稿のIDの部分:blogはUUIDだけ(今まで通り)、他のブログは「ブログ/UUID」 * 翻訳した記事は同じUUIDなので、ブログ毎に違うIDにする */ private static function postKey(array $post): string { $section = $post['section'] ?? 'blog'; return $section === 'blog' ? $post['uuid'] : "{$section}/{$post['uuid']}"; } /** * 投稿からCreateアクティビティを作る(getActivity・getOutbox・federateで共通) * * @param array $post 投稿データ * @return array アクティビティ */ private function buildCreate(array $post): array { $uuid = self::postKey($post); $section = $post['section'] ?? 'blog'; $url = "{$this->domain}/{$section}/".rawurlencode($post['slug']); $more = blogs()[$section]['more'] ?? '読み続き'; // 投稿の日付はPHPのタイムゾーンの時刻なので、UTCに変換する $published = gmdate('Y-m-d\TH:i:s\Z', strtotime($post['date']) ?: time()); $esc = fn(string $s) => htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $note = [ 'id' => "{$this->domain}/ap/objects/{$uuid}", 'type' => 'Note', 'name' => $post['title'], 'attributedTo' => "{$this->domain}/ap/actor", 'cc' => [ "{$this->domain}/ap/followers", ], 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'content' => $esc($post['preview'])."

{$esc($more)}", 'url' => $url, 'published' => $published, 'replies' => "{$this->domain}/ap/objects/{$uuid}/replies", 'sensitive' => false, ]; // タグと添付は(Createではなく)Noteに付ける。そうしないと相手のサーバーに無視される if (!empty($post['category'])) { $note['tag'] = array_map(fn($cat) => ['type' => 'Hashtag', 'name' => '#'.$cat], array_values($post['category'])); } $thumb = $post['thumbnail'] ?? ''; $imgpath = ROOT.ass_path("article/{$thumb}"); if ($thumb !== '' && is_file($imgpath)) { $note['attachment'] = [ [ 'digestMultibase' => 'z'.base58btc_encode(hash_file('sha256', $imgpath, true)), 'mediaType' => self::imageType($imgpath), 'type' => 'Image', 'url' => ass_url('article/'.rawurlencode($thumb), $this->domain), ], ]; } return [ '@context' => [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', [ 'Emoji' => 'toot:Emoji', 'EmojiReact' => 'litepub:EmojiReact', 'Hashtag' => 'as:Hashtag', 'litepub' => 'http://litepub.social/ns#', 'sensitive' => 'as:sensitive', 'toot' => 'http://joinmastodon.org/ns#', ], ], 'id' => "{$this->domain}/ap/activities/create/{$uuid}", 'type' => 'Create', 'actor' => "{$this->domain}/ap/actor", 'cc' => [ "{$this->domain}/ap/followers", ], 'published' => $published, 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'object' => $note, ]; } /** * UUIDで公開投稿を探す * * @param string $uuid 投稿のUUID * @param string $section ブログのディレクトリ * @return array|null 投稿データ、無い(又は非公開)場合はnull */ private function findPublicPost(string $uuid, string $section = 'blog'): ?array { if ($uuid === '') return null; foreach ($this->federatablePosts() as $post) { if ($post['uuid'] === $uuid && ($post['section'] ?? 'blog') === $section) return $post; } return null; } /** * 画像のMIMEタイプ(fileinfoが無い環境では拡張子で判断する) * * @param string $path 画像のパス * @return string */ private static function imageType(string $path): string { if (function_exists('mime_content_type')) { $type = @mime_content_type($path); if (is_string($type) && $type !== '') return $type; } return match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) { 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'avif' => 'image/avif', 'svg' => 'image/svg+xml', default => 'image/jpeg', }; } /** * 連合できる投稿(UUIDあり、公開、日付が過去)を古い順に返す * * @return array 投稿データの配列 */ private function federatablePosts(): array { if ($this->posts instanceof \Closure) $this->posts = ($this->posts)(); $now = time(); $out = []; foreach ($this->posts as $p) { if (!is_string($p['uuid'] ?? null) || $p['uuid'] === '') continue; if (($p['preview'] ?? '') === '未許可') continue; if (array_intersect(self::RESTRICTED, (array)($p['category'] ?? []))) continue; $t = strtotime($p['date'] ?? ''); if ($t === false || $t > $now) continue; $p['_time'] = $t; $out[] = $p; } usort($out, fn($a, $b) => $a['_time'] <=> $b['_time']); return $out; } /** * federate()の記録を書き込む * * @param resource $fp ロック済みのファイル * @param array $state 記録 * @return void */ private function writeFederateState($fp, array $state): void { ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); fflush($fp); } /** * フォロワーの受付ボックスを取得する(sharedInboxがあればそちら、重複なし) * * @return array 受付ボックスのURL */ protected function followerInboxes(): array { $inboxes = []; foreach ($this->getFollowersList() as $actor) { $data = $this->fetchRemoteJson($actor); if (!$data) { logger(LogType::ActivityPub, "フォロワー「{$actor}」の取得に失敗"); continue; } $inbox = $data['endpoints']['sharedInbox'] ?? null; if (!$this->isSafeRemoteUrl($inbox)) $inbox = $data['inbox'] ?? null; if (!$this->isSafeRemoteUrl($inbox)) continue; $inboxes[$inbox] = true; } return array_keys($inboxes); } /** * フォロワーを受け入れる * * @param array $activity フォローアクティビティデータ * @return array [HTTPステータス, レスポンスの本文] */ private function acceptFollower(array $activity): array { $followerActor = $activity['actor'] ?? null; if (!is_string($followerActor) || !$this->isSafeRemoteUrl($followerActor)) { return [400, ['error' => 'アクターがない']]; } $object = $activity['object'] ?? null; if (is_array($object)) $object = $object['id'] ?? null; if ($object !== "{$this->domain}/ap/actor") { return [400, ['error' => '不正なフォロー対象']]; } $inbox = $this->getInboxFromActor($followerActor); if (!$inbox) { return [500, ['error' => 'フォロワーの受付ボックスの受取に失敗']]; } $this->storeFollower($followerActor); $accept = [ '@context' => [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', ], 'id' => "{$this->domain}/ap/activities/accept/".uuid(), 'type' => 'Accept', 'actor' => "{$this->domain}/ap/actor", 'object' => $activity, ]; if (!$this->sendActivity($inbox, $accept)) { logger(LogType::ActivityPub, "「{$followerActor}」へのAcceptの送信に失敗"); } return [200, ['status' => 'OK']]; } /** * フォロワーをストレージに保存する * * @param string $followerActor フォロワーのアクターURL * @return void */ private function storeFollower(string $followerActor): void { $this->modifyFollowers(function(array $list) use ($followerActor): array { if (!in_array($followerActor, $list, true)) $list[] = $followerActor; return $list; }); } /** * フォロワーを外す * * @param string $followerActor フォロワーのアクターURL * @return void */ private function removeFollower(string $followerActor): void { $this->modifyFollowers(fn(array $list): array => array_values(array_filter($list, fn($a) => $a !== $followerActor))); } /** * フォロワーのファイルをロックして読み書きする(同時のFollowで消えない様に) * * @param callable $fn リストを受け取り、新しいリストを返す * @return void */ private function modifyFollowers(callable $fn): void { $file = $this->followersFile(); $fp = @fopen($file, 'c+'); if ($fp === false) { logger(LogType::ActivityPub, "「{$file}」を開けません"); return; } flock($fp, LOCK_EX); $list = self::parseList((string)stream_get_contents($fp)); $new = $fn($list); if ($new !== $list) { ftruncate($fp, 0); rewind($fp); fwrite($fp, empty($new) ? '' : implode("\n", $new)."\n"); fflush($fp); } flock($fp, LOCK_UN); fclose($fp); @chmod($file, 0644); } /** * フォロワーを保存するファイル(テストで変えられる様に) * * @return string */ protected function followersFile(): string { return ROOT.'/data/followers.txt'; } /** * フォロワーのリストを配列として取得する * * @return array フォロワーのURLの配列 */ private function getFollowersList(): array { return $this->readList($this->followersFile()); } /** * 1行に1つのURLのファイルを読む * * @param string $file ファイル * @return array 空行を除いた行 */ private function readList(string $file): array { return is_file($file) ? self::parseList((string)file_get_contents($file)) : []; } private static function parseList(string $text): array { return array_values(array_filter(array_map('trim', explode("\n", $text)), fn($l) => $l !== '')); } /** * アクターのインボックスURLを取得する * * @param string $actor アクターのURL * @return string|null インボックスURL、取得に失敗した場合はnull */ protected function getInboxFromActor(string $actor): ?string { $data = $this->fetchRemoteJson($actor); if (!$data) return null; $inbox = $data['inbox'] ?? null; return $this->isSafeRemoteUrl($inbox) ? $inbox : null; } }