| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, 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 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib\Fedi; |
| 39 | |
| 40 | use Std\Lib\Activitypub; |
| 41 | use Std\Lib\Curl; |
| 42 | |
| 43 | /** |
| 44 | * リモートのアクターとオブジェクトの取得とキャッシュ |
| 45 | * |
| 46 | * 取得はブログのアクターの鍵で署名する(Authorized fetchのサーバーにも対応)。 |
| 47 | * このサイトのURLはHTTPで取りに行かず、保存しているデータから返す。 |
| 48 | */ |
| 49 | final class Remote { |
| 50 | /** アクターのキャッシュの有効期限 */ |
| 51 | public const ACTOR_TTL = 86400; |
| 52 | /** 見つからなかったWebFingerを再び試すまで */ |
| 53 | public const WEBFINGER_RETRY = 600; |
| 54 | /** フォローした時にタイムラインに入れる、相手の最近の投稿の数 */ |
| 55 | public const OUTBOX_LIMIT = 20; |
| 56 | /** 画像のキャッシュの有効期限 */ |
| 57 | public const IMAGE_TTL = 604800; |
| 58 | /** 取得に失敗した画像を再び試すまで */ |
| 59 | public const IMAGE_RETRY = 3600; |
| 60 | /** 画像の最大サイズ */ |
| 61 | public const IMAGE_MAX = 8388608; // 8MiB |
| 62 | |
| 63 | private static ?string $secret = null; |
| 64 | |
| 65 | private static ?Activitypub $ap = null; |
| 66 | |
| 67 | private static function ap(): Activitypub { |
| 68 | return self::$ap ??= new Activitypub(); |
| 69 | } |
| 70 | |
| 71 | /** リモートに接続しても安全なURLか(https、公開アドレス等) */ |
| 72 | public static function isSafeUrl(mixed $url): bool { |
| 73 | return is_string($url) && self::ap()->isSafeRemoteUrl($url); |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * URLを取得する(キャッシュなし) |
| 78 | */ |
| 79 | public static function fetch(string $url): ?array { |
| 80 | if (Actor::isDomainBlocked($url)) return null; |
| 81 | return self::ap()->fetchRemoteJson($url); |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * アクターを取得する(1日キャッシュ) |
| 86 | * |
| 87 | * @param string $url アクターのURL |
| 88 | * @param bool $refresh キャッシュを使わない |
| 89 | * @return array|null |
| 90 | */ |
| 91 | public static function actor(string $url, bool $refresh = false, bool $canonical = true): ?array { |
| 92 | if (Fedi::isLocal($url)) { |
| 93 | $name = Fedi::localUsername($url); |
| 94 | if ($name === null) return $url === Fedi::blogActorUrl() ? json_decode(self::ap()->getActor(), true) : null; |
| 95 | $u = Fedi::localActor($name); |
| 96 | return $u ? Actor::document($u) : null; |
| 97 | } |
| 98 | |
| 99 | $file = Fedi::path('remote/actors/'.sha1($url).'.json'); |
| 100 | $cached = Fedi::read($file, null); |
| 101 | if (!$refresh && $cached && ($cached['fetchedAt'] ?? 0) > time() - self::ACTOR_TTL) return $cached['doc']; |
| 102 | |
| 103 | $doc = self::fetch($url); |
| 104 | // プロフィールのURL(Mastodonの「/@user」等)からは本当のIDのアクターが返る。同じサーバーなら、そのIDで取り直す |
| 105 | $id = is_array($doc) ? ($doc['id'] ?? null) : null; |
| 106 | if ($canonical && is_string($id) && $id !== $url && self::isActor($doc) && Fedi::domainOf($id) === Fedi::domainOf($url)) { |
| 107 | return self::actor($id, $refresh, false); |
| 108 | } |
| 109 | if (!$doc || !self::isActor($doc) || $id !== $url || !self::isSafeUrl($doc['inbox'] ?? null)) { |
| 110 | return $cached['doc'] ?? null; |
| 111 | } |
| 112 | Fedi::write($file, ['doc' => $doc, 'fetchedAt' => time()]); |
| 113 | return $doc; |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * キャッシュしているアクター(取得しない。表示用) |
| 118 | */ |
| 119 | public static function cachedActor(string $url): ?array { |
| 120 | if (Fedi::isLocal($url)) return self::actor($url); |
| 121 | return Fedi::read(Fedi::path('remote/actors/'.sha1($url).'.json'), null)['doc'] ?? null; |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * 受け取ったアクター(Update{Person}等)でキャッシュを更新する |
| 126 | */ |
| 127 | public static function cacheActor(array $doc): void { |
| 128 | $id = $doc['id'] ?? null; |
| 129 | if (!is_string($id) || Fedi::isLocal($id) || !self::isActor($doc)) return; |
| 130 | Fedi::write(Fedi::path('remote/actors/'.sha1($id).'.json'), ['doc' => $doc, 'fetchedAt' => time()]); |
| 131 | } |
| 132 | |
| 133 | public static function forgetActor(string $url): void { |
| 134 | @unlink(Fedi::path('remote/actors/'.sha1($url).'.json')); |
| 135 | } |
| 136 | |
| 137 | public static function isActor(array $doc): bool { |
| 138 | return in_array($doc['type'] ?? '', ['Person', 'Service', 'Application', 'Group', 'Organization'], true); |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * 「@user@host」をWebFingerでアクターのURLにする |
| 143 | * |
| 144 | * @return string|null |
| 145 | */ |
| 146 | public static function webfinger(string $user, string $host): ?string { |
| 147 | if (!preg_match('/^[A-Za-z0-9_.-]+$/', $user) || !preg_match('/^[A-Za-z0-9.-]+$/', $host)) return null; |
| 148 | if (strtolower($host) === strtolower(Fedi::host())) return self::localHandle($user); |
| 149 | |
| 150 | // 同じ人を何度も探さない様に、結果を1日キャッシュする |
| 151 | // 見つからなかった場合は短くする(以前は1日で、相手のサーバーが一時的に応答しないと1日探せなかった) |
| 152 | $file = Fedi::path('remote/webfinger/'.sha1(strtolower("{$user}@{$host}")).'.json'); |
| 153 | $cached = Fedi::read($file, null); |
| 154 | $ttl = ($cached['url'] ?? null) !== null ? self::ACTOR_TTL : self::WEBFINGER_RETRY; |
| 155 | if ($cached && ($cached['at'] ?? 0) > time() - $ttl) return $cached['url']; |
| 156 | |
| 157 | $jrd = self::fetch("https://{$host}/.well-known/webfinger?resource=".rawurlencode("acct:{$user}@{$host}")); |
| 158 | $url = null; |
| 159 | foreach ($jrd['links'] ?? [] as $l) { |
| 160 | if (($l['rel'] ?? '') === 'self' && str_contains((string)($l['type'] ?? ''), 'json') && self::isSafeUrl($l['href'] ?? null)) { |
| 161 | $url = $l['href']; |
| 162 | break; |
| 163 | } |
| 164 | } |
| 165 | if ($url === null) logger(\LogType::ActivityPub, "WebFingerで見つかりません:{$user}@{$host}"); |
| 166 | Fedi::write($file, ['url' => $url, 'at' => time()]); |
| 167 | return $url; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * アクターの送信ボックスから最近の投稿(公開・未収載)を取得して保存する |
| 172 | * |
| 173 | * @param array $doc アクター |
| 174 | * @return array 投稿(Note等)の配列 |
| 175 | */ |
| 176 | public static function outbox(array $doc): array { |
| 177 | $outbox = is_string($doc['outbox'] ?? null) ? self::fetch($doc['outbox']) : null; |
| 178 | if (!$outbox) return []; |
| 179 | $page = $outbox['orderedItems'] ?? null; |
| 180 | if ($page === null) { |
| 181 | $first = $outbox['first'] ?? null; |
| 182 | $page = is_array($first) ? ($first['orderedItems'] ?? []) : (is_string($first) ? (self::fetch($first)['orderedItems'] ?? []) : []); |
| 183 | } |
| 184 | $notes = self::outboxNotes($doc, (array)$page); |
| 185 | // いいね・ブースト・返信が出来る様に保存する |
| 186 | foreach ($notes as $obj) self::store($obj); |
| 187 | return $notes; |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * 送信ボックスの項目から、そのアクターの投稿だけを取り出す |
| 192 | */ |
| 193 | public static function outboxNotes(array $doc, array $items): array { |
| 194 | $actor = (string)($doc['id'] ?? ''); |
| 195 | $followers = is_string($doc['followers'] ?? null) ? $doc['followers'] : null; |
| 196 | $out = []; |
| 197 | foreach ($items as $act) { |
| 198 | if (count($out) >= self::OUTBOX_LIMIT) break; |
| 199 | if (!is_array($act) || ($act['type'] ?? '') !== 'Create' || !is_array($act['object'] ?? null)) continue; |
| 200 | $obj = $act['object']; |
| 201 | $id = Fedi::id($obj['id'] ?? null); |
| 202 | if ($id === null || Fedi::domainOf($id) !== Fedi::domainOf($actor)) continue; |
| 203 | if (Fedi::id($obj['attributedTo'] ?? null) !== $actor) continue; |
| 204 | if (!in_array($obj['type'] ?? '', ['Note', 'Article', 'Question', 'Page'], true)) continue; |
| 205 | $vis = Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []), $followers); |
| 206 | if (!in_array($vis, ['public', 'unlisted'], true)) continue; |
| 207 | $out[] = $obj; |
| 208 | } |
| 209 | return $out; |
| 210 | } |
| 211 | |
| 212 | /** |
| 213 | * 「@user@host」「user@host」「@localuser」又はURLからアクターのURL |
| 214 | */ |
| 215 | public static function resolve(string $q): ?string { |
| 216 | $q = trim($q); |
| 217 | if (preg_match('#^https?://#', $q)) { |
| 218 | $doc = self::actor($q); |
| 219 | return $doc ? $doc['id'] : null; |
| 220 | } |
| 221 | 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]); |
| 222 | if (preg_match('/^@([A-Za-z0-9_.-]+)$/', $q, $m)) return self::localHandle($m[1]); |
| 223 | return null; |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * このサイトのアカウント名からアクターのURL |
| 228 | * ブログのアカウント(FEDIINFOのactor)も含める。同じ名前のユーザーより優先する(WebFingerと同じ) |
| 229 | */ |
| 230 | private static function localHandle(string $user): ?string { |
| 231 | if (ACTIVITYPUB_ENABLED && strcasecmp($user, (string)FEDIINFO['actor']) === 0) return Fedi::blogActorUrl(); |
| 232 | return Fedi::localActor($user) ? Fedi::actorUrl($user) : null; |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * オブジェクト(投稿)を取得する。保存しているものがあればそれを使う |
| 237 | * |
| 238 | * @param string $url オブジェクトのID |
| 239 | * @return array|null |
| 240 | */ |
| 241 | public static function object(string $url): ?array { |
| 242 | if (Fedi::isLocal($url)) { |
| 243 | $ids = Fedi::localNoteId($url); |
| 244 | $note = $ids ? Notes::get($ids[1]) : null; |
| 245 | return $note && $note['author'] === $ids[0] ? Notes::toObject($note) : null; |
| 246 | } |
| 247 | $stored = self::stored($url); |
| 248 | if ($stored) return $stored; |
| 249 | |
| 250 | $obj = self::fetch($url); |
| 251 | // 他のサーバーのオブジェクトを名乗っていないか |
| 252 | if (!$obj || !is_string($obj['id'] ?? null) || Fedi::domainOf($obj['id']) !== Fedi::domainOf($url)) return null; |
| 253 | $author = Fedi::id($obj['attributedTo'] ?? null); |
| 254 | if ($author === null || Fedi::domainOf($author) !== Fedi::domainOf($obj['id'])) return null; |
| 255 | return $obj; |
| 256 | } |
| 257 | |
| 258 | //////// 受け取ったオブジェクトの保存 |
| 259 | |
| 260 | public static function storedPath(string $id): string { |
| 261 | return Fedi::path('remote/objects/'.sha1($id).'.json'); |
| 262 | } |
| 263 | |
| 264 | /** 保存しているリモートのオブジェクト */ |
| 265 | public static function stored(string $id): ?array { |
| 266 | return Fedi::read(self::storedPath($id), null); |
| 267 | } |
| 268 | |
| 269 | /** |
| 270 | * リモートのオブジェクトを保存する(本文は表示する時に安全にする) |
| 271 | */ |
| 272 | public static function store(array $obj): void { |
| 273 | $id = $obj['id'] ?? null; |
| 274 | if (!is_string($id) || Fedi::isLocal($id)) return; |
| 275 | $path = self::storedPath($id); |
| 276 | Fedi::write($path, $obj); |
| 277 | } |
| 278 | |
| 279 | public static function forget(string $id): void { |
| 280 | @unlink(self::storedPath($id)); |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * アクターの受付ボックス(sharedInboxがあればそちら) |
| 285 | */ |
| 286 | public static function inbox(array $actor, bool $shared = true): ?string { |
| 287 | // このサイトの受付ボックスはこのサイトのアクターだけ(リモートのアクターが名乗っても使わない) |
| 288 | $local = Fedi::isLocal((string)($actor['id'] ?? '')); |
| 289 | $ok = fn($u) => is_string($u) && ($local ? Fedi::isLocal($u) : self::isSafeUrl($u)); |
| 290 | $inbox = $shared ? ($actor['endpoints']['sharedInbox'] ?? null) : null; |
| 291 | if (!$ok($inbox)) $inbox = $actor['inbox'] ?? null; |
| 292 | return $ok($inbox) ? $inbox : null; |
| 293 | } |
| 294 | |
| 295 | /** 「@user@host」 */ |
| 296 | public static function handle(array $actor): string { |
| 297 | $user = (string)($actor['preferredUsername'] ?? 'unknown'); |
| 298 | return '@'.$user.'@'.Fedi::domainOf((string)($actor['id'] ?? '')); |
| 299 | } |
| 300 | |
| 301 | //////// 画像のプロキシ |
| 302 | |
| 303 | /** |
| 304 | * リモートの画像をこのサイト経由で表示するURL |
| 305 | * CSPのimg-srcを広げずに済み、閲覧者のIPアドレスも相手のサーバーに渡らない。 |
| 306 | * 署名付きなので、このサイトが出したURL以外は取得しない(踏み台にされない様に) |
| 307 | * |
| 308 | * @param string $url 画像のURL(空、又はこのサイトのURLならそのまま返す) |
| 309 | * @return string |
| 310 | */ |
| 311 | public static function imageUrl(string $url): string { |
| 312 | if ($url === '' || !preg_match('#^https?://#i', $url) || Fedi::isLocal($url)) return $url; |
| 313 | // ASS_DOMAINが別のサーバーの場合、このサイトの静的ファイルは経由しない |
| 314 | if (str_starts_with($url, ass_url().'/')) return $url; |
| 315 | return '/microblog/media?u='.rawurlencode($url).'&s='.self::imageSig($url); |
| 316 | } |
| 317 | |
| 318 | public static function imageSig(string $url): string { |
| 319 | return substr(hash_hmac('sha256', $url, self::secret()), 0, 32); |
| 320 | } |
| 321 | |
| 322 | /** |
| 323 | * 画像を取得する(1週間キャッシュ) |
| 324 | * |
| 325 | * @param string $url 画像のURL |
| 326 | * @return array|null ['path' => キャッシュのファイル, 'type' => MIME]、画像でなければnull |
| 327 | */ |
| 328 | public static function image(string $url): ?array { |
| 329 | if (!self::isSafeUrl($url) || Actor::isDomainBlocked($url)) return null; |
| 330 | $base = Fedi::path('remote/media/'.sha1($url)); |
| 331 | $meta = Fedi::read($base.'.json', null); |
| 332 | if (is_array($meta)) { |
| 333 | $age = time() - (int)($meta['at'] ?? 0); |
| 334 | if (($meta['type'] ?? '') === '' && $age < self::IMAGE_RETRY) return null; |
| 335 | if (($meta['type'] ?? '') !== '' && $age < self::IMAGE_TTL && is_file($base.'.bin')) { |
| 336 | return ['path' => $base.'.bin', 'type' => $meta['type']]; |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | // 非公開アドレスへの接続は、リダイレクト先もCurlが拒否する |
| 341 | $curl = new Curl($url); |
| 342 | $curl->setHeaders(['Accept: image/*']) |
| 343 | ->setFollowRedirects(true) |
| 344 | ->setMaxRedirects(3) |
| 345 | ->setTimeout(10) |
| 346 | ->setMaxResponseSize(self::IMAGE_MAX) |
| 347 | ->setCaInfo('/etc/ssl/cert.pem'); |
| 348 | $res = $curl->execute(); |
| 349 | $body = $curl->getResponseBody(); |
| 350 | $type = $res->isSuccess && $curl->getResponseCode() === 200 && strlen($body) <= self::IMAGE_MAX ? self::imageType($body) : null; |
| 351 | if ($type === null) { |
| 352 | logger(\LogType::ActivityPub, "画像を取得できません:{$url}(HTTP {$curl->getResponseCode()} {$curl->getError()})"); |
| 353 | Fedi::write($base.'.json', ['url' => $url, 'type' => '', 'at' => time()]); |
| 354 | return null; |
| 355 | } |
| 356 | $tmp = $base.'.'.bin2hex(random_bytes(4)).'.tmp'; |
| 357 | if (file_put_contents($tmp, $body) === false || !rename($tmp, $base.'.bin')) { |
| 358 | @unlink($tmp); |
| 359 | return null; |
| 360 | } |
| 361 | Fedi::write($base.'.json', ['url' => $url, 'type' => $type, 'at' => time()]); |
| 362 | return ['path' => $base.'.bin', 'type' => $type]; |
| 363 | } |
| 364 | |
| 365 | /** |
| 366 | * 中身から画像の形式を判断する(相手のContent-Typeは信用しない) |
| 367 | * SVGはスクリプトを含められるので受け付けない |
| 368 | */ |
| 369 | public static function imageType(string $data): ?string { |
| 370 | return match (true) { |
| 371 | str_starts_with($data, "\x89PNG\r\n\x1a\n") => 'image/png', |
| 372 | str_starts_with($data, "\xff\xd8\xff") => 'image/jpeg', |
| 373 | str_starts_with($data, 'GIF87a'), str_starts_with($data, 'GIF89a') => 'image/gif', |
| 374 | substr($data, 0, 4) === 'RIFF' && substr($data, 8, 4) === 'WEBP' => 'image/webp', |
| 375 | in_array(substr($data, 4, 8), ['ftypavif', 'ftypavis'], true) => 'image/avif', |
| 376 | default => null, |
| 377 | }; |
| 378 | } |
| 379 | |
| 380 | /** 画像のURLの署名の鍵(data/ap/secret.key、無ければ作る) */ |
| 381 | private static function secret(): string { |
| 382 | if (self::$secret !== null) return self::$secret; |
| 383 | $file = Fedi::path('secret.key'); |
| 384 | if (!is_file($file)) { |
| 385 | // 同時に作られても、先に出来た方を全員が使う(link()は既にあると失敗する) |
| 386 | $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp'; |
| 387 | file_put_contents($tmp, bin2hex(random_bytes(32))); |
| 388 | @chmod($tmp, 0600); |
| 389 | @link($tmp, $file); |
| 390 | @unlink($tmp); |
| 391 | } |
| 392 | return self::$secret = trim((string)file_get_contents($file)); |
| 393 | } |
| 394 | } |