isSafeRemoteUrl($url); } /** * URLを取得する(キャッシュなし) */ public static function fetch(string $url): ?array { if (Actor::isDomainBlocked($url)) return null; return self::ap()->fetchRemoteJson($url); } /** * アクターを取得する(1日キャッシュ) * * @param string $url アクターのURL * @param bool $refresh キャッシュを使わない * @return array|null */ public static function actor(string $url, bool $refresh = false, bool $canonical = true): ?array { if (Fedi::isLocal($url)) { $name = Fedi::localUsername($url); if ($name === null) return $url === Fedi::blogActorUrl() ? json_decode(self::ap()->getActor(), true) : null; $u = Fedi::localActor($name); return $u ? Actor::document($u) : null; } $file = Fedi::path('remote/actors/'.sha1($url).'.json'); $cached = Fedi::read($file, null); if (!$refresh && $cached && ($cached['fetchedAt'] ?? 0) > time() - self::ACTOR_TTL) return $cached['doc']; $doc = self::fetch($url); // プロフィールのURL(Mastodonの「/@user」等)からは本当のIDのアクターが返る。同じサーバーなら、そのIDで取り直す $id = is_array($doc) ? ($doc['id'] ?? null) : null; if ($canonical && is_string($id) && $id !== $url && self::isActor($doc) && Fedi::domainOf($id) === Fedi::domainOf($url)) { return self::actor($id, $refresh, false); } if (!$doc || !self::isActor($doc) || $id !== $url || !self::isSafeUrl($doc['inbox'] ?? null)) { return $cached['doc'] ?? null; } Fedi::write($file, ['doc' => $doc, 'fetchedAt' => time()]); return $doc; } /** * キャッシュしているアクター(取得しない。表示用) */ public static function cachedActor(string $url): ?array { if (Fedi::isLocal($url)) return self::actor($url); return Fedi::read(Fedi::path('remote/actors/'.sha1($url).'.json'), null)['doc'] ?? null; } /** * 受け取ったアクター(Update{Person}等)でキャッシュを更新する */ public static function cacheActor(array $doc): void { $id = $doc['id'] ?? null; if (!is_string($id) || Fedi::isLocal($id) || !self::isActor($doc)) return; Fedi::write(Fedi::path('remote/actors/'.sha1($id).'.json'), ['doc' => $doc, 'fetchedAt' => time()]); } public static function forgetActor(string $url): void { @unlink(Fedi::path('remote/actors/'.sha1($url).'.json')); } public static function isActor(array $doc): bool { return in_array($doc['type'] ?? '', ['Person', 'Service', 'Application', 'Group', 'Organization'], true); } /** * 「@user@host」をWebFingerでアクターのURLにする * * @return string|null */ public static function webfinger(string $user, string $host): ?string { if (!preg_match('/^[A-Za-z0-9_.-]+$/', $user) || !preg_match('/^[A-Za-z0-9.-]+$/', $host)) return null; if (strtolower($host) === strtolower(Fedi::host())) return self::localHandle($user); // 同じ人を何度も探さない様に、結果を1日キャッシュする // 見つからなかった場合は短くする(以前は1日で、相手のサーバーが一時的に応答しないと1日探せなかった) $file = Fedi::path('remote/webfinger/'.sha1(strtolower("{$user}@{$host}")).'.json'); $cached = Fedi::read($file, null); $ttl = ($cached['url'] ?? null) !== null ? self::ACTOR_TTL : self::WEBFINGER_RETRY; if ($cached && ($cached['at'] ?? 0) > time() - $ttl) return $cached['url']; $jrd = self::fetch("https://{$host}/.well-known/webfinger?resource=".rawurlencode("acct:{$user}@{$host}")); $url = null; foreach ($jrd['links'] ?? [] as $l) { if (($l['rel'] ?? '') === 'self' && str_contains((string)($l['type'] ?? ''), 'json') && self::isSafeUrl($l['href'] ?? null)) { $url = $l['href']; break; } } if ($url === null) logger(\LogType::ActivityPub, "WebFingerで見つかりません:{$user}@{$host}"); Fedi::write($file, ['url' => $url, 'at' => time()]); return $url; } /** * アクターの送信ボックスから最近の投稿(公開・未収載)を取得して保存する * * @param array $doc アクター * @return array 投稿(Note等)の配列 */ public static function outbox(array $doc): array { $outbox = is_string($doc['outbox'] ?? null) ? self::fetch($doc['outbox']) : null; if (!$outbox) return []; $page = $outbox['orderedItems'] ?? null; if ($page === null) { $first = $outbox['first'] ?? null; $page = is_array($first) ? ($first['orderedItems'] ?? []) : (is_string($first) ? (self::fetch($first)['orderedItems'] ?? []) : []); } $notes = self::outboxNotes($doc, (array)$page); // いいね・ブースト・返信が出来る様に保存する foreach ($notes as $obj) self::store($obj); return $notes; } /** * 送信ボックスの項目から、そのアクターの投稿だけを取り出す */ public static function outboxNotes(array $doc, array $items): array { $actor = (string)($doc['id'] ?? ''); $followers = is_string($doc['followers'] ?? null) ? $doc['followers'] : null; $out = []; foreach ($items as $act) { if (count($out) >= self::OUTBOX_LIMIT) break; if (!is_array($act) || ($act['type'] ?? '') !== 'Create' || !is_array($act['object'] ?? null)) continue; $obj = $act['object']; $id = Fedi::id($obj['id'] ?? null); if ($id === null || Fedi::domainOf($id) !== Fedi::domainOf($actor)) continue; if (Fedi::id($obj['attributedTo'] ?? null) !== $actor) continue; if (!in_array($obj['type'] ?? '', ['Note', 'Article', 'Question', 'Page'], true)) continue; $vis = Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []), $followers); if (!in_array($vis, ['public', 'unlisted'], true)) continue; $out[] = $obj; } return $out; } /** * 「@user@host」「user@host」「@localuser」又はURLからアクターのURL */ public static function resolve(string $q): ?string { $q = trim($q); if (preg_match('#^https?://#', $q)) { $doc = self::actor($q); return $doc ? $doc['id'] : null; } if (preg_match('/^@?([A-Za-z0-9_.-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?::\d+)?)$/', $q, $m)) return self::webfinger($m[1], $m[2]); if (preg_match('/^@([A-Za-z0-9_.-]+)$/', $q, $m)) return self::localHandle($m[1]); return null; } /** * このサイトのアカウント名からアクターのURL * ブログのアカウント(FEDIINFOのactor)も含める。同じ名前のユーザーより優先する(WebFingerと同じ) */ private static function localHandle(string $user): ?string { if (ACTIVITYPUB_ENABLED && strcasecmp($user, (string)FEDIINFO['actor']) === 0) return Fedi::blogActorUrl(); return Fedi::localActor($user) ? Fedi::actorUrl($user) : null; } /** * オブジェクト(投稿)を取得する。保存しているものがあればそれを使う * * @param string $url オブジェクトのID * @return array|null */ public static function object(string $url): ?array { if (Fedi::isLocal($url)) { $ids = Fedi::localNoteId($url); $note = $ids ? Notes::get($ids[1]) : null; return $note && $note['author'] === $ids[0] ? Notes::toObject($note) : null; } $stored = self::stored($url); if ($stored) return $stored; $obj = self::fetch($url); // 他のサーバーのオブジェクトを名乗っていないか if (!$obj || !is_string($obj['id'] ?? null) || Fedi::domainOf($obj['id']) !== Fedi::domainOf($url)) return null; $author = Fedi::id($obj['attributedTo'] ?? null); if ($author === null || Fedi::domainOf($author) !== Fedi::domainOf($obj['id'])) return null; return $obj; } //////// 受け取ったオブジェクトの保存 public static function storedPath(string $id): string { return Fedi::path('remote/objects/'.sha1($id).'.json'); } /** 保存しているリモートのオブジェクト */ public static function stored(string $id): ?array { return Fedi::read(self::storedPath($id), null); } /** * リモートのオブジェクトを保存する(本文は表示する時に安全にする) */ public static function store(array $obj): void { $id = $obj['id'] ?? null; if (!is_string($id) || Fedi::isLocal($id)) return; $path = self::storedPath($id); Fedi::write($path, $obj); } public static function forget(string $id): void { @unlink(self::storedPath($id)); } /** * アクターの受付ボックス(sharedInboxがあればそちら) */ public static function inbox(array $actor, bool $shared = true): ?string { // このサイトの受付ボックスはこのサイトのアクターだけ(リモートのアクターが名乗っても使わない) $local = Fedi::isLocal((string)($actor['id'] ?? '')); $ok = fn($u) => is_string($u) && ($local ? Fedi::isLocal($u) : self::isSafeUrl($u)); $inbox = $shared ? ($actor['endpoints']['sharedInbox'] ?? null) : null; if (!$ok($inbox)) $inbox = $actor['inbox'] ?? null; return $ok($inbox) ? $inbox : null; } /** 「@user@host」 */ public static function handle(array $actor): string { $user = (string)($actor['preferredUsername'] ?? 'unknown'); return '@'.$user.'@'.Fedi::domainOf((string)($actor['id'] ?? '')); } //////// 画像のプロキシ /** * リモートの画像をこのサイト経由で表示するURL * CSPのimg-srcを広げずに済み、閲覧者のIPアドレスも相手のサーバーに渡らない。 * 署名付きなので、このサイトが出したURL以外は取得しない(踏み台にされない様に) * * @param string $url 画像のURL(空、又はこのサイトのURLならそのまま返す) * @return string */ public static function imageUrl(string $url): string { if ($url === '' || !preg_match('#^https?://#i', $url) || Fedi::isLocal($url)) return $url; // ASS_DOMAINが別のサーバーの場合、このサイトの静的ファイルは経由しない if (str_starts_with($url, ass_url().'/')) return $url; return '/microblog/media?u='.rawurlencode($url).'&s='.self::imageSig($url); } public static function imageSig(string $url): string { return substr(hash_hmac('sha256', $url, self::secret()), 0, 32); } /** * 画像を取得する(1週間キャッシュ) * * @param string $url 画像のURL * @return array|null ['path' => キャッシュのファイル, 'type' => MIME]、画像でなければnull */ public static function image(string $url): ?array { if (!self::isSafeUrl($url) || Actor::isDomainBlocked($url)) return null; $base = Fedi::path('remote/media/'.sha1($url)); $meta = Fedi::read($base.'.json', null); if (is_array($meta)) { $age = time() - (int)($meta['at'] ?? 0); if (($meta['type'] ?? '') === '' && $age < self::IMAGE_RETRY) return null; if (($meta['type'] ?? '') !== '' && $age < self::IMAGE_TTL && is_file($base.'.bin')) { return ['path' => $base.'.bin', 'type' => $meta['type']]; } } // 非公開アドレスへの接続は、リダイレクト先もCurlが拒否する $curl = new Curl($url); $curl->setHeaders(['Accept: image/*']) ->setFollowRedirects(true) ->setMaxRedirects(3) ->setTimeout(10) ->setMaxResponseSize(self::IMAGE_MAX) ->setCaInfo('/etc/ssl/cert.pem'); $res = $curl->execute(); $body = $curl->getResponseBody(); $type = $res->isSuccess && $curl->getResponseCode() === 200 && strlen($body) <= self::IMAGE_MAX ? self::imageType($body) : null; if ($type === null) { logger(\LogType::ActivityPub, "画像を取得できません:{$url}(HTTP {$curl->getResponseCode()} {$curl->getError()})"); Fedi::write($base.'.json', ['url' => $url, 'type' => '', 'at' => time()]); return null; } $tmp = $base.'.'.bin2hex(random_bytes(4)).'.tmp'; if (file_put_contents($tmp, $body) === false || !rename($tmp, $base.'.bin')) { @unlink($tmp); return null; } Fedi::write($base.'.json', ['url' => $url, 'type' => $type, 'at' => time()]); return ['path' => $base.'.bin', 'type' => $type]; } /** * 中身から画像の形式を判断する(相手のContent-Typeは信用しない) * SVGはスクリプトを含められるので受け付けない */ public static function imageType(string $data): ?string { return match (true) { str_starts_with($data, "\x89PNG\r\n\x1a\n") => 'image/png', str_starts_with($data, "\xff\xd8\xff") => 'image/jpeg', str_starts_with($data, 'GIF87a'), str_starts_with($data, 'GIF89a') => 'image/gif', substr($data, 0, 4) === 'RIFF' && substr($data, 8, 4) === 'WEBP' => 'image/webp', in_array(substr($data, 4, 8), ['ftypavif', 'ftypavis'], true) => 'image/avif', default => null, }; } /** 画像のURLの署名の鍵(data/ap/secret.key、無ければ作る) */ private static function secret(): string { if (self::$secret !== null) return self::$secret; $file = Fedi::path('secret.key'); if (!is_file($file)) { // 同時に作られても、先に出来た方を全員が使う(link()は既にあると失敗する) $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp'; file_put_contents($tmp, bin2hex(random_bytes(32))); @chmod($tmp, 0600); @link($tmp, $file); @unlink($tmp); } return self::$secret = trim((string)file_get_contents($file)); } }