isBlogResource($resource)) { // Gitlerのリポジトリ(acct:所有者.名前@ホスト) $jrd = Actor::webfinger($resource) ?? (\Std\Lib\Gitler\Gitler::enabled() ? \Std\Lib\Gitler\Federation::webfinger($resource) : null); if ($jrd === null) $this->json(404, ['error' => '見つかりません'], 'application/jrd+json'); $this->json(200, $jrd, 'application/jrd+json'); } header('Content-Type: application/jrd+json'); $ap = new Activitypub(); echo $ap->getWebfinger(); exit; } catch (\Exception $e) { header('Content-Type: text/plain; charset=utf-8'); echo 'フェディバースの作成に失敗: '.$e->getMessage(); exit; } } /** * @param array $params パラメータ配列 * @return void */ public function apactor(array $params): void { try { header('Content-Type: application/activity+json'); $ap = new Activitypub(); echo $ap->getActor(); exit; } catch (\Exception $e) { header('Content-Type: text/plain; charset=utf-8'); echo 'フェディバースの作成に失敗: '.$e->getMessage(); exit; } } /** * @param array $params パラメータ配列 * @return void */ public function apinbox(array $params): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('HTTP/1.1 405 Method Not Allowed'); header('Allow: POST'); exit; } $maxBody = 262144; $input = file_get_contents('php://input', false, null, 0, $maxBody + 1); if ($input === false || strlen($input) > $maxBody) { header('HTTP/1.1 413 Payload Too Large'); exit; } $activity = json_decode($input, true); if (!is_array($activity) || !isset($activity['type']) || !is_string($activity['type'])) { header('HTTP/1.1 400 Bad Request'); header('Content-Type: application/activity+json'); echo json_encode(['error' => '不正なアクティビティ']); exit; } try { header('Content-Type: application/activity+json'); // 投稿はコメント(ブログの投稿への返信)の処理で必要になった時だけ読む $ap = new Activitypub(fn() => $this->federatedPosts()); if (!$ap->verifyInboxSignature($input, $activity)) { // 削除されたアカウントのDeleteは鍵がもう取得出来ないので検証出来ない。拒否すると何日も再送されるので受け流す if ($activity['type'] === 'Delete') { header('HTTP/1.1 202 Accepted'); echo json_encode(['status' => '無視しました']); exit; } header('HTTP/1.1 401 Unauthorized'); echo json_encode(['error' => '署名の検証に失敗']); exit; } logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity)); // ブログの投稿への返信は記事のコメントにする(マルチユーザーでも、ユーザー宛ての処理は続ける) $ap->handleComments($activity); // マルチユーザーでは共有の受付ボックス(ブログのアクター宛てはInboxからActivitypubに渡す) if (Fedi::enabled()) { [$code, $body] = Inbox::handle($activity); $this->json($code, $body); } $ap->postInbox($activity); exit; } catch (\Exception $e) { header('Content-Type: text/plain; charset=utf-8'); echo 'フェディバースの作成に失敗: '.$e->getMessage(); exit; } } /** * ユーザーのアクター(/ap/users/{name})。ブラウザからはプロフィールのページに移動する * * @param array $params パラメータ配列 * @return void */ public function apuser(array $params): void { $u = $this->actorOr404($params); if ($this->wantsHtml()) { header('Location: /microblog/user/'.$u->username); exit; } $this->json(200, Actor::document($u)); } /** * ユーザーの受付ボックス(POST /ap/users/{name}/inbox)。処理は共有の受付ボックスと同じ * * @param array $params パラメータ配列 * @return void */ public function apuserinbox(array $params): void { $this->actorOr404($params); $this->apinbox($params); } /** * ユーザーの送信ボックス(公開・未収載の投稿とブースト、20件ずつ) * * @param array $params パラメータ配列 * @return void */ public function apuseroutbox(array $params): void { $u = $this->actorOr404($params); $url = Fedi::actorUrl($u->username).'/outbox'; $me = Fedi::actorUrl($u->username); $entries = array_values(array_filter(Timeline::entries(), fn($e) => $e['actor'] === $me && in_array($e['vis'], ['public', 'unlisted'], true))); usort($entries, fn($a, $b) => $b['t'] <=> $a['t']); $total = count($entries); if (!isset($_GET['page'])) { $this->json(200, ['@context' => 'https://www.w3.org/ns/activitystreams', 'id' => $url, 'type' => 'OrderedCollection', 'totalItems' => $total, 'first' => "{$url}?page=1", 'last' => "{$url}?page=".max(1, (int)ceil($total / 20))]); } $page = max(1, (int)$_GET['page']); $boostIds = Fedi::read(Fedi::userPath($u->username, 'boosts.json')); $items = []; foreach (array_slice($entries, ($page - 1) * 20, 20) as $e) { if ($e['type'] === 'boost') { $items[] = ['id' => $boostIds[$e['object']] ?? $me.'#boosts/'.sha1($e['object']), 'type' => 'Announce', 'actor' => $me, 'published' => Fedi::now((int)$e['t']), 'to' => [Fedi::PUBLIC], 'cc' => [$me.'/followers'], 'object' => $e['object']]; continue; } $ids = Fedi::localNoteId($e['object']); $note = $ids ? Notes::get($ids[1]) : null; if (!$note) continue; $create = Notes::toCreate($note); unset($create['@context'], $create['object']['@context']); $items[] = $create; } $data = ['@context' => Fedi::CONTEXT, 'id' => "{$url}?page={$page}", 'type' => 'OrderedCollectionPage', 'partOf' => $url, 'orderedItems' => $items]; if ($page * 20 < $total) $data['next'] = "{$url}?page=".($page + 1); if ($page > 1) $data['prev'] = "{$url}?page=".($page - 1); $this->json(200, $data); } /** * フォロワー・フォロー(数だけ公開する) * * @param array $params パラメータ配列 * @return void */ public function apuserfollowers(array $params): void { $u = $this->actorOr404($params); $this->json(200, Actor::collection($u->username, 'followers')); } public function apuserfollowing(array $params): void { $u = $this->actorOr404($params); $this->json(200, Actor::collection($u->username, 'following')); } /** * 投稿(/ap/users/{name}/notes/{id}) * 公開・未収載以外は、署名したアクターが宛先かフォロワーの場合だけ返す * * @param array $params パラメータ配列 * @return void */ public function apnote(array $params): void { [$u, $note] = $this->noteOr404($params); $url = Notes::url($note); if ($this->wantsHtml()) { header('Location: '.Notes::htmlUrl($url)); exit; } $this->json(200, Notes::toObject($note)); } public function apnoteactivity(array $params): void { [$u, $note] = $this->noteOr404($params); $this->json(200, Notes::toCreate($note)); } /** * NodeInfo(サーバーのソフトウェアの情報。他のサーバーが表示に使う) * * @param array $params パラメータ配列 * @return void */ public function nodeinfo(array $params): void { $this->json(200, ['links' => [['rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0', 'href' => Fedi::base().'/nodeinfo/2.0']]], 'application/json'); } public function nodeinfoSchema(array $params): void { $users = 0; foreach (glob((Fedi::$userDir ?? ROOT.'/data/user/').'*.json') ?: [] as $f) { $d = json_decode((string)file_get_contents($f)); if ($d instanceof \stdClass && can_federate($d)) $users++; } $this->json(200, [ 'version' => '2.0', 'software' => ['name' => 'littlebeast', 'version' => '1.0'], 'protocols' => ['activitypub'], 'services' => ['inbound' => [], 'outbound' => ['atom1.0']], 'openRegistrations' => AUTH_REGISTER_ENABLED, 'usage' => ['users' => ['total' => $users], 'localPosts' => count(glob(Fedi::path('notes/').'*.json') ?: [])], 'metadata' => ['nodeName' => SITEINFO['title']], ], 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"'); } /////// /** ローカルのアクター、無ければ404 */ private function actorOr404(array $params): \stdClass { $u = Fedi::enabled() ? Fedi::localActor((string)($params['name'] ?? '')) : null; if ($u === null) $this->json(404, ['error' => '見つかりません']); return $u; } /** 見る事が出来る投稿、無ければ404 */ private function noteOr404(array $params): array { $u = $this->actorOr404($params); $note = Notes::get((string)($params['id'] ?? '')); if (!$note || $note['author'] !== $u->username) $this->json(404, ['error' => '見つかりません']); if (!in_array($note['visibility'], ['public', 'unlisted'], true)) { $signer = (new Activitypub())->signer(); $ok = $signer !== null && (in_array($signer, array_merge($note['to'], $note['cc']), true) || ($note['visibility'] === 'followers' && Actor::isFollowedBy($u->username, $signer))); if (!$ok) $this->json(404, ['error' => '見つかりません']); } return [$u, $note]; } /** ブラウザからのアクセスか(ActivityPubのJSONを求めていない) */ private function wantsHtml(): bool { $accept = (string)($_SERVER['HTTP_ACCEPT'] ?? ''); return str_contains($accept, 'text/html') && !str_contains($accept, 'activity+json') && !str_contains($accept, 'ld+json'); } /** ブログのアクターのresource(acct:actor@host又は/ap/actor) */ private function isBlogResource(string $resource): bool { if ($resource === Fedi::blogActorUrl()) return true; return strtolower($resource) === strtolower('acct:'.FEDIINFO['actor'].'@'.Fedi::host()); } private function json(int $code, array $data, string $type = 'application/activity+json'): never { $status = [200 => 'OK', 202 => 'Accepted', 400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found', 500 => 'Internal Server Error'][$code] ?? ''; header("HTTP/1.1 {$code} {$status}"); header("Content-Type: {$type}"); echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); exit; } /** * @param array $params パラメータ配列 * @return void */ public function apactivity(array $params): void { $uuid = ''; if (isset($params['uuid'])) $uuid = $params['uuid']; try { header('Content-Type: application/activity+json'); $posts = $this->federatedPosts(); $ap = new Activitypub($posts); $json = $ap->getActivity($uuid, $params['section'] ?? 'blog'); if ($json === '') { header('HTTP/1.1 404 Not Found'); echo json_encode(['error' => '見つかりません']); exit; } echo $json; exit; } catch (\Exception $e) { header('Content-Type: text/plain; charset=utf-8'); echo 'フェディバースの作成に失敗: '.$e->getMessage(); exit; } } /** * @param array $params パラメータ配列 * @return void */ public function apobject(array $params): void { try { header('Content-Type: application/activity+json'); $ap = new Activitypub($this->federatedPosts()); $json = $ap->getObject($params['uuid'] ?? '', $params['section'] ?? 'blog'); if ($json === '') { header('HTTP/1.1 404 Not Found'); echo json_encode(['error' => '見つかりません']); exit; } echo $json; exit; } catch (\Exception $e) { header('Content-Type: text/plain; charset=utf-8'); echo 'フェディバースの作成に失敗: '.$e->getMessage(); exit; } } /** * 投稿への返信のコレクション(/ap/objects/{uuid}/replies) * * @param array $params パラメータ配列 * @return void */ public function apreplies(array $params): void { try { header('Content-Type: application/activity+json'); $ap = new Activitypub($this->federatedPosts()); $json = $ap->getReplies($params['uuid'] ?? '', $params['section'] ?? 'blog'); if ($json === '') { header('HTTP/1.1 404 Not Found'); echo json_encode(['error' => '見つかりません']); exit; } echo $json; exit; } catch (\Exception $e) { header('Content-Type: text/plain; charset=utf-8'); echo 'フェディバースの作成に失敗: '.$e->getMessage(); exit; } } /** * @param array $params パラメータ配列 * @return void */ public function apoutbox(array $params): void { try { header('Content-Type: application/activity+json'); $posts = $this->federatedPosts(); $ap = new Activitypub($posts); echo $ap->getOutbox(); exit; } catch (\Exception $e) { header('Content-Type: text/plain; charset=utf-8'); echo 'フェディバースの作成に失敗: '.$e->getMessage(); exit; } } /** * @param array $params パラメータ配列 * @return void */ public function apfollowers(array $params): void { try { header('Content-Type: application/activity+json'); $ap = new Activitypub(); echo $ap->getFollowers(); exit; } catch (\Exception $e) { header('Content-Type: text/plain; charset=utf-8'); echo 'フェディバースの作成に失敗: '.$e->getMessage(); exit; } } /** * @param array $params パラメータ配列 * @return void */ public function apfollowing(array $params): void { try { header('Content-Type: application/activity+json'); $ap = new Activitypub(); echo $ap->getFollowing(); exit; } catch (\Exception $e) { header('Content-Type: text/plain; charset=utf-8'); echo 'フェディバースの作成に失敗: '.$e->getMessage(); exit; } } }