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;
}
}